commit a33fa24e99fdcf90fb3ec48f177c6c692e8727a7 Author: Artem Tsyrulnikov <1+root@noreply.git.tlartem.ru> Date: Wed Aug 5 19:31:35 2026 +0300 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3e860ef --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +APP__ORIGINS=["http://locahost:3000"] +DB__URL=postgres://user:password@localhost:5432/tgex + +LOGGER__APP_NAME=tgex-backend +LOGGER__APP_VERSION=0.0.0 +LOGGER__PRETTY_CONSOLE=true +LOGGER__LEVEL=info + +JWT__SECRET_KEY=your_secret_key_here_please_generate_a_strong_random_string + +PARSER__URL=http://localhost:8080 + +S3__ENDPOINT_URL=http://localhost:9000 +S3__ACCESS_KEY_ID=user +S3__SECRET_ACCESS_KEY=password +S3__BUCKET_NAME=tgex-files + +TELEGRAM__TOKEN=your_bot_token_here +BACKEND__BASE_URL=http://localhost:8000 +LOGIN_URL=https://app.example.com/api/v1/auth/complete?token= + +TELEGRAM__API_ID=123456 +TELEGRAM__API_HASH=your_api_hash_here +TELEGRAM__SESSION_FILE=/data/tg_parser.json +TELEGRAM__PHONE=+79999999999 +TELEGRAM__PASSWORD= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..3047252 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,20 @@ +name: Build & Push +on: + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Login to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Build and push + run: | + docker build -t ghcr.io/${{ github.repository }}/app:latest . + docker push ghcr.io/${{ github.repository }}/app:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee448f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,133 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +.vscode/ +.idea/ + +# PyPI configuration file +.pypirc +docker-compose.yml +.uv_cache/ + + +session.json +tg_parser.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..314dc56 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,416 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a FastAPI + Telegram Bot backend application for managing Telegram channel integrations. The project uses: +- **FastAPI** for HTTP API endpoints +- **aiogram** for Telegram bot functionality +- **Tortoise ORM** (async) for database operations +- **PostgreSQL** with asyncpg driver +- **aerich** for database migrations +- **uv** for Python package management + +## Development Commands + +### Environment Setup +```bash +# Install dependencies (using uv) +uv sync + +# Start PostgreSQL (via Docker Compose) +docker-compose up -d + +# Run database migrations +make migrate-up # or: export $(cat .env | xargs) && aerich upgrade +``` + +### Running the Application +```bash +# Start the FastAPI server +uv run uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload +``` + +### Code Quality +```bash +# Run linter (Ruff) +uv run ruff check . + +# Format code +uv run ruff format . + +# Type checking (mypy with strict mode) +uv run mypy . +``` + +### Testing +```bash +# Run all tests +uv run pytest + +# Run a single test file +uv run pytest tests/test_file.py + +# Run a specific test +uv run pytest tests/test_file.py::test_function_name +``` + +### Database Migrations +```bash +# Create a new migration (auto-generated based on model changes) +make migrate-create # or: source .env && aerich migrate + +# Apply migrations +make migrate-up # or: source .env && aerich upgrade + +# Rollback one migration +make migrate-down # or: source .env && aerich downgrade +``` + +**Important Migration Workflow:** + +1. **For simple model changes**: Let aerich auto-generate the migration: + ```bash + source .env && aerich migrate --name "descriptive_name" + ``` + +2. **For complex refactorings** (table renames, data migration, etc.): + - **FIRST**: Update domain models to reflect the new structure + - **THEN**: Run `aerich migrate` to auto-generate the migration file + - **FINALLY**: Edit the generated file to add data migration logic + + Example workflow: + ```bash + # 1. Update models in src/domain/ + # 2. Generate migration (will create base ALTER TABLE statements) + source .env && aerich migrate --name "refactor_tables" + # 3. Edit the generated file in migrations/models/ to add: + # - Data migration SQL (INSERT ... SELECT) + # - Proper ordering of operations + # - Comments explaining complex logic + ``` + +3. **For custom constraints** (CHECK, custom indexes, etc.): + ```bash + source .env && aerich migrate --name "add_check_constraint" --empty + # Then edit and add: # ruff: noqa and # mypy: ignore-errors at the top + ``` + +**Migration Best Practices:** +- Always test migrations on a copy of production data +- Use transactions (`RUN_IN_TRANSACTION = True`) +- Add comments explaining complex data transformations +- For table renames with data migration: + 1. Rename old table to `old_*` + 2. Create new table structure + 3. Migrate data with `INSERT INTO new SELECT ... FROM old` + 4. Drop old table last +- Include proper downgrade logic (even if it loses some data) + +## Architecture + +### Layered Architecture + +The codebase follows a clean architecture pattern with clear separation of concerns: + +1. **Domain Layer** (`src/domain/`) + - Tortoise ORM models that represent database entities + - All models inherit from `domain.base.TimestampedModel` which provides: + - Auto-generated `id` (UUID primary key) + - Timestamps: `created_at`, `updated_at`, `deleted_at` + - Timezone-aware datetime fields + - Use `class Meta: table = 'table_name'` for explicit table naming + +2. **Use Case Layer** (`src/usecase/`) + - Business logic functions organized by feature + - Each use case is a standalone function with signature: `async def use_case_name(self: 'Usecase', ...) -> ReturnType` + - Depends on Protocol interfaces (Database, TelegramBotWriter, JWTEncoder) + - Use cases are assembled in the `Usecase` dataclass for dependency injection + - **IMPORTANT RULES:** + - Один use case отвечает за полноценный сценарий «от триггера до завершения» + - Use case НЕ должен вызывать другие use cases (общую логику выносить в хелперы/сервисы) + - Use case содержит ВСЮ бизнес-логику, включая управление состояниями диалога + - Use case может отправлять сообщения пользователям через `self.telegram_bot` protocol + - Всегда оборачивать операции БД в `async with self.database.transaction()` + +3. **Adapter Layer** (`src/adapter/`) + - Concrete implementations of protocol interfaces: + - `Postgres`: Database operations (implements Database protocol) + - `Telegram`: Telegram bot integration (implements TelegramWriter protocol) + - `JWT`: Token encoding/decoding (implements JWTEncoder protocol) + +4. **Controller Layer** (`src/controller/`) + - **HTTP controllers** (`src/controller/http/`): FastAPI route handlers + - **Telegram callbacks** (`src/controller/telegram_callback/`): Telegram event handlers + - **ВАЖНО: Controllers - это тонкий слой роутинга** + - Контроллер только извлекает данные из событий (HTTP request / Telegram update) + - Валидирует базовые параметры (user exists, message not empty) + - Вызывает соответствующий use case + - НЕ содержит бизнес-логику + - НЕ обращается к базе данных напрямую + - НЕ отправляет сообщения пользователям + - Все handlers регистрируются через декораторы на `telegram_callback_router` + - Handlers импортируются в `__init__.py` для auto-регистрации + +5. **DTO Layer** (`src/dto/`) + - Pydantic models for request/response validation + - Separate from domain models to decouple API contracts from database schema + +### Key Architectural Patterns + +**Dependency Injection via Protocols:** +- Use cases depend on Protocol interfaces, not concrete implementations +- Allows for easy testing and swapping implementations +- See `src/usecase/__init__.py` for protocol definitions + +**Transaction Management:** +- Database uses context-aware transactions via `DatabaseBase.transaction()` +- Session stored in ContextVar (`_session_ctx`) for implicit session access +- Always use `async with database.transaction()` for database operations +- The session is accessible via `database.session` within transaction context + +**Shared Base Classes:** +- `shared/datebase_base.py`: Base database class with connection pooling and migration checking +- `shared/telegram_base.py`: Base Telegram bot class with polling lifecycle +- `shared/logger/`: Structured logging with JSON and console formatters + +**Configuration:** +- All config in `src/config.py` using Pydantic Settings +- Environment variables loaded from `.env` file +- Nested config via double underscore: `DB__URL`, `TELEGRAM__TOKEN` + +### Application Lifecycle + +1. **Startup** (in `src/main.py`): + - Logger initialized + - Adapters instantiated (Postgres, Telegram, JWT) + - Usecase dataclass created with adapter dependencies + - FastAPI lifespan context: + - Connect to database (includes migration check) + - Start Telegram bot polling + +2. **Request Flow**: + - HTTP: FastAPI route → Controller → Usecase → Adapter → Database + - Telegram: Event → Telegram callback → Usecase → Adapter → Database + +3. **Shutdown**: + - Stop Telegram bot polling + - Close database connections + +### Important Implementation Details + +**Database Session Management:** +- DO NOT create sessions manually +- Use `async with database.transaction()` which handles begin/commit/rollback +- Access session via `database.session` property within transaction context +- Session lifecycle is managed by ContextVars for thread-safety + +**Telegram Bot Integration:** +- Bot runs in polling mode (not webhook) +- Routers registered in `src/controller/telegram_callback/` +- Base class handles graceful shutdown of polling task + +**Authentication:** +- JWT-based authentication for HTTP endpoints +- Login flow: Telegram bot → Login token → JWT access token +- Use `get_current_user` dependency for protected routes + +**Code Style:** +- Single quotes for strings (configured in Ruff) +- Line length: 120 characters +- Python 3.13+ syntax +- Strict mypy typing enabled + +### Telegram Bot Patterns + +**State Management:** +- Состояния хранятся в БД через модель `TelegramState` +- Используется `TelegramStateEnum` для типизации состояний (не строки!) +- Database protocol методы: `get_telegram_state()`, `set_telegram_state()`, `clear_telegram_state()` + +**Controller Pattern (Telegram):** +```python +from aiogram import F +from aiogram.filters import Command +from aiogram.types import Message + +@telegram_callback_router.message(Command('command_name')) +async def cmd_handler(message: Message) -> None: + if not message.from_user: + log.error('Failed to get user data') + return + + usecase = dependencies.get_usecase() + await usecase.some_use_case( + telegram_id=message.from_user.id, + chat_id=message.chat.id + ) +``` + +**Use Case Pattern (Telegram):** +```python +from src import domain + +async def some_use_case(self: 'Usecase', telegram_id: int, chat_id: int) -> None: + async with self.database.transaction(): + # 1. Получить/проверить пользователя + user = await self.database.get_user(telegram_id=telegram_id) + if not user: + await self.telegram_bot.send_message('❌ Не авторизован', chat_id=chat_id) + return + + # 2. Получить состояние (если нужно) + state = await self.database.get_telegram_state(telegram_id) + + # 3. Бизнес-логика + # ... + + # 4. Обновить состояние + await self.database.set_telegram_state( + telegram_id=telegram_id, + state=domain.TelegramStateEnum.SOME_STATE, + context={'key': 'value'} + ) + + # 5. Отправить сообщение + await self.telegram_bot.send_message('✅ Успешно', chat_id=chat_id) +``` + +**Inline Keyboard Pattern:** +```python +from aiogram.types import InlineKeyboardButton + +# В use case: +buttons = [ + [InlineKeyboardButton(text='Кнопка', callback_data='callback_id:value')] + for item in items +] +await self.telegram_bot.send_message_with_inline_keyboard( + 'Выберите опцию:', chat_id=chat_id, buttons=buttons +) +``` + +**Callback Query Handler:** +```python +@telegram_callback_router.callback_query(F.data.startswith('prefix:')) +async def callback_handler(callback: CallbackQuery) -> None: + if not callback.from_user or not callback.data: + return + + usecase = dependencies.get_usecase() + await usecase.handle_callback( + telegram_id=callback.from_user.id, + chat_id=callback.message.chat.id, + callback_data=callback.data, + message_id=callback.message.message_id + ) + await callback.answer() # Убрать "loading" на кнопке +``` + +**Text Message Filters:** +```python +# Обрабатывать только не-команды +@telegram_callback_router.message(F.text & ~F.text.startswith('/')) +async def text_handler(message: Message) -> None: + # Этот handler НЕ сработает для команд типа /start + ... +``` + +**Важные правила для Telegram handlers:** +- Специфичные фильтры (Command) регистрировать РАНЬШЕ общих (F.text) +- Использовать `~F.text.startswith('/')` для исключения команд в текстовых handlers +- Всегда проверять `message.from_user` перед использованием +- Callback handlers должны вызывать `await callback.answer()` в конце +- НЕ импортировать aiogram типы в usecase layer (только через TYPE_CHECKING в Protocol) + +### Adding New Protocol Methods + +When adding new functionality that requires protocol methods: + +**1. Define Protocol in usecase layer (`src/usecase/__init__.py`):** +```python +if typing.TYPE_CHECKING: + from aiogram.types import InlineKeyboardButton # Импорт только для типов + +class TelegramBotWriter(typing.Protocol): + async def send_message_with_inline_keyboard( + self, text: str, chat_id: int, buttons: list[list['InlineKeyboardButton']] + ) -> None: ... +``` + +**2. Implement in adapter (`src/adapter/telegram_bot.py`):** +```python +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + +class TelegramBot(TelegramBase): + async def send_message_with_inline_keyboard( + self, text: str, chat_id: int, buttons: list[list[InlineKeyboardButton]] + ) -> None: + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) + await self.bot.send_message(chat_id=chat_id, text=text, reply_markup=keyboard) +``` + +**3. Use in use case:** +```python +from aiogram.types import InlineKeyboardButton # Можно импортировать в use case + +buttons = [[InlineKeyboardButton(text='Text', callback_data='data')]] +await self.telegram_bot.send_message_with_inline_keyboard('Message', chat_id, buttons) +``` + +**Типизация Enum в Domain:** +- Используйте `enum.StrEnum` для строковых enum (Python 3.11+) +- Enum значения в lowercase с underscores +- Используйте `fields.CharEnumField()` в Tortoise ORM + +```python +import enum +from tortoise import fields +from .base import TimestampedModel + +class TelegramStateEnum(enum.StrEnum): + CREATIVE_WAITING_CHANNEL = 'creative_waiting_channel' + CREATIVE_WAITING_NAME = 'creative_waiting_name' + +class TelegramState(TimestampedModel): + state = fields.CharEnumField(TelegramStateEnum, max_length=26) + + class Meta: + table = 'telegram_state' +``` + +**Database Constraints:** +- Foreign keys: Use `fields.ForeignKeyField()` with `on_delete=fields.CASCADE` +- Unique constraints: Use `unique=True` or `unique_together` in Meta +- CHECK constraints: Cannot be defined in Tortoise models - create manual migrations +- Indexes: Use `index=True` on fields or define in Meta + +Example with CHECK constraint: +```bash +# Create empty migration +export $(cat .env | xargs) && aerich migrate --name "add_check_constraint" --empty +``` + +Then edit the created file: +```python +# migrations/models/1_xxx_add_check_constraint.py +# ruff: noqa +# mypy: ignore-errors +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "table_name" + ADD CONSTRAINT "constraint_name" + CHECK (your_condition); + """ + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "table_name" + DROP CONSTRAINT IF EXISTS "constraint_name"; + """ +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e604bcc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.13-slim-bookworm +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen + +COPY src ./src +COPY shared ./shared +COPY migrations ./migrations + +ENV PYTHONPATH=/shared:/src +ARG GIT_COMMIT=unknown +ENV GIT_COMMIT=${GIT_COMMIT} + +EXPOSE 8000 + +CMD [".venv/bin/uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..268b616 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +test: + pytest --no-header + +build-parser: + docker build --platform linux/amd64 -f ./tg_parser/Dockerfile -t tg-parser:latest . + docker save tg-parser:latest -o tg-parser-x86.tar + +env: + cp .env.example .env + +run: + uv run uvicorn src.main:app + +up: + GIT_COMMIT_COUNT=$$(git rev-list --count HEAD 2>/dev/null) docker compose up --build -d --force-recreate + docker compose logs -f + +migrate-init: + export $$(cat .env | xargs) && aerich init-db + +migrate-create: + export $$(cat .env | xargs) && aerich migrate + +migrate-up: + export $$(cat .env | xargs) && aerich upgrade + +down: + docker compose down + +clean: + @docker rm -f $$(docker ps -aq) 2>/dev/null || true + @docker rmi -f $$(docker images --filter "dangling=true" -q) 2>/dev/null || true + @docker images --format "{{.Repository}}:{{.Tag}}" | grep -E "^(crm|local)" | xargs -r docker rmi -f 2>/dev/null || true + @docker volume rm $$(docker volume ls -q) 2>/dev/null || true + @docker network rm $$(docker network ls -q | grep -vE 'bridge|host|none') 2>/dev/null || true + @docker system prune --volumes -f + @echo "${GREEN}Docker очистка завершена. Базовые образы из registry сохранены.${NC}" diff --git a/README.md b/README.md new file mode 100644 index 0000000..17f542a --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# tgex-backend + +## Быстрый старт + +```bash +# Установить зависимости +uv sync + +# Скопировать примеры конфигов +cp docker-compose.yml.example docker-compose.yml +cp .env.example .env + +# Поднять Postgres и сервисы +make up + +# (первый запуск) проинициализировать базу и таблицы aerich +make migrate-init + +# Применить актуальные миграции +make migrate-up +``` + +Открыть: +- API: http://localhost:8000 +- Swagger: http://localhost:8000/docs + +## Полезные команды + +```bash +make up # поднять приложение и Postgres +make down # остановить контейнеры +make migrate-init # однократная инициализация базы (aerich init-db) +make migrate-up # применить миграции (aerich upgrade) +make migrate-create # сгенерировать новую миграцию (aerich migrate) +make test # pytest +uv run ruff check . # линтер +uv run mypy . # типизация +``` + +## Архитектура + +- FastAPI + PostgreSQL + Tortoise ORM, aiogram 3.x для бота +- Слои: `domain` (модели/правила), `usecase` (бизнес-логика), `adapter` (БД/бот/JWT), `controller` (HTTP + Telegram), `dto` +- Все use case оборачиваются в `self.database.transaction()` и проверяют права через `domain.WorkspacePermissions` +- Миграции и схема БД управляются aerich (`migrations/models`) diff --git a/docker-compose.yml.example b/docker-compose.yml.example new file mode 100644 index 0000000..2327201 --- /dev/null +++ b/docker-compose.yml.example @@ -0,0 +1,73 @@ +services: + postgres: + image: postgres:15 + environment: + POSTGRES_USER: "user" + POSTGRES_PASSWORD: "password" + POSTGRES_DB: "tgex" + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: "user" + MINIO_ROOT_PASSWORD: "password" + ports: + - "9001:9001" # Web Console + volumes: + - minio_data:/data + + backend: + build: + context: . + ports: + - "8000:8000" + depends_on: + - postgres + - minio + environment: + DB__URL: "postgres://user:password@postgres:5432/tgex" + S3__ENDPOINT_URL: "http://minio:9000" + PARSER__URL: "http://tg_parser:8080" + GIT_COMMIT_COUNT: "${GIT_COMMIT_COUNT:-}" + env_file: + - .env + restart: unless-stopped + + tg_bot: + build: + context: . + dockerfile: tg_bot/Dockerfile + depends_on: + - backend + environment: + BACKEND__BASE_URL: "http://backend:8000" + env_file: + - .env + restart: unless-stopped + + tg_parser: + build: + context: . + dockerfile: tg_parser/Dockerfile + depends_on: + - postgres + environment: + DB__URL: "postgres://user:password@postgres:5432/tgex" + LOGGER__LEVEL: "info" + LOGGER__PRETTY_CONSOLE: "true" + expose: + - 8080 + env_file: + - .env + volumes: + - ./tg_parser.json:/data/tg_parser.json + restart: unless-stopped + +volumes: + postgres_data: + minio_data: diff --git a/migrations/models/0_20260107150827_init.py b/migrations/models/0_20260107150827_init.py new file mode 100644 index 0000000..9b8f5ec --- /dev/null +++ b/migrations/models/0_20260107150827_init.py @@ -0,0 +1,343 @@ +# ruff: noqa +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "channel" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "telegram_id" BIGINT NOT NULL UNIQUE, + "title" VARCHAR(255) NOT NULL, + "username" VARCHAR(255) NOT NULL UNIQUE, + "access_hash" BIGINT, + "pts" INT +); +CREATE INDEX IF NOT EXISTS "idx_channel_telegra_414c3b" ON "channel" ("telegram_id"); +CREATE INDEX IF NOT EXISTS "idx_channel_usernam_7fd87a" ON "channel" ("username"); +CREATE TABLE IF NOT EXISTS "post" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "message_id" INT NOT NULL, + "text" TEXT NOT NULL, + "deleted_from_channel_at" TIMESTAMPTZ, + "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_post_channel_0a2cba" UNIQUE ("channel_id", "message_id") +); +CREATE INDEX IF NOT EXISTS "idx_post_channel_197bed" ON "post" ("channel_id"); +CREATE TABLE IF NOT EXISTS "post_views_history" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "views_count" INT NOT NULL, + "fetched_at" TIMESTAMPTZ NOT NULL, + "post_id" UUID NOT NULL REFERENCES "post" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_post_views__post_id_c6cc9f" UNIQUE ("post_id", "fetched_at") +); +CREATE INDEX IF NOT EXISTS "idx_post_views__fetched_103eb5" ON "post_views_history" ("fetched_at"); +CREATE INDEX IF NOT EXISTS "idx_post_views__post_id_dfb1ca" ON "post_views_history" ("post_id"); +CREATE TABLE IF NOT EXISTS "telegram_user" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "telegram_id" BIGINT NOT NULL UNIQUE, + "username" VARCHAR(255), + "first_name" VARCHAR(255), + "last_name" VARCHAR(255) +); +CREATE INDEX IF NOT EXISTS "idx_telegram_us_telegra_3a3100" ON "telegram_user" ("telegram_id"); +CREATE TABLE IF NOT EXISTS "user" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "telegram_user_id" UUID NOT NULL UNIQUE REFERENCES "telegram_user" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_user_telegra_444a03" ON "user" ("telegram_user_id"); +CREATE TABLE IF NOT EXISTS "login_token" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "token" VARCHAR(255) NOT NULL UNIQUE, + "expires_at" TIMESTAMPTZ NOT NULL, + "used_at" TIMESTAMPTZ, + "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_login_token_token_2582d2" ON "login_token" ("token"); +CREATE INDEX IF NOT EXISTS "idx_login_token_user_id_c85d7c" ON "login_token" ("user_id"); +CREATE TABLE IF NOT EXISTS "workspace" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "name" VARCHAR(255) NOT NULL +); +CREATE TABLE IF NOT EXISTS "project" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "purchase_invite_type_default" VARCHAR(10) NOT NULL DEFAULT 'approval', + "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, + "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_project_workspa_2f80f2" UNIQUE ("workspace_id", "channel_id") +); +CREATE INDEX IF NOT EXISTS "idx_project_channel_ce2466" ON "project" ("channel_id"); +CREATE INDEX IF NOT EXISTS "idx_project_workspa_2354fd" ON "project" ("workspace_id"); +COMMENT ON COLUMN "project"."status" IS 'ACTIVE: active\nINACTIVE: inactive\nARCHIVED: archived'; +COMMENT ON COLUMN "project"."purchase_invite_type_default" IS 'PUBLIC: public\nAPPROVAL: approval'; +CREATE TABLE IF NOT EXISTS "creative" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "name" VARCHAR(255) NOT NULL, + "text" TEXT NOT NULL, + "media_type" VARCHAR(32), + "media_file_id" VARCHAR(512), + "media_s3_key" VARCHAR(512), + "buttons" JSONB NOT NULL, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_creative_project_0766a6" ON "creative" ("project_id"); +COMMENT ON COLUMN "creative"."status" IS 'ACTIVE: active\nARCHIVED: archived'; +CREATE TABLE IF NOT EXISTS "purchase" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "placement_at" TIMESTAMPTZ, + "payment_at" TIMESTAMPTZ, + "cost_type" VARCHAR(8), + "cost_value" DOUBLE PRECISION, + "cost_before_bargain" DOUBLE PRECISION, + "purchase_type" VARCHAR(16), + "format" TEXT, + "comment" TEXT, + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE, + "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_purchase_creativ_6829cd" ON "purchase" ("creative_id"); +CREATE INDEX IF NOT EXISTS "idx_purchase_project_95b1c2" ON "purchase" ("project_id"); +CREATE INDEX IF NOT EXISTS "idx_purchase_project_dee224" ON "purchase" ("project_id", "status"); +COMMENT ON COLUMN "purchase"."status" IS 'ACTIVE: active\nARCHIVED: archived'; +COMMENT ON COLUMN "purchase"."cost_type" IS 'FIXED: fixed\nCPM: cpm'; +COMMENT ON COLUMN "purchase"."purchase_type" IS 'SELF_PROMO: self_promo\nSTANDARD: standard'; +CREATE TABLE IF NOT EXISTS "purchase_channel" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "status" VARCHAR(11) NOT NULL DEFAULT 'planned', + "placement_at" TIMESTAMPTZ, + "cost_type" VARCHAR(8), + "cost_value" DOUBLE PRECISION, + "cost_before_bargain" DOUBLE PRECISION, + "format" TEXT, + "comment" TEXT, + "invite_link" VARCHAR(512) NOT NULL, + "invite_link_type" VARCHAR(8) NOT NULL, + "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, + "purchase_id" UUID NOT NULL REFERENCES "purchase" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_purchase_ch_purchas_f7c86e" UNIQUE ("purchase_id", "channel_id") +); +CREATE INDEX IF NOT EXISTS "idx_purchase_ch_channel_2b1264" ON "purchase_channel" ("channel_id"); +CREATE INDEX IF NOT EXISTS "idx_purchase_ch_purchas_1fe84a" ON "purchase_channel" ("purchase_id"); +COMMENT ON COLUMN "purchase_channel"."status" IS 'PLANNED: planned\nAPPROVED: approved\nREJECTED: rejected\nIN_PROGRESS: in_progress\nCOMPLETED: completed'; +COMMENT ON COLUMN "purchase_channel"."cost_type" IS 'FIXED: fixed\nCPM: cpm'; +COMMENT ON COLUMN "purchase_channel"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval'; +CREATE TABLE IF NOT EXISTS "placement" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "wanted_placement_date" TIMESTAMPTZ NOT NULL, + "cost" DOUBLE PRECISION, + "comment" TEXT, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE, + "post_id" UUID REFERENCES "post" ("id") ON DELETE SET NULL, + "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE, + "purchase_channel_id" UUID NOT NULL REFERENCES "purchase_channel" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_placement_creativ_32111d" ON "placement" ("creative_id"); +CREATE INDEX IF NOT EXISTS "idx_placement_post_id_e4a16e" ON "placement" ("post_id"); +CREATE INDEX IF NOT EXISTS "idx_placement_project_2c6aa4" ON "placement" ("project_id"); +CREATE INDEX IF NOT EXISTS "idx_placement_purchas_61f9e6" ON "placement" ("purchase_channel_id"); +COMMENT ON COLUMN "placement"."status" IS 'ACTIVE: active\nARCHIVED: archived'; +CREATE TABLE IF NOT EXISTS "subscription" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "invite_link" VARCHAR(512) NOT NULL, + "status" VARCHAR(12) NOT NULL DEFAULT 'active', + "unsubscribed_at" TIMESTAMPTZ, + "placement_id" UUID NOT NULL REFERENCES "placement" ("id") ON DELETE CASCADE, + "telegram_user_id" UUID NOT NULL REFERENCES "telegram_user" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_subscriptio_placeme_75b40b" UNIQUE ("placement_id", "telegram_user_id") +); +CREATE INDEX IF NOT EXISTS "idx_subscriptio_invite__f2058d" ON "subscription" ("invite_link"); +CREATE INDEX IF NOT EXISTS "idx_subscriptio_placeme_5decb2" ON "subscription" ("placement_id"); +CREATE INDEX IF NOT EXISTS "idx_subscriptio_telegra_8be9bb" ON "subscription" ("telegram_user_id"); +COMMENT ON COLUMN "subscription"."status" IS 'ACTIVE: active\nUNSUBSCRIBED: unsubscribed'; +CREATE TABLE IF NOT EXISTS "workspace_invite" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "status" VARCHAR(8) NOT NULL DEFAULT 'pending', + "invited_by_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, + "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, + "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_workspace_i_workspa_abd9c4" UNIQUE ("workspace_id", "user_id") +); +CREATE INDEX IF NOT EXISTS "idx_workspace_i_invited_3ed9f8" ON "workspace_invite" ("invited_by_id"); +CREATE INDEX IF NOT EXISTS "idx_workspace_i_user_id_a96e1e" ON "workspace_invite" ("user_id"); +CREATE INDEX IF NOT EXISTS "idx_workspace_i_workspa_71e9d2" ON "workspace_invite" ("workspace_id"); +COMMENT ON COLUMN "workspace_invite"."status" IS 'PENDING: pending\nACCEPTED: accepted\nREVOKED: revoked'; +CREATE TABLE IF NOT EXISTS "workspace_user" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "status" VARCHAR(7) NOT NULL DEFAULT 'active', + "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, + "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_workspace_u_workspa_13c46c" UNIQUE ("workspace_id", "user_id") +); +CREATE INDEX IF NOT EXISTS "idx_workspace_u_user_id_246318" ON "workspace_user" ("user_id"); +CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_a403b0" ON "workspace_user" ("workspace_id"); +COMMENT ON COLUMN "workspace_user"."status" IS 'ACTIVE: active\nINVITED: invited\nBLOCKED: blocked'; +CREATE TABLE IF NOT EXISTS "workspace_user_permission" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "permission" VARCHAR(24) NOT NULL, + "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_workspace_u_workspa_86f574" UNIQUE ("workspace_user_id", "permission") +); +CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_b9b7b0" ON "workspace_user_permission" ("workspace_user_id"); +COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write'; +CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "permission" VARCHAR(24) NOT NULL, + "channel_id" UUID REFERENCES "channel" ("id") ON DELETE CASCADE, + "creative_id" UUID REFERENCES "creative" ("id") ON DELETE CASCADE, + "placement_id" UUID REFERENCES "placement" ("id") ON DELETE CASCADE, + "project_id" UUID REFERENCES "project" ("id") ON DELETE CASCADE, + "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_workspace_u_channel_d94b48" ON "workspace_user_permission_scope" ("channel_id"); +CREATE INDEX IF NOT EXISTS "idx_workspace_u_creativ_8da562" ON "workspace_user_permission_scope" ("creative_id"); +CREATE INDEX IF NOT EXISTS "idx_workspace_u_placeme_ea1846" ON "workspace_user_permission_scope" ("placement_id"); +CREATE INDEX IF NOT EXISTS "idx_workspace_u_project_bd7fea" ON "workspace_user_permission_scope" ("project_id"); +CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_82a35a" ON "workspace_user_permission_scope" ("workspace_user_id"); +COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write'; +CREATE TABLE IF NOT EXISTS "aerich" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "version" VARCHAR(255) NOT NULL, + "app" VARCHAR(100) NOT NULL, + "content" JSONB NOT NULL +);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + """ + + +MODELS_STATE = ( + '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' + 'v0' +) diff --git a/migrations/models/10_20260127194137_update.py b/migrations/models/10_20260127194137_update.py new file mode 100644 index 0000000..375c6a5 --- /dev/null +++ b/migrations/models/10_20260127194137_update.py @@ -0,0 +1,149 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX IF EXISTS "idx_workspace_u_channel_d94b48"; + ALTER TABLE "workspace_user_permission_scope" DROP CONSTRAINT IF EXISTS "fk_workspac_channel_e039fdfd"; + ALTER TABLE "creative" ADD "created_by_user_id" UUID; + COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'ADMIN_FULL: admin_full +PROJECTS_READ: projects_read +PROJECTS_WRITE: projects_write +CREATIVES_READ: creatives_read +CREATIVES_WRITE: creatives_write +PLACEMENTS_READ: placements_read +PLACEMENTS_WRITE: placements_write +ANALYTICS_READ: analytics_read +ANALYTICS_WITHOUT_CLICKS: analytics_without_clicks +ANALYTICS_OWN_CREATIVES: analytics_own_creatives'; + ALTER TABLE "workspace_user_permission_scope" DROP COLUMN "channel_id"; + COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'ADMIN_FULL: admin_full +PROJECTS_READ: projects_read +PROJECTS_WRITE: projects_write +CREATIVES_READ: creatives_read +CREATIVES_WRITE: creatives_write +PLACEMENTS_READ: placements_read +PLACEMENTS_WRITE: placements_write +ANALYTICS_READ: analytics_read +ANALYTICS_WITHOUT_CLICKS: analytics_without_clicks +ANALYTICS_OWN_CREATIVES: analytics_own_creatives'; + ALTER TABLE "creative" ADD CONSTRAINT "fk_creative_user_2ed7d825" FOREIGN KEY ("created_by_user_id") REFERENCES "user" ("id") ON DELETE SET NULL; + CREATE INDEX IF NOT EXISTS "idx_creative_created_2d9564" ON "creative" ("created_by_user_id"); + CREATE UNIQUE INDEX IF NOT EXISTS "uid_creative_me_creativ_b07496" ON "creative_media" ("creative_id", "position");""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX IF EXISTS "uid_creative_me_creativ_b07496"; + DROP INDEX IF EXISTS "idx_creative_created_2d9564"; + ALTER TABLE "creative" DROP CONSTRAINT IF EXISTS "fk_creative_user_2ed7d825"; + ALTER TABLE "creative" DROP COLUMN "created_by_user_id"; + COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'ADMIN_FULL: admin_full +PROJECTS_READ: projects_read +PROJECTS_WRITE: projects_write +CREATIVES_READ: creatives_read +CREATIVES_WRITE: creatives_write +PLACEMENTS_READ: placements_read +PLACEMENTS_WRITE: placements_write +ANALYTICS_READ: analytics_read +ANALYTICS_WITHOUT_CLICKS: analytics_without_clicks +CHANNELS_READ: channels_read +CHANNELS_WRITE: channels_write'; + ALTER TABLE "workspace_user_permission_scope" ADD "channel_id" UUID; + COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'ADMIN_FULL: admin_full +PROJECTS_READ: projects_read +PROJECTS_WRITE: projects_write +CREATIVES_READ: creatives_read +CREATIVES_WRITE: creatives_write +PLACEMENTS_READ: placements_read +PLACEMENTS_WRITE: placements_write +ANALYTICS_READ: analytics_read +ANALYTICS_WITHOUT_CLICKS: analytics_without_clicks +CHANNELS_READ: channels_read +CHANNELS_WRITE: channels_write'; + ALTER TABLE "workspace_user_permission_scope" ADD CONSTRAINT "fk_workspac_channel_e039fdfd" FOREIGN KEY ("channel_id") REFERENCES "channel" ("id") ON DELETE CASCADE; + CREATE INDEX IF NOT EXISTS "idx_workspace_u_channel_d94b48" ON "workspace_user_permission_scope" ("channel_id");""" + + +MODELS_STATE = ( + "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" + "6H29Y3Htd2t3/Nr7ZJBtfJboLLf3p5/xfKHDK4" +) diff --git a/migrations/models/11_20260128113726_update.py b/migrations/models/11_20260128113726_update.py new file mode 100644 index 0000000..0766b1a --- /dev/null +++ b/migrations/models/11_20260128113726_update.py @@ -0,0 +1,149 @@ +# ruff: noqa +# mypy: ignore-errors +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + -- 1. Drop old constraints + ALTER TABLE "subscription" DROP CONSTRAINT IF EXISTS "uid_subscriptio_placeme_aba67c"; + ALTER TABLE "subscription" DROP CONSTRAINT IF EXISTS "fk_subscrip_placemen_f35fb6bb"; + + -- 2. Add new column for placement_id + ALTER TABLE "subscription" ADD COLUMN "placement_id" UUID; + + -- 3. Populate placement_id from placement_post table + UPDATE "subscription" s + SET placement_id = pp.placement_id + FROM "placement_post" pp + WHERE s.placement_post_id = pp.id; + + -- 4. Make NOT NULL (all subscriptions should have a placement via placement_post) + ALTER TABLE "subscription" ALTER COLUMN "placement_id" SET NOT NULL; + + -- 5. Add FK constraint to placement table + ALTER TABLE "subscription" ADD CONSTRAINT "fk_subscrip_placemen_0b5d8f15" + FOREIGN KEY ("placement_id") REFERENCES "placement" ("id") ON DELETE CASCADE; + + -- 6. Create unique index + CREATE UNIQUE INDEX IF NOT EXISTS "uid_subscriptio_placeme_75b40b" + ON "subscription" ("placement_id", "telegram_user_id"); + + -- 7. Drop old column + ALTER TABLE "subscription" DROP COLUMN "placement_post_id"; + """ + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + -- 1. Drop new constraints + DROP INDEX IF EXISTS "uid_subscriptio_placeme_75b40b"; + ALTER TABLE "subscription" DROP CONSTRAINT IF EXISTS "fk_subscrip_placemen_0b5d8f15"; + + -- 2. Add back placement_post_id column + ALTER TABLE "subscription" ADD COLUMN "placement_post_id" UUID; + + -- 3. Populate from placement_post (best effort - takes first placement_post for the placement) + UPDATE "subscription" s + SET placement_post_id = pp.id + FROM "placement_post" pp + WHERE s.placement_id = pp.placement_id; + + -- 4. Make NOT NULL (may fail if no matching placement_post exists) + ALTER TABLE "subscription" ALTER COLUMN "placement_post_id" SET NOT NULL; + + -- 5. Add FK constraint back to placement_post + ALTER TABLE "subscription" ADD CONSTRAINT "fk_subscrip_placemen_f35fb6bb" + FOREIGN KEY ("placement_post_id") REFERENCES "placement_post" ("id") ON DELETE CASCADE; + + -- 6. Restore unique index + CREATE UNIQUE INDEX IF NOT EXISTS "uid_subscriptio_placeme_aba67c" + ON "subscription" ("placement_post_id", "telegram_user_id"); + + -- 7. Drop new column + ALTER TABLE "subscription" DROP COLUMN "placement_id"; + """ + + +MODELS_STATE = ( + "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" + "al" +) diff --git a/migrations/models/12_20260128120011_add_published_at_to_post.py b/migrations/models/12_20260128120011_add_published_at_to_post.py new file mode 100644 index 0000000..695bb48 --- /dev/null +++ b/migrations/models/12_20260128120011_add_published_at_to_post.py @@ -0,0 +1,99 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "creative" ADD "tag" VARCHAR(10) NOT NULL DEFAULT 'testing'; + ALTER TABLE "post" ADD "published_at" TIMESTAMPTZ; + COMMENT ON COLUMN "creative"."tag" IS 'TESTING: testing\nPRODUCTION: production';""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "post" DROP COLUMN "published_at"; + ALTER TABLE "creative" DROP COLUMN "tag";""" + + +MODELS_STATE = ( + "eJztXWtz2rga/isMn7ozOZ2ES8hmzpwZQtyutwQYIGl3m47HGEF8Ymxqm6SZnfz3lXzBtn" + "zBN7AN7xeaSnpl+dH1efRK/qe+UuZI0j72nnhZRlL9uvZPXeZXCP9BR53V6vx67USQAJ2f" + "SUZawZVopukqL+g4eMFLGsJBc6QJqrjWRUXGofJGkkigIuCEorx0gjay+HODOF1ZIv0JqT" + "ji+w8cLMpz9Atp9n/Xz9xCRNLcU1hxTp5thHP629oIu79nbz8ZKcnjZpygSJuV7KRev+lP" + "irxNvtmI84/EhsQtkYxUXkdz12uQUlovbAeZJcYBurpB26LOnYA5WvAbiYBR/+9iIwsEg5" + "rxJPLT+l89ATyCIhNoRVknWPzzbr6V885GaJ08qvdHd/yhefmb8ZaKpi9VI9JApP5uGPI6" + "b5oauDpACioir83xuh/QWxyjiysUDKrXkgJ3bpl+tP9IA7Id4KDstDAbZhu+dJjW8TvMh7" + "L0ZtVgBMZT9o6ZTLt3I/ImK037KRkQdacMiWkYoW9U6AezShTcP8x+s82k9pWd/lEj/639" + "PRwwdMVt003/rpMy8Rtd4WTllePnrsZmh9rA4JROxW7W85QV67WEii20Yq3CO/WKx2KUrl" + "69ljnUq1XaA1ZrRarRfu3IDqrj6liq/IoLmstuxCUr68H1SBlSFYmh2s+ElrFDLslT/vN7" + "o9FsdhrnzcurdqvTaV+dX+G0RpH8UZ2I6r5hP7ODqbf2SMC7F2NRl5AfXbzGUUOwtQ0oVP" + "GrlHSgW/G/OAnJS/0J/7fRbkdg9tAdGysFnIpq9wMrqmHGeUHcaEg1/k6Ao9smFZT02LL3" + "9rl/HHlBQJrGPfHaU7IOTxmm6vCHH6uL6fFrXfODG4qslbpaiDYuWp3WVfOytQVyGxKFnx" + "8rUX4RdcRJovycpG9TZrl070MPlBfnjVaMHk6ShXZxM/L9nZDUxbOLXZGAGS88v/LqnPPE" + "uNqpxAtoheSg5npj2X76MkYSb7yoH2eLro/sfMo5Pb3bjccOrQcsacnKKisMOIsqI6Aq/0" + "dCZhDMXCqGA+ktSkMJ6z/+qFVjRYfwMr80Sk2eTZ5ki1lEoxBfUD1I6LLjziKVLncqkLpA" + "6gJFBKQuqFiQukDqii91JVUPMikHxynC6OhXQC+Y4tAwkfBXUNsvLYRRzZ35NvW0dBuoD3" + "fdb795Wnt/OPhsJ3cB2+sPbyg8ZxtdxyXwQ/rnZDgIhtRlQqF6L+O3/T4XBf2sJoma/mNf" + "GLtWWrONKOmirH0kD9zTYotgEY08DTI1eJAMaOQ1ndc3AcCT4YCRNysDfBYXkZcF5KsEx/" + "pwLbuO1/wWBfBiXO/2puwDc10zEzzKBB0ccItDVOEJB5mtKOEAchVj+LgKHTyufEMHv0yL" + "tmV6QKh1pOkEKT/WUzwNsYPPeC42kzzKo/Hw9h7XwHBwXcMMer4RbH6cXA6KJQZFSEE06D" + "ZlmL1xRAsP3OQJZ3HB1hlY3e71S856ehYK59NFEqLntcqTC5cYNp8K6UPRD+EnRUXiUv6C" + "3nzDwAEUpv1A5xOYcLDKv26FFap14Hc0yYIBcXfS694y9aienAOK91Y2Je2zOxEMHp08SE" + "6YaW1w3+/X3+OI4Ss0F3lO1NEqowJqa3p3JMNSL3R36MEF7A4URnF3QIHUlahpOGtOExQy" + "IGZC5KuiPmtrjArphKNt3hOSdbVAOoRubvajCPF829F2K+jcaps2Vx39u/MEa8JXNNHI6A" + "dI7HtZn4HEfuRKLEjsR1qxILEfq8RuLqEN/H0VGS60e62qohV7tZtmI4Z202yEajck6j0A" + "y4UooUDhYRecLsNqItq+iAMpThWKqREXBKrW5J7RW3JMHbtKupvtBdHtOteHZri/o8skH7" + "/x6nk9UmwhkSTrIhknryq6PaMyCmJuV6yyghdPFXMaSLCwGK6G7ZPO95WlKE+VZyTXA7i8" + "K/YsishLJB2m3nZC8IYDqg6MDqg6VCxQdaDqCQ5+2jNo7EOJtkE+dPIIjtKhX2sR10iKvu" + "C1rOYYd0ydYaOlm6o0GM/KUoUrpGmYdwTy6VAlwmt0ogcwU3iG5eMOdiQSxGH9ccorPYR5" + "4RQsOzjeJwGqg8c1JVx0WHuS7VNy+O72k7PcjH+ADgE6BNBV0CFOt2JBhzjWdXsFD+I8bs" + "5bFxfkt9k2fjs18k/LCGo1jKBz5+9W04ltnvunvvpgyGEUp/eT61qmrB/lr2N2yli5zJ3E" + "zYXxe+VJ7GQiYMMuS461cGNmMhoOJnYel0baFvltm3aCEdFErvI0nOJuSyhPmfHdhOuORu" + "PhQ7dvZte4cGybRsmbM1dh3LGuN23OncI32zXfa898ZmbS33Ehhtyo+5f1LsiFhPuxLhsH" + "j1GXvd1h13HV0twqg9zrDnpMn/HYWk8QfOnPyeEhtsdwg+GUG37x1Jv5otZjkVMNrTYdYh" + "fdROYy6Ckk/zEu1kN3MPU+xQUzTzcrK3xmYeJ5T9xkqabiKXZY8+i48rUaTD3eROZV8+Ic" + "lGqEH5Rq+A5KbRf4KSYU2hamlIKnlDX/lrYqPZZQkQVXpIDzi/Am3L088GRQrM9W/RP7jc" + "wLCxGb4YlidHddE9arNMNfzidzDZBeeGkT5EQjKXyIduo1o8BdELuq9Y/b4f1Nn6mNxkyP" + "nbDWofNthzAiDVXmpySaytaY6faDwJyhhaIibsarS/zc5Kj67QFeep7OMiz4cyl4bJgw/U" + "8cXirfDa9rGpIW3FpVVsqjjAfQwW13jIcN8iZzXk11kv/iMs65cnqgd50rv6SHDNw8V0Gz" + "a/hNII5FRVxnD30ViKCsbIE5LqYuEwA1ENQTvlt0L87eLmAyDcBB+RR8SqE+whMQ27uurT" + "czSRQeZUe74Nd4OMYrnTKs1cxPniT1GvdYnciObam87eHmk2NvbnDzid0M9nHzycFPeJT7" + "2pNdBzwCpow84HNyqmzr806FSf1UgohoHveG25lV/AJxbTPbZp4RkokrqwojAvfGhIB0EI" + "8voztFeX3Z/S2G55fRz420cOIMPL3AIeigu0Xg6XUyFQueXse6m+tMpQnlBMruRAQF+ttE" + "SVFzTE5Dt4oSYNzu+1klmHw/dVWUCEP1qd0ygr38zQpfPIZbWvnF1a0SXzW7V84TRnV2M5" + "y98JrvlNLiOmkHF2IC54GlMXAeqFjgPMfOeYo6lV5Eb8z5WDp8aypfXyh7ZFioyoqzF2fp" + "B5iAbGC0KVphIa5L2lOqiYO2hcos+vADuHrVs0s/4P1wdN4PJdrFdvfXFxG9atyTqOmKKm" + "bd6CdQPJAM/zDyeyv1EubAu/s0NCGqFw1ftALGuavvbR96mFvDR7pgzbQghoEYBpoJiGFQ" + "sSCGHTujMadYQdkEbcSGqmGU1anKYa41Q8Je4LU8zOh2OGpYuV5QmC9HqaotgzPHQR0Rys" + "vlwzwRir5u0zqiFMTKnNNLEWTMlShnBvZqe89bsLnUEKBhQMNgtQ40DCoWaNixL0AreOMm" + "XgRYx3apSxu6vSn7wFzXzASPMjuwQ0TZDiNb5zjoFqdShScclOoSnZwvclhvcFl4DS9FzK" + "swSIac/cYpq2ZXnoesMPeVGYe4Z+MizpWQF+FXQl74roSE/ddURxXoRXZc4Gi7E4EOtq7t" + "hpDD1nVgO8wBv6/uvCqLIN3D0m//23dQZNzkzveDq4Vs+m8dIfLygagyFnDXweG9ITz3ZA" + "RobvQ9GuHCm0anzNv/gTpypuOxZ6nyK87+bhBocKDBgVQDGhxULGhwx67BFXgx7eEJygEu" + "pj1mTfN+MLm/mfTG7A3RMDeytVCbpdMxY6EfAb4fe3eJ0sw4fnMYnor2UYGrWtLqnz5akw" + "C+INsTgTDK4Qdub8l8e4unZeWA49TKr/IfLQ7qcmVyp/IAHaDv0BURru/4mgBcZAniTT6L" + "LeD4IN5AxYJ4cyLsaDuTBs1lN+Iy9CgLZZjPUZbdE1ouB1l+bzSazU7jvHl51W51Ou2r8+" + "2JFn9U1NGWG/YzOd3iqT3/cReySDH+9gEcro65bSr5zaZGux1DnMGpwr9sS+Kog0Oiqulc" + "Uiy9VoCmjabEpwDTY3TaWPp4fhx/E/iuRGI3Au9IGo7ZUEZTBf/sRi4m1S+Z30UCqh1GsX" + "dTa2DU0QsQYNRAvIBRQ8UCo85/2it7NSZj1CXcNNs3v85xzyy27zZu+Yf34S7RytDD6JSl" + "iF9LeUZZuUWf5DQlGZVzIogFh2ZsKxp+Rnk5cLNGbhXGxHVyAoAJBoYMvnk6/FcMFD83j7" + "33bjPwA+28H2g629fGe6bNdOccVwDN9xzyCuf6nnNlQPiB8OfDH4AXAuGHigXCfyKEP+kO" + "WqbNsyK64P53IvkXPK2pnNbkntFbEix9hpXckcztGEkqFcW6pS7rqW/nrrvyNeFYBBD4MP" + "DhQx95p5tLFJdzWlQMRmepO/kTO//Vk3DmHTgfUAPgfFCxwPlOhfNV8Iz2GslzUj2+CqyP" + "mMEtO/h8XbOSPMrdXo8ZTY1rJgUBrXFJHuUx8zD8QoJU9KI8l+LiSXONM+dmbwn32X2GJ3" + "IylfZKTwjb6R3lhdsgU0MXcQoa7jM8S3WfYfDYlwOIlT/67BvQdwOY0+nxykNX0sPiXsUo" + "ShfZ7dPulbJAEwFNBDSRUnEu0EROpmJBEwFNpDSaSIJvcTywhh5iLTQf5Zv+sGfIITNJEV" + "LKIVFHy205pBMqh3R8l9cBowdGD4y+HLQqOaMHQpqZkAZd4L+fq/vLuUqM5dhRrm8blAml" + "g+kZrna0S9nwNrm4Gge39prtTe5wLyCcR4LsAbIHsGOQPaBiQfY4dtnDO9OmkT68ORR8LK" + "Devb1jB9yn+z75iuV8JcrcAqd7lEfj4Z9Mbzrhxkz39rpme41zpIe6Yr+O2Snjin5VRR09" + "yj1sRSQV23x7ZN+yd+KtDJwEVg6jfrfH3DEDpwTb75XZZXBS2KVwkli5dAfd/l9Ttmdngp" + "dr0psuCnYeTjxpOcP7Kdfrs70vE3fKVxHX0EbnBEkUnjW3zfDrgNu+idtEeZW9txQkPnbR" + "inPqohV+6KJFq0WBa7jkCsjpKUhxZJC87ibfg9984XpIFTacaR4bn6VteW8Kqmby8fwJG7" + "CxPQwDwMaOftEObOxIKxbYGLAxYGPAxsrDxuziJORhlFmGxdjuYac0BKxkX3erKnLmmJAU" + "N4/V6aEGqgmoJuVUTQL6dg5Qxr/Wo6gOvfsLg54hazd49pyaA3q53ihbEHzUEiNG4yvoG5" + "elRTDeJy6LUT27SBWFp3qAvmnFnEUpmbyTpjSCZegXq+J+qMqqwmxCZUahxfxOVeOi1Wld" + "NS9b289TbUOiXIftL1CF65MvSA2nyMHouUzg4jLn4jLcNRKAaCWvJoAX5+cxAMSpwr+3Tu" + "Io3qvIeuBU8edkOAjhvI4JBeS9jF/w+1wU9LOaJGr6j3LCGoEieWuP1GaD9+Gu+43Gtdcf" + "3tCrbZLBTbKb4PKfXt7/BaQvMYA=" +) diff --git a/migrations/models/13_20260128125621_add_invite_link_created_at_to_placement.py b/migrations/models/13_20260128125621_add_invite_link_created_at_to_placement.py new file mode 100644 index 0000000..f4150e5 --- /dev/null +++ b/migrations/models/13_20260128125621_add_invite_link_created_at_to_placement.py @@ -0,0 +1,98 @@ +# ruff: noqa +# mypy: ignore-errors +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" ADD "invite_link_created_at" TIMESTAMPTZ;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" DROP COLUMN "invite_link_created_at";""" + + +MODELS_STATE = ( + "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" +) diff --git a/migrations/models/14_20260128182443_update.py b/migrations/models/14_20260128182443_update.py new file mode 100644 index 0000000..9ccfe0e --- /dev/null +++ b/migrations/models/14_20260128182443_update.py @@ -0,0 +1,105 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "channel" ADD "is_accessible" BOOL NOT NULL DEFAULT True; + ALTER TABLE "placement_post" ADD "status" VARCHAR(64) NOT NULL DEFAULT 'Без статуса'; + COMMENT ON COLUMN "placement_post"."status" IS 'NO_STATUS: Без статуса\nSEND_POST: Отправить пост\nPOST_APPROVAL: Согласование поста\nWAITING_SCHEDULE: Ожидание отложки\nSCHEDULED: Запланирован\nPOST_PUBLISHED: Пост вышел\nCOMPLETED_DELETED: Размещение отработало - пост удалён\nCOMPLETED_NOT_DELETED: Размещение отработало - пост не удалён\nCHECK_DELETED_EARLY: Проверить - пост удалён раньше срока\nCHECK_NOT_PUBLISHED: Проверить - пост не вышел\nCHECK_COMPLETED: Размещение отработало';""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "channel" DROP COLUMN "is_accessible"; + ALTER TABLE "placement_post" DROP COLUMN "status";""" + + +MODELS_STATE = ( + "eJztXWtz2roW/SsMn3pmejoJkJBm7twZHm7LKQGGR9pzmo7HGIX4xtgc2zTNdPrfr+QHlu" + "UHtjHYhv2FprK2LK2t117aW/5VXakLJOvvOk+CoiC5elv5VVWEFcJ/sI/eVqrCeu0+IAmG" + "MJfNvCKVaa4bmiAaOPlRkHWEkxZIFzVpbUiqglOVjSyTRFXEGSVl6SZtFOnfDeINdYmMJ6" + "ThB9++42RJWaCfSHf+u37mHyUkLzyVlRbk3WY6b7yuzbTZrNf9YOYkr5vzoipvVoqbe/1q" + "PKnKNvtmIy3eERnybIkUpAkGWlDNILW0G+wkWTXGCYa2QduqLtyEBXoUNjIBo/qfx40iEg" + "wq5pvIT+O/1QTwiKpCoJUUg2Dx67fVKrfNZmqVvKrzqTV+U7/+w2ylqhtLzXxoIlL9bQoK" + "hmCJmri6QIoaIs3mBcMPaBc/MaQVCgbVK8mAu7BF3zl/pAHZSXBRdnuYA7MDXzpMq7gNi6" + "Eiv9oajMB42rvjJtPW3Yi0ZKXr/8omRK0pR57UzNRXJvWNpRIVjw9r3GwLqXzpTT9VyH8r" + "/wwHHKu4bb7pP1VSJ2FjqLyivvDCgupsTqoDDM7pKnazXqRUrFcSFJurYu3Ku3rFczFKp1" + "evZAZ6tWt7RLWWRI1OsyMHqIHVsdSEFR+0lrWlZU8xgvXICDKKxFAdZkHbc0AuyVv+fF+r" + "1evN2kX9+uaq0Wxe3Vzc4LxmlfyPmhHqbvc+9gZTr/ZIwm8vxpIhIz+6eI+jhWDrCDCo4q" + "YUdKJbCT95GSlL4wn/t3Z1FYHZfWts7hRwLqbfD+xHNeuZF8SNjjTz7wQ40jKpoGTnloP3" + "z8PjKIgi0nX+SdCfkg14RjDVgD/+XJ3PiF8buh/cUGTt3OVCtHbZaDZu6teNLZDblCj8/F" + "hJyg/JQLwsKc9JxjYjlsnwPvZEeXlRa8QY4SRb6BC3HjKQ6rw1WqV50MLTVlUZCUoIrqws" + "g+wcCx9qEdrOpllvy9rDYd+zI2v3mDE8mN21OQy1iTPOhPuW210JAfD4TFmuJGEuiM8vgr" + "bgPU+oOUAWRLRCStBU0LZlP3weI1kwG+pH2qZCRk45xVz6fzsdyEmtBpgLZNe6Lwy4iDIj" + "oKn/Q+LeIFillAwHMlrUmho2fvyPVrUVmyIowtKsNXk3eZNDFBL+R/qBqkEkovPsbSSLSO" + "cCGhFoRGCbgEYExQKNCDRifBoxKTOzFytzmgSXgX4GjIIpTg0jYH8G9f3CQhjV3bmvU09P" + "d4B6c9f6+oent/eHg49OdgrYTn/YZvCcbwwD18AP6V+T4SAYUkqEQXWm4NZ+W0ii8bYiS7" + "rx/VAYUzut+UaSDUnR35EXHmizRbCIRp4FmZk8SAEs8rohGJsA4Ml0wCmblQl+D1dRUETk" + "U4IrfbyeXcV7ftsE8GJcbXWmvXvutmJleFAIOjihi1M08QknWb0o4QRyE2P6uAmdPG58U4" + "ewTIu2LXpEqA2kGwQpP9ZTvAz1Bh/xWmxleVBG42F3hjUwHNxWsAW92IiOfZycaotFtEXQ" + "bCzojskwf+XJOUPgAVq4FRcsvYdVt3v/kvFZxT4mnI8XSYieVypLW7jAsPlYSB+Kfgg/qB" + "qSlspn9OqbBo7AMB0GOh/BhJM14WVLrDC9A7fRMhZMiFuTTqvLVaNGcgYozuxiCjpmdyIY" + "PDt5kJxw08pg1u9Xf8chw1doIQm8ZKDVngyow+ndkQILvdHdwQfncDqQm4m7AwqkrSRdx0" + "XzuqiSCXEvRL6o2rO+xqiQQTjalj0hRZcLpGPw5tY4iiDPtwNtN4POr7Z5M+XRv7lvsBd8" + "VZfMgr4DxX6Q/RlQ7CfOxALFfqKKBYr9VCl2awtt4u9TZDjR7pUqC1fs5W7qtRjcTb0Wyt" + "2QR78DsHyUZBRIPOyCkxIsJ6JXl3EgxblCMTWfBYGq1/ln9JocU1eulK58B0F0u8/1oRnu" + "S0qJZOOTXz6PUsZaSETJUkbG2bOKtGfUnoQY7YpVVPDisWJuBwkmFsPZsEOa8311KSlT9R" + "kp1QBbnnr6NsqQl0k+bHo7GcEbDkx1sOjAVAfFgqkOpnqCoFpnBY0d8OkIZGNOnkCYIvq5" + "lrBGUowFr2Q557hTGgwbPd1SpcN8VhQVrpCuY7sj0J4OZSK8Qmca3JrCMywbd7AToSCO64" + "9TXOohzAsnZ9rB9T4JYB08rinhpMPak+2QlMM32k/OdjP+DjwE8BBgrgIPcb6KBR7iVPft" + "JQzEedhcNC4vyW/9yvxtVsg/DTOpUTOTLty/G3X3af3Cv/RVB0MeozidTW4rexX9oHwZ96" + "acXcrCzVx/NH9vPJndQkQs2OqRsBZ+zE1Gw8HEKePazNsgv1eWnGg+qCOqPjW3utsaKlNu" + "fDfhW6PReHjf6lvF1S5d2bpZ8/qcqgz9lGppfeFWvn5V8TV77hOzsr7HlRjyo9bfdlsQhQ" + "T9WkrGxWPU6nV3yDUpLS3sOiid1qDD9TmPrP0G0Zf/ggQP9TocPxhO+eFnj96shtqvRa4a" + "GldsilN1C5nroLeQ8se4WvetwdT7Fgpmge1WdvrcxsTTTtxlma7iqXZY92hS5dodphpvIf" + "OyeXECpWrhgVI1X6DUdoOfYkFhZWFJyXlJWQuvaVXpkQRF5qxIEZcX4U24e3vgKSBfn63q" + "h95Xsi48SlgMLxSju9uKuF6lmf4yjsw1QfohyJsgJxpZFUK4U68YA+4jkSvb+OgOZ+0+Vx" + "mNuU5v0rODzrcDwnxosjLbm9fGXKsfBOYcPaoa4ueCtsTvTY6qXx7gZdfpfaYFfyk5zw0T" + "rv+Bx1vlu+FtRUfyI7/W1JX6oOAJdNBtjfG0QVqyELRUkfyX13HiytmJnoorv2anDNw9V0" + "Gra/hNIK5ESVxnj30ViKiuHII5LqaUCIAaCOoZ39t6EGdvChg+PWUeXgrsuHPecdOq2WeF" + "DSon5zCU6gjvMHqd28p6M5cl8UFxySlhjddbvJUtwmbc+l5Q0rAAj9SZHMkXKpwCrrY59e" + "4GV9s43eAQV9scPYSn2Pfa7IrgCVgysoDPLam0vc+7FCZ1RApiGrK4GN4prOQ3xOub+bbw" + "PSGZUEWVGBG4GCgEpKO49JnDKcqtzxlvMVz7zHFu5oWQQnDlA4+vo5IT4Mp3NooFV75TJQ" + "/BlS9DV74JN+jyo+FkGuBKZnqFNS4oZ6oAJzafA9n2zQop9lC+eYFvtNvkeBlOOp+47qzP" + "eTzsrqmiGtGvoLGg3f+uXUc2LKA4r3G88WjvM58/H/0eG9qg5lrYmVTy5NO25IA2V1zhhu" + "VCd0P1hfmD0hnejfrclOvyXc7811YErdYm6znYeO/z7wtDxtM/LtmntHNfHVX+DNUd5XDp" + "UYspaTuFLujWEGfD4reoTvsrxmjeJ67z2WkWz7XG/b9p1Qd0mCsq3Tsu98S64oNiYRVMdz" + "J6qqHrJjhD0WoQ0VVgX86yQR6oI0eEWadtT8qx96Q5CLqO85nM6/CPZF77PpHpmqYJ6XlG" + "7kwIevZjjklRc0XO4xwo6kCDjnfc90gj22+D5nWowYyp3bS8QyftC188xriwxxnUsEp8N/" + "9BOcQw6nA3Y3gQnvAbc3JBXU0AN4gDhwhUE3CIoFjgEE+dQ8zrGp88RmPG9/jAxzmzdR53" + "ZoZHTV3xzuYs/QQTUAzMNnlHihJXYP0p1cLByoIy844WBdfp6v7UD3gTnpw3YYG8wujx+k" + "NCLzr/JOmGqkn7Os4RKO5JgZ/M8l4LvYU5srccC00I68XCF82A8bT6Xg/Bh9EcPjJEe6UF" + "MgzIMOBMgAwDxQIZduoWjbXEiuom6CA2lA1jpM6VDqP2DAlHgVfyOLPb8UzD0o2C3Hw5Cq" + "W2PZw5juqIUFxbPswTIe/7ye2Q3yCrzI0GjjDGqEwZW2AvTjSaDRvFhoAZBmYY7NbBDAPF" + "ghl26hvQEsY14U2AfQ0GE6DU6kx799xtxcrwoPQGToqkOGnk6BwndXEuTXzCSaluHcz4Yq" + "T1BtdF0PFWxLpaihTIOy1OqZpdZR5TYfQVVMe4t+oyzh3al+F3aF/67tCG89dUoQrsJjsu" + "cKzcmUAHR9dOR8jg6DqwH2aA3xe6rNIiyI6w9Mf/zp1Oex5yZ/uF+lwO/beOEFn5QJQZC7" + "g76PjeEJ57pwI4N/ZeqnDiTWdzZu3/wIScGXjuWWrCinc+tAgcHHBwQNUABweKBQ7u1Dm4" + "HG/yP76BcoSb/E+Z05wNJrP2pDPutQmHuVHsjdo8HY8ZC/0I8P3Y0zVKs+L4xWF6yttHBa" + "5qSct/+syaBPAFyZ4JhFEOP3B7y963t3h6VgY4Tu3yZnZxpYUyaMgVyZ3KA3QAv8MqIpzf" + "8XUBuBgayJtsNltg4wN5A4oF8uZMrKPtShq0lrWlZWgoCyOYTSjL7gUtk0CW97Vavd6sXd" + "Svb64azebVzcU2osX/KCq0pd37SKJbPNrzh7uQTYr5tw/gcHaMlinlRy5rV1cxyBmcK5Sd" + "MZ8xgUOSpht8Uiy9UoCmg6YspADTI3TeWPrs/Dj+JvCdpsRuBN6ZNByzoYKmKv7ZjVxMU7" + "9gfhcJTO0wE3u3aQ0WdfQGBCxqMLzAogbFgkWd/bJXdDUms6gLeGh2aPs6wzOz2L7buOcf" + "34e7QDtDj0WnLiXcLPUZ7Wtb9ElJU1JQMReCWHDo5rGi6WeUlQN3zyytxJhQkRMATDAwZP" + "LN0uG/ZKD4bfPYZ++OBX6kk/cjLWeHOnjf6zDdjeMKMPM9QV7htr4nrgwMfjD4s7EfwC4E" + "gx8UCwb/mRj8SU/Q9jo8y2MIHv4kUviBlzWN1+v8M3pNgqVPsJQnkpmFkaRiUexb6vaN+n" + "bvuiteF45lAII9DPbwsUPe2e4SZcu5PSqGRWezO9kbdv6rJyHmHWw+MA3A5gPFgs13LjZf" + "CWO010hZEPX4FFgdcYNub/DxtmJneVBanQ43mprXTIoiWuOaPChj7n74mSRp6If6XIiLJ6" + "09zoKfvyY8Z/cJnklkKuuVnhC28wvlhdsgU0MXEQUN9xm+TXWfYfDclwGIpQ999k3ouwHM" + "KHq89NAVNFjcyxhF8SK7fdq9VBZwIsCJACdSKJsLOJGzUSxwIsCJFIYTSfAtjvueyYfYG8" + "0Hpd0fdkw6ZC6rYko6JCq03KFDmqF0SNN3eR1Y9GDRg0VfDLMquUUPBuneBmnQBf6Hubq/" + "mLvEWI4dxfq2QZFQOhqfQfWjXcyGt8vF5Tj4tVfsYHQHvYFwXwm0B9AeYB0D7QGKBdrj1G" + "kP70qbhvrwlpBzWEC11b3rDfgPsz75iuViJSn8I873oIzGw7+4znTCj7lW97bieI3zZIRS" + "T7+Me1OOevyiSQZ6UDpYilAqjvg2ZN+Wd5/bBbgZ7BJG/VaHu+MGbg223ytz6uDmcGrhZr" + "FLaQ1a/b+nvY5TCN6uya+GJDpluM9JzxnOpnyn3+t8ntA5XySsoY3Bi7IkPuu0zPDLgN+2" + "hBZRXxTvLQWJwy4acaIuGuFBFw2WLQrcwyVnQM6PQYpDg2R1N/kB/OZz50PKcODM2rHxrb" + "St3ZvCVLPs8ewNNrDGDjANgDV28pt2sMZOVLFgjYE1BtYYWGPFscac6iS0wxixPTZju6ed" + "whhgBfu6W1mRs+aEpLh5pM4PNWBNgDUpJmsSMLYzgDL+tR55DejdXxj0TFm7wXPW1AzQy/" + "RG2ZzgY7YYMTpfTt+4LCyC8T5xmQ/r2UKaJD5VA/hN+8nbKCZTcPMUhrAM/WJV3A9V2Src" + "j6jck2ixvlNVu2w0Gzf168b281TblCjXYecLVOH85A+khZvIwehRInBxmXtxGR4aCUC0s5" + "cTwMuLixgA4lzh31snzxi7V1WMwKXir8lwEGLzuiIMkDMFN/DbQhKNtxVZ0o3vxYQ1AkXS" + "ag/V5oD35q71lcW10x+22d02KaCd7Ca47JeX3/8HQeRRtg==" +) diff --git a/migrations/models/15_20260201174836_add_cost_before_bargain_type.py b/migrations/models/15_20260201174836_add_cost_before_bargain_type.py new file mode 100644 index 0000000..08f7d9b --- /dev/null +++ b/migrations/models/15_20260201174836_add_cost_before_bargain_type.py @@ -0,0 +1,103 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" ADD "cost_before_bargain_type" VARCHAR(8); + COMMENT ON COLUMN "placement"."cost_before_bargain_type" IS 'FIXED: fixed\nCPM: cpm';""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" DROP COLUMN "cost_before_bargain_type";""" + + +MODELS_STATE = ( + "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" +) diff --git a/migrations/models/16_20260202115014_add_invite_link_name.py b/migrations/models/16_20260202115014_add_invite_link_name.py new file mode 100644 index 0000000..93e8efd --- /dev/null +++ b/migrations/models/16_20260202115014_add_invite_link_name.py @@ -0,0 +1,105 @@ +# ruff: noqa +# mypy: ignore-errors +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" ADD "invite_link_name" VARCHAR(32);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" DROP COLUMN "invite_link_name";""" + + +MODELS_STATE = ( + "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" + "cQREBB" +) diff --git a/migrations/models/17_20260204143416_add_check_constraint.py b/migrations/models/17_20260204143416_add_check_constraint.py new file mode 100644 index 0000000..cea251d --- /dev/null +++ b/migrations/models/17_20260204143416_add_check_constraint.py @@ -0,0 +1,120 @@ +# ruff: noqa +# mypy: ignore-errors +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + -- Drop old constraint (replaced by stricter one) + ALTER TABLE "channel" DROP CONSTRAINT IF EXISTS "channel_identity_required"; + + -- Add CHECK constraint: channel must have exactly one of username or invite_link + ALTER TABLE "channel" + ADD CONSTRAINT "check_channel_username_xor_invite_link" + CHECK ( + (username IS NOT NULL AND invite_link IS NULL) OR + (username IS NULL AND invite_link IS NOT NULL) + ); + + COMMENT ON CONSTRAINT "check_channel_username_xor_invite_link" ON "channel" + IS 'Ensures channel has exactly one identifier: username OR invite_link (not both, not neither)'; + """ + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "channel" + DROP CONSTRAINT IF EXISTS "check_channel_username_xor_invite_link"; + """ + + +MODELS_STATE = ( + "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" + "cQREBB" +) diff --git a/migrations/models/18_20260228130403_update.py b/migrations/models/18_20260228130403_update.py new file mode 100644 index 0000000..bf0b1a6 --- /dev/null +++ b/migrations/models/18_20260228130403_update.py @@ -0,0 +1,165 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" ADD "feed_time_minutes" INT; + ALTER TABLE "placement" ADD "top_time_minutes" INT; + + -- Migrate known patterns: "X / Y" where X is top hours, Y is feed hours + -- Pattern: "1 / 24", "1/24" + UPDATE "placement" SET top_time_minutes = 60, feed_time_minutes = 1440 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^1\\s*/\\s*24$'; + + -- Pattern: "1 / 36", "1/36" + UPDATE "placement" SET top_time_minutes = 60, feed_time_minutes = 2160 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^1\\s*/\\s*36$'; + + -- Pattern: "1 / 48", "1/48" + UPDATE "placement" SET top_time_minutes = 60, feed_time_minutes = 2880 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^1\\s*/\\s*48$'; + + -- Pattern: "1 / 72", "1/72" + UPDATE "placement" SET top_time_minutes = 60, feed_time_minutes = 4320 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^1\\s*/\\s*72$'; + + -- Pattern: "2 / 24", "2/24" + UPDATE "placement" SET top_time_minutes = 120, feed_time_minutes = 1440 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^2\\s*/\\s*24$'; + + -- Pattern: "2 / 36", "2/36" + UPDATE "placement" SET top_time_minutes = 120, feed_time_minutes = 2160 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^2\\s*/\\s*36$'; + + -- Pattern: "2 / 48", "2/48" + UPDATE "placement" SET top_time_minutes = 120, feed_time_minutes = 2880 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^2\\s*/\\s*48$'; + + -- Pattern: "2 / 72", "2/72" + UPDATE "placement" SET top_time_minutes = 120, feed_time_minutes = 4320 + WHERE format IS NOT NULL AND lower(trim(format)) ~ '^2\\s*/\\s*72$'; + + -- Pattern: "X / (N дней)" or "X / (N дн)" + UPDATE "placement" SET + top_time_minutes = CASE + WHEN substring(lower(trim(format)) from '^(\\d+)\\s*/') ~ '^\\d+$' + AND (substring(lower(trim(format)) from '^(\\d+)\\s*/'))::int <= 12 + THEN (substring(lower(trim(format)) from '^(\\d+)\\s*/'))::int * 60 + ELSE (substring(lower(trim(format)) from '^(\\d+)\\s*/'))::int + END, + feed_time_minutes = (substring(lower(trim(format)) from '(\\d+)\\s*дн'))::int * 24 * 60 + WHERE format IS NOT NULL + AND top_time_minutes IS NULL + AND lower(trim(format)) ~ '^\\d+\\s*/.*\\d+\\s*дн'; + + -- Pattern: "X / (без удаления)" or "X / без удаления" + UPDATE "placement" SET + top_time_minutes = CASE + WHEN substring(lower(trim(format)) from '^(\\d+)\\s*/') ~ '^\\d+$' + AND (substring(lower(trim(format)) from '^(\\d+)\\s*/'))::int <= 12 + THEN (substring(lower(trim(format)) from '^(\\d+)\\s*/'))::int * 60 + ELSE (substring(lower(trim(format)) from '^(\\d+)\\s*/'))::int + END, + feed_time_minutes = 0 + WHERE format IS NOT NULL + AND top_time_minutes IS NULL + AND lower(trim(format)) ~ 'без удаления'; + """ + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" DROP COLUMN IF EXISTS "feed_time_minutes"; + ALTER TABLE "placement" DROP COLUMN IF EXISTS "top_time_minutes";""" + + +MODELS_STATE = ( + "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" +) diff --git a/migrations/models/1_20260107150903_add_check_constraint.py b/migrations/models/1_20260107150903_add_check_constraint.py new file mode 100644 index 0000000..cc189a3 --- /dev/null +++ b/migrations/models/1_20260107150903_add_check_constraint.py @@ -0,0 +1,108 @@ +# ruff: noqa +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + -- Add CHECK constraint: exactly one FK must be set + ALTER TABLE "workspace_user_permission_scope" + ADD CONSTRAINT "check_exactly_one_scope" + CHECK ( + (("project_id" IS NOT NULL)::int + + ("creative_id" IS NOT NULL)::int + + ("placement_id" IS NOT NULL)::int + + ("channel_id" IS NOT NULL)::int) = 1 + ); + + COMMENT ON TABLE "workspace_user_permission_scope" + IS 'Permission scoped to specific entity (project/creative/placement/channel)'; \ + """ + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "workspace_user_permission_scope" + DROP CONSTRAINT IF EXISTS "check_exactly_one_scope"; \ + """ + + +MODELS_STATE = ( + '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' + 'v0' +) diff --git a/migrations/models/2_20260115142040_refactor_purchase_to_placement_post.py b/migrations/models/2_20260115142040_refactor_purchase_to_placement_post.py new file mode 100644 index 0000000..d536737 --- /dev/null +++ b/migrations/models/2_20260115142040_refactor_purchase_to_placement_post.py @@ -0,0 +1,412 @@ +# ruff: noqa +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + -- Шаг 1: Переименовать старую таблицу placement в old_placement + ALTER TABLE "placement" RENAME TO "old_placement"; + + -- Шаг 2: Создать новую таблицу placement (объединение purchase + purchase_channel) + CREATE TABLE "placement" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "status" VARCHAR(11) NOT NULL DEFAULT 'planned', + "placement_at" TIMESTAMPTZ, + "payment_at" TIMESTAMPTZ, + "cost_type" VARCHAR(8), + "cost_value" DOUBLE PRECISION, + "cost_before_bargain" DOUBLE PRECISION, + "placement_type" VARCHAR(16), + "format" TEXT, + "comment" TEXT, + "invite_link" VARCHAR(512) NOT NULL, + "invite_link_type" VARCHAR(8) NOT NULL, + "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE, + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE, + "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE + ); + + -- Шаг 3: Мигрировать данные из purchase_channel + purchase в новый placement + INSERT INTO "placement" ( + "id", "created_at", "updated_at", "deleted_at", + "status", "placement_at", "payment_at", + "cost_type", "cost_value", "cost_before_bargain", + "placement_type", "format", "comment", + "invite_link", "invite_link_type", + "project_id", "creative_id", "channel_id" + ) + SELECT + pc.id, pc.created_at, pc.updated_at, pc.deleted_at, + pc.status, + COALESCE(pc.placement_at, p.placement_at), + p.payment_at, + COALESCE(pc.cost_type, p.cost_type), + COALESCE(pc.cost_value, p.cost_value), + COALESCE(pc.cost_before_bargain, p.cost_before_bargain), + p.purchase_type, + COALESCE(pc.format, p.format), + COALESCE(pc.comment, p.comment), + pc.invite_link, pc.invite_link_type, + p.project_id, p.creative_id, pc.channel_id + FROM "purchase_channel" pc + JOIN "purchase" p ON pc.purchase_id = p.id; + + -- Шаг 4: Создать таблицу placement_post (бывший placement) + CREATE TABLE "placement_post" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "wanted_placement_date" TIMESTAMPTZ NOT NULL, + "cost" DOUBLE PRECISION, + "comment" TEXT, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE, + "placement_id" UUID NOT NULL REFERENCES "placement" ("id") ON DELETE CASCADE, + "post_id" UUID REFERENCES "post" ("id") ON DELETE SET NULL, + "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE + ); + + -- Шаг 5: Мигрировать данные из old_placement в placement_post + INSERT INTO "placement_post" ( + "id", "created_at", "updated_at", "deleted_at", + "wanted_placement_date", "cost", "comment", "status", + "creative_id", "placement_id", "post_id", "project_id" + ) + SELECT + op.id, op.created_at, op.updated_at, op.deleted_at, + op.wanted_placement_date, op.cost, op.comment, op.status, + op.creative_id, op.purchase_channel_id, op.post_id, op.project_id + FROM "old_placement" op; + + -- Шаг 6: Обновить foreign keys для таблиц, ссылающихся на old_placement + + -- 6.1: Обновить subscription (placement_id -> placement_post_id) + -- Удалить constraint ДО переименования колонки + ALTER TABLE "subscription" + DROP CONSTRAINT IF EXISTS "subscription_placement_id_fkey"; + + -- Переименовать колонку + ALTER TABLE "subscription" RENAME COLUMN "placement_id" TO "placement_post_id"; + + -- 6.2: Обновить workspace_user_permission_scope.placement_id + -- Удалить старый foreign key constraint ДО обновления данных + ALTER TABLE "workspace_user_permission_scope" + DROP CONSTRAINT IF EXISTS "workspace_user_permission_scope_placement_id_fkey"; + + -- Обновить данные: старые placement_id -> новые placement_id (через purchase_channel_id) + UPDATE "workspace_user_permission_scope" wups + SET placement_id = op.purchase_channel_id + FROM "old_placement" op + WHERE wups.placement_id = op.id; + + -- Шаг 7: Создать индексы и constraints + CREATE INDEX "idx_placement_project_4155ce" + ON "placement" ("project_id", "status"); + CREATE INDEX "idx_placement_channel_fb3968" + ON "placement" ("channel_id"); + CREATE INDEX "idx_placement_post_creativ_1c6807" + ON "placement_post" ("creative_id"); + CREATE INDEX "idx_placement_post_placeme_bd5bc9" + ON "placement_post" ("placement_id"); + CREATE INDEX "idx_placement_post_post_id_998755" + ON "placement_post" ("post_id"); + CREATE INDEX "idx_placement_post_project_94129d" + ON "placement_post" ("project_id"); + + ALTER TABLE "subscription" + ADD CONSTRAINT "fk_subscrip_placem_1959be9e" + FOREIGN KEY ("placement_post_id") + REFERENCES "placement_post" ("id") ON DELETE CASCADE; + + CREATE UNIQUE INDEX "uid_subscriptio_placem_578bfb" + ON "subscription" ("placement_post_id", "telegram_user_id"); + + ALTER TABLE "workspace_user_permission_scope" + ADD CONSTRAINT "fk_workspac_placemen_8c5d3f2a" + FOREIGN KEY ("placement_id") + REFERENCES "placement" ("id") ON DELETE CASCADE; + + -- Шаг 8: Добавить комментарии + COMMENT ON TABLE "placement" + IS 'Размещение, управляемое пользователем (бывший PurchaseChannel + Purchase)'; + COMMENT ON COLUMN "placement"."status" + IS 'PLANNED: planned +APPROVED: approved +REJECTED: rejected +IN_PROGRESS: in_progress +COMPLETED: completed'; + COMMENT ON COLUMN "placement"."invite_link_type" + IS 'PUBLIC: public +APPROVAL: approval'; + COMMENT ON COLUMN "placement"."cost_type" + IS 'FIXED: fixed +CPM: cpm'; + COMMENT ON COLUMN "placement"."placement_type" + IS 'SELF_PROMO: self_promo +STANDARD: standard'; + + COMMENT ON TABLE "placement_post" + IS 'Публикация, мониторимая системой (бывший Placement)'; + COMMENT ON COLUMN "placement_post"."status" + IS 'ACTIVE: active +ARCHIVED: archived'; + + -- Шаг 9: Удалить старые таблицы + DROP TABLE IF EXISTS "old_placement"; + DROP TABLE IF EXISTS "purchase_channel"; + DROP TABLE IF EXISTS "purchase"; + """ + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + -- ВНИМАНИЕ: Downgrade может привести к потере данных при объединении placement обратно + -- в purchase + purchase_channel (если были созданы placement без привязки к одному purchase) + + -- Шаг 1: Переименовать новую таблицу placement в new_placement + ALTER TABLE "placement" RENAME TO "new_placement"; + + -- Шаг 2: Воссоздать таблицу purchase + CREATE TABLE "purchase" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "placement_at" TIMESTAMPTZ, + "payment_at" TIMESTAMPTZ, + "cost_type" VARCHAR(8), + "cost_value" DOUBLE PRECISION, + "cost_before_bargain" DOUBLE PRECISION, + "purchase_type" VARCHAR(16), + "format" TEXT, + "comment" TEXT, + "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE, + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE + ); + + -- Шаг 3: Воссоздать таблицу purchase_channel + CREATE TABLE "purchase_channel" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "status" VARCHAR(11) NOT NULL DEFAULT 'planned', + "placement_at" TIMESTAMPTZ, + "cost_type" VARCHAR(8), + "cost_value" DOUBLE PRECISION, + "cost_before_bargain" DOUBLE PRECISION, + "format" TEXT, + "comment" TEXT, + "invite_link" VARCHAR(512) NOT NULL, + "invite_link_type" VARCHAR(8) NOT NULL, + "purchase_id" UUID NOT NULL REFERENCES "purchase" ("id") ON DELETE CASCADE, + "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE + ); + + -- Шаг 4: Мигрировать данные из new_placement в purchase (сгруппировать) + -- Создаем purchase для каждого уникального project_id + creative_id + INSERT INTO "purchase" ( + "id", "created_at", "updated_at", "deleted_at", + "status", "placement_at", "payment_at", + "cost_type", "cost_value", "cost_before_bargain", + "purchase_type", "format", "comment", + "project_id", "creative_id" + ) + SELECT + gen_random_uuid(), + MIN(np.created_at), + MAX(np.updated_at), + NULL, + 'active', + MIN(np.placement_at), + MIN(np.payment_at), + MIN(np.cost_type), + MIN(np.cost_value), + MIN(np.cost_before_bargain), + MIN(np.placement_type), + MIN(np.format), + MIN(np.comment), + np.project_id, + np.creative_id + FROM "new_placement" np + GROUP BY np.project_id, np.creative_id; + + -- Шаг 5: Мигрировать данные из new_placement в purchase_channel + INSERT INTO "purchase_channel" ( + "id", "created_at", "updated_at", "deleted_at", + "status", "placement_at", "cost_type", "cost_value", + "cost_before_bargain", "format", "comment", + "invite_link", "invite_link_type", + "purchase_id", "channel_id" + ) + SELECT + np.id, np.created_at, np.updated_at, np.deleted_at, + np.status, np.placement_at, np.cost_type, np.cost_value, + np.cost_before_bargain, np.format, np.comment, + np.invite_link, np.invite_link_type, + p.id, np.channel_id + FROM "new_placement" np + JOIN "purchase" p + ON p.project_id = np.project_id + AND p.creative_id = np.creative_id; + + -- Шаг 6: Воссоздать старую таблицу placement + CREATE TABLE "placement" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "wanted_placement_date" TIMESTAMPTZ NOT NULL, + "cost" DOUBLE PRECISION, + "comment" TEXT, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE, + "purchase_channel_id" UUID NOT NULL + REFERENCES "purchase_channel" ("id") ON DELETE CASCADE, + "post_id" UUID REFERENCES "post" ("id") ON DELETE SET NULL, + "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE + ); + + -- Шаг 7: Мигрировать данные из placement_post в placement + INSERT INTO "placement" ( + "id", "created_at", "updated_at", "deleted_at", + "wanted_placement_date", "cost", "comment", "status", + "creative_id", "purchase_channel_id", "post_id", "project_id" + ) + SELECT + pub.id, pub.created_at, pub.updated_at, pub.deleted_at, + pub.wanted_placement_date, pub.cost, pub.comment, pub.status, + pub.creative_id, pub.placement_id, pub.post_id, pub.project_id + FROM "placement_post" pub; + + -- Шаг 8: Обновить foreign keys обратно + + -- 8.1: Обновить subscription (placement_post_id -> placement_id) + -- Удалить новый foreign key constraint + ALTER TABLE "subscription" + DROP CONSTRAINT IF EXISTS "fk_subscrip_placem_1959be9e"; + + -- Переименовать колонку обратно + ALTER TABLE "subscription" RENAME COLUMN "placement_post_id" TO "placement_id"; + + -- 8.2: Обновить workspace_user_permission_scope.placement_id обратно + -- Удалить constraint на новый placement + ALTER TABLE "workspace_user_permission_scope" + DROP CONSTRAINT IF EXISTS "fk_workspac_placemen_8c5d3f2a"; + + -- Обновить данные обратно: новые placement_id -> старые placement_id + UPDATE "workspace_user_permission_scope" wups + SET placement_id = pub.id + FROM "placement_post" pub + WHERE wups.placement_id = pub.placement_id; + + -- Шаг 9: Создать индексы и constraints + CREATE INDEX "idx_purchase_project_status" ON "purchase" ("project_id", "status"); + CREATE INDEX "idx_placement_purchas_61f9e6" + ON "placement" ("purchase_channel_id"); + CREATE INDEX "idx_placement_post_id_e4a16e" ON "placement" ("post_id"); + + ALTER TABLE "subscription" + ADD CONSTRAINT "fk_subscrip_placemen_0b5d8f15" + FOREIGN KEY ("placement_id") + REFERENCES "placement" ("id") ON DELETE CASCADE; + + CREATE UNIQUE INDEX "uid_subscriptio_placeme_75b40b" + ON "subscription" ("placement_id", "telegram_user_id"); + + ALTER TABLE "workspace_user_permission_scope" + ADD CONSTRAINT "fk_workspac_placemen_8c5d3f2a" + FOREIGN KEY ("placement_id") + REFERENCES "placement" ("id") ON DELETE CASCADE; + + -- Шаг 10: Удалить новые таблицы + DROP TABLE IF EXISTS "placement_post"; + DROP TABLE IF EXISTS "new_placement"; + """ + + +MODELS_STATE = ( + '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' + 'OfXn7/A5u7TFM=' +) diff --git a/migrations/models/3_20260119185004_update.py b/migrations/models/3_20260119185004_update.py new file mode 100644 index 0000000..37ae6c7 --- /dev/null +++ b/migrations/models/3_20260119185004_update.py @@ -0,0 +1,91 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "login_token" ADD "message_id" INT; + """ + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "login_token" DROP COLUMN "message_id"; + """ + + +MODELS_STATE = ( + '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' +) diff --git a/migrations/models/4_20260120193804_update.py b/migrations/models/4_20260120193804_update.py new file mode 100644 index 0000000..e142aa5 --- /dev/null +++ b/migrations/models/4_20260120193804_update.py @@ -0,0 +1,107 @@ +# ruff: noqa +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX IF EXISTS "idx_placement_p_creativ_a8f431"; + DROP INDEX IF EXISTS "idx_placement_p_project_888982"; + ALTER TABLE "placement_post" DROP CONSTRAINT IF EXISTS "fk_placemen_project_f98a29a5"; + ALTER TABLE "placement_post" DROP CONSTRAINT IF EXISTS "fk_placemen_creative_db63ad95"; + ALTER TABLE "placement" ALTER COLUMN "creative_id" DROP NOT NULL; + ALTER TABLE "placement_post" DROP COLUMN "comment"; + ALTER TABLE "placement_post" DROP COLUMN "project_id"; + ALTER TABLE "placement_post" DROP COLUMN "cost"; + ALTER TABLE "placement_post" DROP COLUMN "wanted_placement_date"; + ALTER TABLE "placement_post" DROP COLUMN "creative_id";""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" ALTER COLUMN "creative_id" SET NOT NULL; + ALTER TABLE "placement_post" ADD "comment" TEXT; + ALTER TABLE "placement_post" ADD "project_id" UUID NOT NULL; + ALTER TABLE "placement_post" ADD "cost" DOUBLE PRECISION; + ALTER TABLE "placement_post" ADD "wanted_placement_date" TIMESTAMPTZ NOT NULL; + ALTER TABLE "placement_post" ADD "creative_id" UUID NOT NULL; + ALTER TABLE "placement_post" ADD CONSTRAINT "fk_placemen_creative_db63ad95" FOREIGN KEY ("creative_id") REFERENCES "creative" ("id") ON DELETE CASCADE; + ALTER TABLE "placement_post" ADD CONSTRAINT "fk_placemen_project_f98a29a5" FOREIGN KEY ("project_id") REFERENCES "project" ("id") ON DELETE CASCADE; + CREATE INDEX IF NOT EXISTS "idx_placement_p_project_888982" ON "placement_post" ("project_id"); + CREATE INDEX IF NOT EXISTS "idx_placement_p_creativ_a8f431" ON "placement_post" ("creative_id");""" + + +MODELS_STATE = ( + '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' + 'f/C4NtvdBk9VSAWNZJcSZj+9vP8f6Gvh+w==' +) diff --git a/migrations/models/5_20260120195442_update.py b/migrations/models/5_20260120195442_update.py new file mode 100644 index 0000000..af250b6 --- /dev/null +++ b/migrations/models/5_20260120195442_update.py @@ -0,0 +1,140 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" ALTER COLUMN "status" SET DEFAULT 'Без статуса'; + UPDATE "placement" + SET "status" = 'Без статуса' + WHERE "status" IN ('planned', 'approved', 'in_progress', 'completed', 'rejected'); + ALTER TABLE "placement" ALTER COLUMN "status" TYPE VARCHAR(64) USING "status"::VARCHAR(64); + COMMENT ON COLUMN "placement"."status" IS 'NO_STATUS: Без статуса +WRITE: Написать +WAITING_RESPONSE: Ждём ответа +TERMS_APPROVAL: Согласование условий +TO_PAY: Оплатить +PAID: Оплачено +CANCELED: Отмена +PRICE_NOT_OK: Не подходит цена +NOT_RELEVANT: Неактуально +NO_RESPONSE: Не отвечает'; + ALTER TABLE "placement" ALTER COLUMN "invite_link" DROP NOT NULL; + ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "wanted_placement_date"; + ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "cost"; + ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "comment"; + ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "creative_id"; + ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "project_id"; + ALTER TABLE "placement_post" DROP COLUMN IF EXISTS "status";""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" ALTER COLUMN "status" SET DEFAULT 'planned'; + UPDATE "placement" + SET "status" = 'planned' + WHERE "status" IN ( + 'Без статуса', + 'Написать', + 'Ждём ответа', + 'Согласование условий', + 'Оплатить', + 'Оплачено', + 'Отмена', + 'Не подходит цена', + 'Неактуально', + 'Не отвечает' + ); + COMMENT ON COLUMN "placement"."status" IS 'PLANNED: planned +APPROVED: approved +REJECTED: rejected +IN_PROGRESS: in_progress +COMPLETED: completed'; + ALTER TABLE "placement" ALTER COLUMN "status" TYPE VARCHAR(11) USING "status"::VARCHAR(11); + ALTER TABLE "placement" ALTER COLUMN "invite_link" SET NOT NULL; + ALTER TABLE "placement_post" ADD "wanted_placement_date" TIMESTAMPTZ; + ALTER TABLE "placement_post" ADD "cost" DOUBLE PRECISION; + ALTER TABLE "placement_post" ADD "comment" TEXT; + ALTER TABLE "placement_post" ADD "creative_id" UUID; + ALTER TABLE "placement_post" ADD "project_id" UUID; + ALTER TABLE "placement_post" ADD "status" VARCHAR(8) NOT NULL DEFAULT 'active'; + COMMENT ON COLUMN "placement_post"."status" IS 'ACTIVE: active\nARCHIVED: archived';""" + + +MODELS_STATE = ( + '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' +) diff --git a/migrations/models/6_20260120222211_add_workspace_avatar.py b/migrations/models/6_20260120222211_add_workspace_avatar.py new file mode 100644 index 0000000..504819f --- /dev/null +++ b/migrations/models/6_20260120222211_add_workspace_avatar.py @@ -0,0 +1,92 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "workspace" ADD "avatar_s3_key" VARCHAR(512);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "workspace" DROP COLUMN "avatar_s3_key";""" + + +MODELS_STATE = ( + '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' + 'slSFZHCb7lLC/IeXX/8CYMWYuQ==' +) diff --git a/migrations/models/7_20260121021919_update.py b/migrations/models/7_20260121021919_update.py new file mode 100644 index 0000000..17fd304 --- /dev/null +++ b/migrations/models/7_20260121021919_update.py @@ -0,0 +1,94 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "channel" ADD "invite_link" VARCHAR(1024); + ALTER TABLE "channel" ALTER COLUMN "username" DROP NOT NULL;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "channel" DROP COLUMN "invite_link"; + ALTER TABLE "channel" ALTER COLUMN "username" SET NOT NULL;""" + + +MODELS_STATE = ( + "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" +) diff --git a/migrations/models/8_20260121022045_add_channel_constraint.py b/migrations/models/8_20260121022045_add_channel_constraint.py new file mode 100644 index 0000000..c7acd67 --- /dev/null +++ b/migrations/models/8_20260121022045_add_channel_constraint.py @@ -0,0 +1,96 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "channel" + ADD CONSTRAINT "channel_identity_required" + CHECK (username IS NOT NULL OR invite_link IS NOT NULL); \ + """ + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "channel" DROP CONSTRAINT IF EXISTS "channel_identity_required"; \ + """ + + +MODELS_STATE = ( + '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' +) diff --git a/migrations/models/9_20260122191425_update.py b/migrations/models/9_20260122191425_update.py new file mode 100644 index 0000000..7799bef --- /dev/null +++ b/migrations/models/9_20260122191425_update.py @@ -0,0 +1,136 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "creative_media" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "media_type" VARCHAR(32) NOT NULL, + "media_file_id" VARCHAR(512) NOT NULL, + "media_s3_key" VARCHAR(512), + "position" INT NOT NULL, + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_creative_me_creativ_dbd1c4" ON "creative_media" ("creative_id"); +CREATE UNIQUE INDEX IF NOT EXISTS "uid_creative_media_creative_pos" ON "creative_media" ("creative_id", "position"); + INSERT INTO "creative_media" ( + "id", "created_at", "updated_at", "deleted_at", + "media_type", "media_file_id", "media_s3_key", "position", "creative_id" +) +SELECT + gen_random_uuid(), + "created_at", + "updated_at", + "deleted_at", + "media_type", + "media_file_id", + "media_s3_key", + 0, + "id" +FROM "creative" +WHERE "media_file_id" IS NOT NULL AND "media_type" IS NOT NULL; + ALTER TABLE "creative" DROP COLUMN "media_type"; + ALTER TABLE "creative" DROP COLUMN "media_s3_key"; + ALTER TABLE "creative" DROP COLUMN "media_file_id";""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "creative" ADD "media_type" VARCHAR(32); + ALTER TABLE "creative" ADD "media_s3_key" VARCHAR(512); + ALTER TABLE "creative" ADD "media_file_id" VARCHAR(512); + UPDATE "creative" AS c + SET + "media_type" = cm."media_type", + "media_s3_key" = cm."media_s3_key", + "media_file_id" = cm."media_file_id" + FROM "creative_media" AS cm + WHERE cm."creative_id" = c."id" AND cm."position" = 0; + DROP TABLE IF EXISTS "creative_media";""" + + +MODELS_STATE = ( + "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" + "3zVIVkcJ3uUsL8p5f3fwEyCCxF" +) diff --git a/pkg/go.mod b/pkg/go.mod new file mode 100644 index 0000000..8ffc90d --- /dev/null +++ b/pkg/go.mod @@ -0,0 +1,9 @@ +module github.com/TelegramExchange/pkg + +go 1.24.4 + +require ( + github.com/gotd/td v0.136.0 + github.com/jackc/pgx/v5 v5.7.6 + github.com/rs/zerolog v1.34.0 +) diff --git a/pkg/go.sum b/pkg/go.sum new file mode 100644 index 0000000..39ea11a --- /dev/null +++ b/pkg/go.sum @@ -0,0 +1,13 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gotd/td v0.136.0/go.mod h1:mStcqs/9FXhNhWnPTguptSwqkQbRIwXLw3SCSpzPJxM= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/postgres/postgres.go b/pkg/postgres/postgres.go new file mode 100644 index 0000000..848eb9e --- /dev/null +++ b/pkg/postgres/postgres.go @@ -0,0 +1,37 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog/log" +) + +type Config struct { + URL string `envconfig:"DB__URL" required:"true"` +} + +type Pool struct { + *pgxpool.Pool +} + +func New(ctx context.Context, c Config) (*Pool, error) { + cfg, err := pgxpool.ParseConfig(c.URL) + if err != nil { + return nil, fmt.Errorf("pgxpool.ParseConfig: %w", err) + } + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("pgxpool.NewWithConfig: %w", err) + } + + return &Pool{Pool: pool}, nil +} + +func (p *Pool) Close() { + p.Pool.Close() + + log.Info().Msg("Postgres closed") +} diff --git a/pkg/telegram/telegram.go b/pkg/telegram/telegram.go new file mode 100644 index 0000000..c2702a5 --- /dev/null +++ b/pkg/telegram/telegram.go @@ -0,0 +1,90 @@ +package telegram + +import ( + "context" + "fmt" + + "github.com/rs/zerolog/log" + + "github.com/gotd/td/session" + "github.com/gotd/td/telegram" +) + +type Config struct { + ApiID int `envconfig:"TELEGRAM__API_ID" required:"true"` + ApiHash string `envconfig:"TELEGRAM__API_HASH" required:"true"` + SessionFile string `envconfig:"TELEGRAM__SESSION_FILE" required:"true"` +} + +type Client struct { + *telegram.Client + cancel context.CancelFunc + done chan struct{} +} + +func New(cfg Config) (*Client, error) { + ctx := context.Background() + + tg := telegram.NewClient(cfg.ApiID, cfg.ApiHash, telegram.Options{ + SessionStorage: &session.FileStorage{Path: cfg.SessionFile}, + }) + + runCtx, cancel := context.WithCancel(ctx) + + ready := make(chan struct{}) + done := make(chan struct{}) + + client := &Client{ + Client: tg, + cancel: cancel, + done: done, + } + + go func() { + err := tg.Run(runCtx, func(ctx context.Context) error { + status, err := tg.Auth().Status(ctx) + if err != nil { + close(ready) + return fmt.Errorf("telegram.Auth error: %w", err) + } + if !status.Authorized { + close(ready) + return fmt.Errorf("session not authorized") + } + + // Получаем информацию о текущем пользователе + self, err := tg.Self(ctx) + if err != nil { + log.Warn().Err(err).Msg("Failed to get user info") + } else { + log.Info(). + Int64("id", self.ID). + Str("username", "@"+self.Username). + Str("first_name", self.FirstName). + Str("last_name", self.LastName). + Str("phone", self.Phone). + Msg("Authorized as user") + } + + close(ready) + + <-ctx.Done() + return ctx.Err() + }) + + if err != nil { + log.Error().Err(err).Msg("telegram client stopped") + } + + close(done) + }() + + <-ready + + return client, nil +} + +func (c *Client) Close() { + c.cancel() + <-c.done +} diff --git a/pkg/transaction/transaction.go b/pkg/transaction/transaction.go new file mode 100644 index 0000000..29e4d19 --- /dev/null +++ b/pkg/transaction/transaction.go @@ -0,0 +1,101 @@ +package transaction + +import ( + "context" + "errors" + "fmt" + + "github.com/TelegramExchange/pkg/postgres" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog/log" +) + +var ( + errMissingInit = errors.New("missing `transaction.Init' call before `transaction.Begin'") + errMissingBegin = errors.New("missing `transaction.Begin' call before 'transaction.Get'") +) + +var ( + pool *pgxpool.Pool + IsUnitTest bool +) + +type ctxKey struct{} + +func Init(p *postgres.Pool) { + pool = p.Pool +} + +type Transaction struct { + pgx.Tx +} + +func Begin(ctx context.Context) (context.Context, error) { + if IsUnitTest { + return ctx, nil + } + + if pool == nil { + return nil, errMissingInit + } + + tx, err := pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("pool.Begin: %w", err) + } + + ctx = context.WithValue(ctx, ctxKey{}, &Transaction{tx}) + + return ctx, nil +} + +func Rollback(ctx context.Context) { + if IsUnitTest { + return + } + + tx, ok := ctx.Value(ctxKey{}).(*Transaction) + if !ok { + return + } + + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error().Err(err).Msg("transaction: Rollback") + } +} + +func Commit(ctx context.Context) error { + if IsUnitTest { + return nil + } + + tx, ok := ctx.Value(ctxKey{}).(*Transaction) + if !ok { + return errMissingBegin + } + + err := tx.Commit(ctx) + if err != nil { + return fmt.Errorf("tx.Commit: %w", err) + } + + return nil +} + +type Executor interface { + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +func TryExtractTX(ctx context.Context) Executor { + tx, ok := ctx.Value(ctxKey{}).(*Transaction) + if !ok { + return pool + } + + return tx +} diff --git a/pkg/transaction/wrap.go b/pkg/transaction/wrap.go new file mode 100644 index 0000000..97245e3 --- /dev/null +++ b/pkg/transaction/wrap.go @@ -0,0 +1,38 @@ +package transaction + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/rs/zerolog/log" +) + +func Wrap(ctx context.Context, fn func(context.Context) error) error { + tx, err := pool.Begin(ctx) + if err != nil { + return fmt.Errorf("pool.Begin: %w", err) + } + + defer func() { + err = tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error().Err(err).Msg("transaction: Rollback") + } + }() + + ctx = context.WithValue(ctx, ctxKey{}, &Transaction{tx}) + + err = fn(ctx) + if err != nil { + return fmt.Errorf("fn: %w", err) + } + + err = tx.Commit(ctx) + if err != nil { + return fmt.Errorf("tx.Commit: %w", err) + } + + return nil +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e61be61 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,74 @@ +[project] +name = "tgex-backend" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "aerich>=0.9.2", + "aioboto3>=13.3.0", + "aiogram>=3.16.0", + "aiolimiter>=1.2.1", + "asyncpg>=0.30.0", + "beautifulsoup4>=4.14.2", + "fastapi>=0.121.0", + "fastapi-pagination>=0.15.3", + "httpx>=0.28.1", + "lxml>=6.0.2", + "pydantic-settings>=2.11.0", + "pyjwt>=2.10.1", + "python-multipart>=0.0.20", + "tortoise-orm>=0.25.1", + "tortoise-orm-stubs>=1.0.2", + "types-aiobotocore-s3>=2.15.2", + "uvicorn>=0.38.0", +] + +[dependency-groups] +dev = [ + "mypy>=1.18.2", + "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", + "ruff>=0.14.4", + "ty>=0.0.1a25", + "vulture>=2.14", +] + +[tool.ruff] +exclude = [".venv"] +target-version = "py313" +line-length = 120 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = ["B008"] +fixable = ["ALL"] +unfixable = [] + +[tool.ruff.format] +quote-style = "single" + +[tool.mypy] +strict = true +plugins = [ + "pydantic.mypy", +] +exclude = [ + "tests/conftest.py" +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src", "."] + +[tool.aerich] +tortoise_orm = "shared.datebase_base.TORTOISE_ORM" +location = "./migrations" +src_folder = "./." diff --git a/shared/__init__.py b/shared/__init__.py new file mode 100644 index 0000000..5a5f44f --- /dev/null +++ b/shared/__init__.py @@ -0,0 +1 @@ +"""Shared utilities package.""" diff --git a/shared/config_helper.py b/shared/config_helper.py new file mode 100644 index 0000000..de30f34 --- /dev/null +++ b/shared/config_helper.py @@ -0,0 +1,36 @@ +import sys + +from pydantic import ValidationError +from pydantic_settings import BaseSettings + +RED = '\033[91m' +YELLOW = '\033[33m' +RESET = '\033[0m' +BOLD = '\033[1m' + + +def load_settings[T: BaseSettings](settings_class: type[T]) -> T: + try: + return settings_class() + except ValidationError as e: + print(f'{RED}{BOLD}Missing environment variables:{RESET}') + + missing: set[str] = set() + for err in e.errors(): + if err['type'] == 'missing' and len(err['loc']) == 1: + field_info = settings_class.model_fields.get(str(err['loc'][0])) + if field_info and field_info.annotation is not None and hasattr(field_info.annotation, 'model_fields'): + annotation_fields = getattr(field_info.annotation, 'model_fields', {}) + for name, info in annotation_fields.items(): + if info.is_required(): + missing.add(f'{err["loc"][0]}__{name}'.upper()) + else: + missing.add('__'.join(str(loc).upper() for loc in err['loc'])) + else: + missing.add('__'.join(str(loc).upper() for loc in err['loc'])) + + for field_name in sorted(missing): + print(f' {YELLOW}{field_name}{RESET}') + + print(f'\n{RED}Check .env file or set required variables{RESET}') + sys.exit(1) diff --git a/shared/datebase_base.py b/shared/datebase_base.py new file mode 100644 index 0000000..b2093a4 --- /dev/null +++ b/shared/datebase_base.py @@ -0,0 +1,85 @@ +import logging +import os + +import pydantic +from tortoise import Tortoise + + +class DatabaseConfig(pydantic.BaseModel): + URL: pydantic.PostgresDsn + ECHO: bool = False + ECHO_POOL: bool = False + POOL_SIZE: int = 50 + MAX_OVERFLOW: int = 10 + + +TORTOISE_ORM = { + 'connections': {'default': os.getenv('DB__URL')}, + 'apps': { + 'models': { + 'models': ['src.domain', 'aerich.models'], + 'default_connection': 'default', + }, + }, + 'use_tz': True, + 'timezone': 'UTC', +} + + +class DatabaseBase: + def __init__(self, config: DatabaseConfig) -> None: + self.config = config + + async def connect(self) -> None: + if self.config.ECHO: + logging.getLogger('tortoise.db_client').setLevel(logging.DEBUG) + logging.getLogger('tortoise').setLevel(logging.DEBUG) + + await Tortoise.init( + db_url=str(self.config.URL), + modules={'models': ['src.domain', 'aerich.models']}, + use_tz=True, + timezone='UTC', + ) + + await self.ping() + await self._check_migrations() + + async def close(self) -> None: + await Tortoise.close_connections() + + async def ping(self) -> None: + from tortoise import connections + + conn = connections.get('default') + try: + await conn.execute_query('SELECT 1') + except Exception as e: + import logging + + logging.error(f'Database ping failed: {e}') + raise + + async def _check_migrations(self) -> None: + import logging + + from tortoise import connections + + conn = connections.get('default') + + # Проверяем существование таблицы aerich + result = await conn.execute_query( + "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'aerich')" + ) + if not result[1][0]['exists']: + logging.warning('Migrations not applied. Run: aerich upgrade') + return + + # Получаем последнюю примененную версию + try: + result = await conn.execute_query('SELECT version, app FROM aerich ORDER BY id DESC LIMIT 1') + if result[1]: + current_version = result[1][0]['version'] + logging.info(f'Current migration version: {current_version}') + except Exception as e: + logging.debug(f'Could not check migration version: {e}') diff --git a/shared/echotron/.github/workflows/build.yml b/shared/echotron/.github/workflows/build.yml new file mode 100644 index 0000000..0e686f2 --- /dev/null +++ b/shared/echotron/.github/workflows/build.yml @@ -0,0 +1,20 @@ +--- +name: Build + +on: [push, pull_request] + +jobs: + test: + strategy: + matrix: + go-version: [1.19.x] + os: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.go-version }} + + - uses: actions/checkout@v3 + - run: go build diff --git a/shared/echotron/.github/workflows/test.yml b/shared/echotron/.github/workflows/test.yml new file mode 100644 index 0000000..2404dea --- /dev/null +++ b/shared/echotron/.github/workflows/test.yml @@ -0,0 +1,25 @@ +--- +name: Test + +on: [push, pull_request] + +jobs: + test: + strategy: + matrix: + go-version: [1.19.x] + os: [ubuntu-latest] + + runs-on: ${{ matrix.os }} + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.go-version }} + + - uses: actions/checkout@v3 + - run: go test -coverprofile=coverage.out -covermode=atomic + + - uses: codecov/codecov-action@v4 + with: + files: ./coverage.out + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/shared/echotron/COPYING.LESSER b/shared/echotron/COPYING.LESSER new file mode 100644 index 0000000..65c5ca8 --- /dev/null +++ b/shared/echotron/COPYING.LESSER @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/shared/echotron/LICENSE b/shared/echotron/LICENSE new file mode 100644 index 0000000..9ff5cb5 --- /dev/null +++ b/shared/echotron/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + echotron + Copyright (C) 2019 Nicolò Santamaria + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + echotron Copyright (C) 2019 Nicolò Santamaria + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/shared/echotron/README.md b/shared/echotron/README.md new file mode 100644 index 0000000..bff8f61 --- /dev/null +++ b/shared/echotron/README.md @@ -0,0 +1,339 @@ +| logo

[![Language](https://img.shields.io/badge/Language-Go-blue.svg)](https://golang.org/) [![PkgGoDev](https://pkg.go.dev/badge/github.com/NicoNex/echotron/v3)](https://pkg.go.dev/github.com/NicoNex/echotron/v3) [![Go Report Card](https://goreportcard.com/badge/github.com/NicoNex/echotron/v3)](https://goreportcard.com/report/github.com/NicoNex/echotron/v3) [![codecov](https://codecov.io/gh/NicoNex/echotron/graph/badge.svg?token=LVJGOEYL5M)](https://codecov.io/gh/NicoNex/echotron) [![License](http://img.shields.io/badge/license-LGPL3.0-orange.svg?style=flat)](https://github.com/NicoNex/echotron/blob/master/LICENSE) [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go) [![Telegram](https://img.shields.io/badge/Echotron%20News-blue?logo=telegram&style=flat)](https://t.me/echotronnews) | +| :------: | + +**Echotron** is an elegant and concurrent library for the Telegram bot API in Go. + +Fetch with + +```bash +go get github.com/NicoNex/echotron/v3 +``` + +## Example +### Simplest implementations +#### Long polling +```golang +package main + +import "github.com/NicoNex/echotron/v3" + +const token = "MY TELEGRAM TOKEN" + +func main() { + api := echotron.NewAPI(token) + + for u := range echotron.PollingUpdates(token) { + if u.Message.Text == "/start" { + api.SendMessage("Hello world", u.ChatID(), nil) + } + } +} +``` +#### Webhook +```golang +package main + +import "github.com/NicoNex/echotron/v3" + +const token = "MY TELEGRAM TOKEN" + +func main() { + api := echotron.NewAPI(token) + + for u := range echotron.WebhookUpdates("https://example.com:443/my_token", token) { + if u.Message.Text == "/start" { + api.SendMessage("Hello world", u.ChatID(), nil) + } + } +} +``` +For more scalable and recommended implementations see the other examples. + +### Long Polling + +```golang +package main + +import ( + "log" + "time" + + "github.com/NicoNex/echotron/v3" +) + +// Struct useful for managing internal states in your bot, but it could be of +// any type such as `type bot int64` if you only need to store the chatID. +type bot struct { + chatID int64 + echotron.API +} + +const token = "MY TELEGRAM TOKEN" + +// This function needs to be of type 'echotron.NewBotFn' and is called by +// the echotron dispatcher upon any new message from a chatID that has never +// interacted with the bot before. +// This means that echotron keeps one instance of the echotron.Bot implementation +// for each chat where the bot is used. +func newBot(chatID int64) echotron.Bot { + return &bot{ + chatID, + echotron.NewAPI(token), + } +} + +// This method is needed to implement the echotron.Bot interface. +func (b *bot) Update(update *echotron.Update) { + if update.Message.Text == "/start" { + b.SendMessage("Hello world", b.chatID, nil) + } +} + +func main() { + // This is the entry point of echotron library. + dsp := echotron.NewDispatcher(token, newBot) + for { + log.Println(dsp.Poll()) + // In case of connection issues wait 5 seconds before trying to reconnect. + time.Sleep(5 * time.Second) + } +} +``` + +## Design + +**Echotron** makes a new instance of the struct bot for each open chat with a Telegram user, channel or group. +This allows to: +- safely call the `Update(*echotron.Update)` method concurrently +- give to the user a convenient way to manage the bot internal states across all the chats +- make sure that, even if one instance of the bot is deadlocked, the other ones keep running just fine, making the bot work for other users without any issues and/or slowdowns. + +Please note that the the aforementioned behaviour is dictated by the `echotron.Dispatcher` object whose usage is not mandatory and for special needs can be ignored and implemented in different ways still keeping all the methods in the `echotron.API` object. + +**Echotron** is designed to be as similar to the official [Telegram API](https://core.telegram.org/bots/api) as possible, but there are some things to take into account before starting to work with this library. + +- The methods have the exact same name, but with a capital first letter, since in Go methods have to start with a capital letter to be exported. +_Example: `sendMessage` becomes `SendMessage`_ +- The order of the parameters in some methods is different than in the official Telegram API, so refer to the [docs](https://pkg.go.dev/github.com/NicoNex/echotron/v3) for the correct one. +- The only `chat_id` (or, in this case, `chatID`) type supported is `int64`, instead of the "Integer or String" requirement of the official API. That's because numeric IDs can't change in any way, which isn't the case with text-based usernames. +- In some methods, you might find a `InputFile` type parameter. [`InputFile`](https://pkg.go.dev/github.com/NicoNex/echotron/v3#InputFile) is a struct with unexported fields, since only three combination of fields are valid, which can be obtained through the methods [`NewInputFileID`](https://pkg.go.dev/github.com/NicoNex/echotron/v3#NewInputFileID), [`NewInputFilePath`](https://pkg.go.dev/github.com/NicoNex/echotron/v3#NewInputFilePath) and [`NewInputFileBytes`](https://pkg.go.dev/github.com/NicoNex/echotron/v3#NewInputFileBytes). +- In some methods, you might find a `MessageIDOptions` type parameter. [`MessageIDOptions`](https://pkg.go.dev/github.com/NicoNex/echotron/v3#MessageIDOptions) is another struct with unexported fields, since only two combination of field are valid, which can be obtained through the methods [`NewMessageID`](https://pkg.go.dev/github.com/NicoNex/echotron/v3#NewMessageID) and [`NewInlineMessageID`](https://pkg.go.dev/github.com/NicoNex/echotron/v3#NewInlineMessageID). +- Optional parameters can be added by passing the correct struct to each method that might request optional parameters. If you don't want to pass any optional parameter, `nil` is more than enough. Refer to the [docs](https://pkg.go.dev/github.com/NicoNex/echotron/v3) to check for each method's optional parameters struct: it's the type of the `opts` parameter. +- Some parameters are hardcoded to avoid putting random stuff which isn't recognized by the Telegram API. Some notable examples are [`ParseMode`](https://github.com/NicoNex/echotron/blob/master/options.go#L21), [`ChatAction`](https://github.com/NicoNex/echotron/blob/master/options.go#L54) and [`InlineQueryType`](https://github.com/NicoNex/echotron/blob/master/inline.go#L27). For a full list of custom hardcoded parameters, refer to the [docs](https://pkg.go.dev/github.com/NicoNex/echotron/v3) for each custom type: by clicking on the type's name, you'll get the source which contains the possible values for that type. + +## Additional examples +### Functional approach to state management +```golang +package main + +import ( + "log" + "strings" + + "github.com/NicoNex/echotron/v3" +) + +// Recursive type definition of the bot state function. +type stateFn func(*echotron.Update) stateFn + +type bot struct { + chatID int64 + state stateFn + name string + echotron.API +} + +const token = "MY TELEGRAM TOKEN" + +func newBot(chatID int64) echotron.Bot { + bot := &bot{ + chatID: chatID, + API: echotron.NewAPI(token), + } + // We set the default state to the bot.handleMessage method. + bot.state = bot.handleMessage + return bot +} + +func (b *bot) Update(update *echotron.Update) { + // Here we execute the current state and set the next one. + b.state = b.state(update) +} + +func (b *bot) handleMessage(update *echotron.Update) stateFn { + if strings.HasPrefix(update.Message.Text, "/set_name") { + b.SendMessage("Send me my new name!", b.chatID, nil) + // Here we return b.handleName since next time we receive a message it + // will be the new name. + return b.handleName + } + return b.handleMessage +} + +func (b *bot) handleName(update *echotron.Update) stateFn { + b.name = update.Message.Text + b.SendMessage(fmt.Sprintf("My new name is %q", b.name), b.chatID, nil) + // Here we return b.handleMessage since the next time we receive a message + // it will be handled in the default way. + return b.handleMessage +} + +func main() { + dsp := echotron.NewDispatcher(token, newBot) + log.Println(dsp.Poll()) +} +``` + +### Self destruction for lower memory footprint +```golang +package main + +import ( + "log" + "time" + + "github.com/NicoNex/echotron/v3" +) + +type bot struct { + chatID int64 + echotron.API +} + +const token = "MY TELEGRAM TOKEN" + +var dsp *echotron.Dispatcher + +func newBot(chatID int64) echotron.Bot { + bot := &bot{ + chatID, + echotron.NewAPI(token), + } + go bot.selfDestruct(time.After(time.Hour)) + return bot +} + +func (b *bot) selfDestruct(timech <-chan time.Time) { + <-timech + b.SendMessage("goodbye", b.chatID, nil) + dsp.DelSession(b.chatID) +} + +func (b *bot) Update(update *echotron.Update) { + if update.Message.Text == "/start" { + b.SendMessage("Hello world", b.chatID, nil) + } +} + +func main() { + dsp = echotron.NewDispatcher(token, newBot) + log.Println(dsp.Poll()) +} +``` + +### Webhook + +```golang +package main + +import "github.com/NicoNex/echotron/v3" + +type bot struct { + chatID int64 + echotron.API +} + +const token = "MY TELEGRAM TOKEN" + +func newBot(chatID int64) echotron.Bot { + return &bot{ + chatID, + echotron.NewAPI(token), + } +} + +func (b *bot) Update(update *echotron.Update) { + if update.Message.Text == "/start" { + b.SendMessage("Hello world", b.chatID, nil) + } +} + +func main() { + dsp := echotron.NewDispatcher(token, newBot) + dsp.ListenWebhook("https://example.com:443/my_bot_token") +} +``` + + +### Webhook with a custom http.Server + +This is an example for a custom http.Server which handles your own specified routes +and also the webhook route which is specified by ListenWebhook. + +```golang +package main + +import ( + "github.com/NicoNex/echotron/v3" + + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" +) + +type bot struct { + chatID int64 + echotron.API +} + +const token = "MY TELEGRAM TOKEN" + +func newBot(chatID int64) echotron.Bot { + return &bot{ + chatID, + echotron.NewAPI(token), + } +} + +func (b *bot) Update(update *echotron.Update) { + if update.Message.Text == "/start" { + b.SendMessage("Hello world", b.chatID, nil) + } +} + +func main() { + termChan := make(chan os.Signal, 1) // Channel for terminating the app via os.Interrupt signal + signal.Notify(termChan, syscall.SIGINT, syscall.SIGTERM) + + mux := http.NewServeMux() + mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { + // Handle user login + }) + mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { + // Handle user logout + }) + mux.HandleFunc("/about", func(w http.ResponseWriter, r *http.Request) { + // Tell something about your awesome telegram bot + }) + + // Set custom http.Server + server := &http.Server{Addr: ":8080", Handler: mux} + + go func() { + <-termChan + // Perform some cleanup.. + if err := server.Shutdown(context.Background()); err != nil { + log.Print(err) + } + }() + + // Capture the interrupt signal for app termination handling + dsp := echotron.NewDispatcher(token, newBot) + dsp.SetHTTPServer(server) + // Start your custom http.Server with a registered /my_bot_token handler. + log.Println(dsp.ListenWebhook("https://example.com/my_bot_token")) +} +``` diff --git a/shared/echotron/api.go b/shared/echotron/api.go new file mode 100644 index 0000000..6e1f4ea --- /dev/null +++ b/shared/echotron/api.go @@ -0,0 +1,1097 @@ +/* + * Echotron + * Copyright (C) 2018 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" +) + +// API is the object that contains all the functions that wrap those of the Telegram Bot API. +type API struct { + token string + base string +} + +// NewAPI returns a new API object. +func NewAPI(token string) API { + return API{ + token: token, + base: fmt.Sprintf("https://api.telegram.org/bot%s/", token), + } +} + +// NewLocalAPI is like NewAPI but allows to use a local API server. +func NewLocalAPI(url, token string) API { + return API{ + token: token, + base: url, + } +} + +// GetUpdates is used to receive incoming updates using long polling. +func (a API) GetUpdates(opts *UpdateOptions) (res APIResponseUpdate, err error) { + return res, client.get(a.base, "getUpdates", urlValues(opts), &res) +} + +// SetWebhook is used to specify a url and receive incoming updates via an outgoing webhook. +func (a API) SetWebhook(webhookURL string, dropPendingUpdates bool, opts *WebhookOptions) (res APIResponseBase, err error) { + var ( + vals = make(url.Values) + keyVal = map[string]string{"url": webhookURL} + ) + + url, err := url.JoinPath(a.base, "setWebhook") + if err != nil { + return res, err + } + + vals.Set("drop_pending_updates", btoa(dropPendingUpdates)) + addValues(vals, opts) + url = fmt.Sprintf("%s?%s", strings.TrimSuffix(url, "/"), vals.Encode()) + + cnt, err := client.doPostForm(url, keyVal) + if err != nil { + return + } + + if err = json.Unmarshal(cnt, &res); err != nil { + return + } + + err = check(res) + return +} + +// DeleteWebhook is used to remove webhook integration if you decide to switch back to GetUpdates. +func (a API) DeleteWebhook(dropPendingUpdates bool) (res APIResponseBase, err error) { + var vals = make(url.Values) + vals.Set("drop_pending_updates", btoa(dropPendingUpdates)) + + return res, client.get(a.base, "deleteWebhook", vals, &res) +} + +// GetWebhookInfo is used to get current webhook status. +func (a API) GetWebhookInfo() (res APIResponseWebhook, err error) { + return res, client.get(a.base, "getWebhookInfo", nil, &res) +} + +// GetMe is a simple method for testing your bot's auth token. +func (a API) GetMe() (res APIResponseUser, err error) { + return res, client.get(a.base, "getMe", nil, &res) +} + +// LogOut is used to log out from the cloud Bot API server before launching the bot locally. +// You MUST log out the bot before running it locally, otherwise there is no guarantee that the bot will receive updates. +// After a successful call, you can immediately log in on a local server, +// but will not be able to log in back to the cloud Bot API server for 10 minutes. +func (a API) LogOut() (res APIResponseBool, err error) { + return res, client.get(a.base, "logOut", nil, &res) +} + +// Close is used to close the bot instance before moving it from one local server to another. +// You need to delete the webhook before calling this method to ensure that the bot isn't launched again after server restart. +// The method will return error 429 in the first 10 minutes after the bot is launched. +func (a API) Close() (res APIResponseBool, err error) { + return res, client.get(a.base, "close", nil, &res) +} + +// SendMessage is used to send text messages. +func (a API) SendMessage(text string, chatID int64, opts *MessageOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + vals.Set("text", text) + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "sendMessage", addValues(vals, opts), &res) +} + +// ForwardMessage is used to forward messages of any kind. +// Service messages can't be forwarded. +func (a API) ForwardMessage(chatID, fromChatID int64, messageID int, opts *ForwardOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("from_chat_id", itoa(fromChatID)) + vals.Set("message_id", itoa(int64(messageID))) + return res, client.get(a.base, "forwardMessage", addValues(vals, opts), &res) +} + +// ForwardMessages is used to forward multiple messages of any kind. +// If some of the specified messages can't be found or forwarded, they are skipped. +// Service messages and messages with protected content can't be forwarded. +// Album grouping is kept for forwarded messages. +func (a API) ForwardMessages(chatID, fromChatID int64, messageIDs []int, opts *ForwardOptions) (res APIResponseMessageIDs, err error) { + var vals = make(url.Values) + + msgIDs, err := json.Marshal(messageIDs) + if err != nil { + return res, err + } + + vals.Set("chat_id", itoa(chatID)) + vals.Set("from_chat_id", itoa(fromChatID)) + vals.Set("message_ids", string(msgIDs)) + return res, client.get(a.base, "forwardMessages", addValues(vals, opts), &res) +} + +// CopyMessage is used to copy messages of any kind. +// Service messages, paid media mesages, giveaway messages, giveaway winners messages, and invoice messages can't be copied. +// The method is analogous to the method ForwardMessage, +// but the copied message doesn't have a link to the original message. +func (a API) CopyMessage(chatID, fromChatID int64, messageID int, opts *CopyOptions) (res APIResponseMessageID, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("from_chat_id", itoa(fromChatID)) + vals.Set("message_id", itoa(int64(messageID))) + return res, client.get(a.base, "copyMessage", addValues(vals, opts), &res) +} + +// CopyMessages is used to copy messages of any kind. +// If some of the specified messages can't be found or copied, they are skipped. +// Service messages, paid media mesages, giveaway messages, giveaway winners messages, and invoice messages can't be copied. +// A quiz poll can be copied only if the value of the field correct_option_id is known to the bot. +// The method is analogous to the method forwardMessages, but the copied messages don't have a link to the original message. +// Album grouping is kept for copied messages. +func (a API) CopyMessages(chatID, fromChatID int64, messageIDs []int, opts *CopyMessagesOptions) (res APIResponseMessageIDs, err error) { + var vals = make(url.Values) + + msgIDs, err := json.Marshal(messageIDs) + if err != nil { + return res, err + } + + vals.Set("chat_id", itoa(chatID)) + vals.Set("from_chat_id", itoa(fromChatID)) + vals.Set("message_ids", string(msgIDs)) + return res, client.get(a.base, "copyMessages", addValues(vals, opts), &res) +} + +// SendPhoto is used to send photos. +func (a API) SendPhoto(file InputFile, chatID int64, opts *PhotoOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.postFile(a.base, "sendPhoto", "photo", file, InputFile{}, addValues(vals, opts), &res) +} + +// SendAudio is used to send audio files, +// if you want Telegram clients to display them in the music player. +// Your audio must be in the .MP3 or .M4A format. +func (a API) SendAudio(file InputFile, chatID int64, opts *AudioOptions) (res APIResponseMessage, err error) { + var ( + thumbnail InputFile + vals = make(url.Values) + ) + + if opts != nil { + thumbnail = opts.Thumbnail + } + + vals.Set("chat_id", itoa(chatID)) + return res, client.postFile(a.base, "sendAudio", "audio", file, thumbnail, addValues(vals, opts), &res) +} + +// SendDocument is used to send general files. +func (a API) SendDocument(file InputFile, chatID int64, opts *DocumentOptions) (res APIResponseMessage, err error) { + var ( + thumbnail InputFile + vals = make(url.Values) + ) + + if opts != nil { + thumbnail = opts.Thumbnail + } + + vals.Set("chat_id", itoa(chatID)) + return res, client.postFile(a.base, "sendDocument", "document", file, thumbnail, addValues(vals, opts), &res) +} + +// SendVideo is used to send video files. +// Telegram clients support mp4 videos (other formats may be sent with SendDocument). +func (a API) SendVideo(file InputFile, chatID int64, opts *VideoOptions) (res APIResponseMessage, err error) { + var ( + thumbnail InputFile + vals = make(url.Values) + ) + + if opts != nil { + thumbnail = opts.Thumbnail + } + + vals.Set("chat_id", itoa(chatID)) + return res, client.postFile(a.base, "sendVideo", "video", file, thumbnail, addValues(vals, opts), &res) +} + +// SendAnimation is used to send animation files (GIF or H.264/MPEG-4 AVC video without sound). +func (a API) SendAnimation(file InputFile, chatID int64, opts *AnimationOptions) (res APIResponseMessage, err error) { + var ( + thumbnail InputFile + vals = make(url.Values) + ) + + if opts != nil { + thumbnail = opts.Thumbnail + } + + vals.Set("chat_id", itoa(chatID)) + return res, client.postFile(a.base, "sendAnimation", "animation", file, thumbnail, addValues(vals, opts), &res) +} + +// SendVoice is used to send audio files, if you want Telegram clients to display the file as a playable voice message. +// For this to work, your audio must be in an .OGG file encoded with OPUS (other formats may be sent as Audio or Document). +func (a API) SendVoice(file InputFile, chatID int64, opts *VoiceOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.postFile(a.base, "sendVoice", "voice", file, InputFile{}, addValues(vals, opts), &res) +} + +// SendVideoNote is used to send video messages. +func (a API) SendVideoNote(file InputFile, chatID int64, opts *VideoNoteOptions) (res APIResponseMessage, err error) { + var ( + thumbnail InputFile + vals = make(url.Values) + ) + + if opts != nil { + thumbnail = opts.Thumbnail + } + + vals.Set("chat_id", itoa(chatID)) + return res, client.postFile(a.base, "sendVideoNote", "video_note", file, thumbnail, addValues(vals, opts), &res) +} + +// SendPaidMedia is used to send paid media to channel chats. +func (a API) SendPaidMedia(chatID int64, starCount int64, media []GroupableInputMedia, opts *PaidMediaOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("star_count", itoa(starCount)) + return res, client.postMedia(a.base, "sendPaidMedia", false, addValues(vals, opts), &res, toInputMedia(media)...) +} + +// SendMediaGroup is used to send a group of photos, videos, documents or audios as an album. +// Documents and audio files can be only grouped in an album with messages of the same type. +func (a API) SendMediaGroup(chatID int64, media []GroupableInputMedia, opts *MediaGroupOptions) (res APIResponseMessageArray, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.postMedia(a.base, "sendMediaGroup", false, addValues(vals, opts), &res, toInputMedia(media)...) +} + +// SendLocation is used to send point on the map. +func (a API) SendLocation(chatID int64, latitude, longitude float64, opts *LocationOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("latitude", ftoa(latitude)) + vals.Set("longitude", ftoa(longitude)) + return res, client.get(a.base, "sendLocation", addValues(vals, opts), &res) +} + +// EditMessageLiveLocation is used to edit live location messages. +// A location can be edited until its `LivePeriod` expires or editing is explicitly disabled by a call to `StopMessageLiveLocation`. +func (a API) EditMessageLiveLocation(msg MessageIDOptions, latitude, longitude float64, opts *EditLocationOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + vals.Set("latitude", ftoa(latitude)) + vals.Set("longitude", ftoa(longitude)) + return res, client.get(a.base, "editMessageLiveLocation", addValues(addValues(vals, msg), opts), &res) +} + +// StopMessageLiveLocation is used to stop updating a live location message before `LivePeriod` expires. +func (a API) StopMessageLiveLocation(msg MessageIDOptions, opts *StopLocationOptions) (res APIResponseMessage, err error) { + return res, client.get(a.base, "stopMessageLiveLocation", addValues(urlValues(msg), opts), &res) +} + +// SendVenue is used to send information about a venue. +func (a API) SendVenue(chatID int64, latitude, longitude float64, title, address string, opts *VenueOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("latitude", ftoa(latitude)) + vals.Set("longitude", ftoa(longitude)) + vals.Set("title", title) + vals.Set("address", address) + return res, client.get(a.base, "sendVenue", addValues(vals, opts), &res) +} + +// SendContact is used to send phone contacts. +func (a API) SendContact(phoneNumber, firstName string, chatID int64, opts *ContactOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("phone_number", phoneNumber) + vals.Set("first_name", firstName) + return res, client.get(a.base, "sendContact", addValues(vals, opts), &res) +} + +// SendPoll is used to send a native poll. +func (a API) SendPoll(chatID int64, question string, options []InputPollOption, opts *PollOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + pollOpts, err := json.Marshal(options) + if err != nil { + return res, err + } + + vals.Set("chat_id", itoa(chatID)) + vals.Set("question", question) + vals.Set("options", string(pollOpts)) + return res, client.get(a.base, "sendPoll", addValues(vals, opts), &res) +} + +// SendDice is used to send an animated emoji that will display a random value. +func (a API) SendDice(chatID int64, emoji DiceEmoji, opts *BaseOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("emoji", string(emoji)) + return res, client.get(a.base, "sendDice", addValues(vals, opts), &res) +} + +// SendChatAction is used to tell the user that something is happening on the bot's side. +// The status is set for 5 seconds or less (when a message arrives from your bot, Telegram clients clear its typing status). +func (a API) SendChatAction(action ChatAction, chatID int64, opts *ChatActionOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("action", string(action)) + return res, client.get(a.base, "sendChatAction", addValues(vals, opts), &res) +} + +// SetMessageReaction is used to change the chosen reactions on a message. +// Service messages can't be reacted to. +// Automatically forwarded messages from a channel to its discussion group have the same available reactions as messages in the channel. +// In albums, bots must react to the first message. +func (a API) SetMessageReaction(chatID int64, messageID int, opts *MessageReactionOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("message_id", itoa(int64(messageID))) + return res, client.get(a.base, "setMessageReaction", addValues(vals, opts), &res) +} + +// GetUserProfilePhotos is used to get a list of profile pictures for a user. +func (a API) GetUserProfilePhotos(userID int64, opts *UserProfileOptions) (res APIResponseUserProfile, err error) { + var vals = make(url.Values) + + vals.Set("user_id", itoa(userID)) + return res, client.get(a.base, "getUserProfilePhotos", addValues(vals, opts), &res) +} + +// GetUserProfileAudios is used to get the list of profile audios for a user. +func (a API) GetUserProfileAudios(userID int64, opts *UserProfileAudioOptions) (res APIResponseUserProfileAudios, err error) { + var vals = make(url.Values) + + vals.Set("user_id", itoa(userID)) + return res, client.get(a.base, "getUserProfileAudios", addValues(vals, opts), &res) +} + +// SetUserEmojiStatus +func (a API) SetUserEmojiStatus(userID int64, opts *UserEmojiStatusOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("user_id", itoa(userID)) + return res, client.get(a.base, "setUserEmojiStatus", addValues(vals, opts), &res) +} + +// GetFile returns the basic info about a file and prepares it for downloading. +// For the moment, bots can download files of up to 20MB in size. +// The file can then be downloaded with DownloadFile where filePath is taken from the response. +// It is guaranteed that the file will be downloadable for at least 1 hour. +// When the download file expires, a new one can be requested by calling GetFile again. +func (a API) GetFile(fileID string) (res APIResponseFile, err error) { + var vals = make(url.Values) + + vals.Set("file_id", fileID) + return res, client.get(a.base, "getFile", vals, &res) +} + +// DownloadFile returns the bytes of the file corresponding to the given filePath. +// This function is callable for at least 1 hour since the call to GetFile. +// When the download expires a new one can be requested by calling GetFile again. +func (a API) DownloadFile(filePath string) ([]byte, error) { + return client.doGet(fmt.Sprintf( + "https://api.telegram.org/file/bot%s/%s", + a.token, + filePath, + )) +} + +// BanChatMember is used to ban a user in a group, a supergroup or a channel. +// In the case of supergroups or channels, the user will not be able to return to the chat +// on their own using invite links, etc., unless unbanned first (through the UnbanChatMember method). +// The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. +func (a API) BanChatMember(chatID, userID int64, opts *BanOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("user_id", itoa(userID)) + return res, client.get(a.base, "banChatMember", addValues(vals, opts), &res) +} + +// UnbanChatMember is used to unban a previously banned user in a supergroup or channel. +// The user will NOT return to the group or channel automatically, but will be able to join via link, etc. +// The bot must be an administrator for this to work. +// By default, this method guarantees that after the call the user is not a member of the chat, but will be able to join it. +// So if the user is a member of the chat they will also be REMOVED from the chat. +// If you don't want this, use the parameter `OnlyIfBanned`. +func (a API) UnbanChatMember(chatID, userID int64, opts *UnbanOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("user_id", itoa(userID)) + return res, client.get(a.base, "unbanChatMember", addValues(vals, opts), &res) +} + +// RestrictChatMember is used to restrict a user in a supergroup. +// The bot must be an administrator in the supergroup for this to work and must have the appropriate admin rights. +func (a API) RestrictChatMember(chatID, userID int64, permissions ChatPermissions, opts *RestrictOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + perm, err := json.Marshal(permissions) + if err != nil { + return + } + + vals.Set("chat_id", itoa(chatID)) + vals.Set("user_id", itoa(userID)) + vals.Set("permissions", string(perm)) + return res, client.get(a.base, "restrictChatMember", addValues(vals, opts), &res) +} + +// PromoteChatMember is used to promote or demote a user in a supergroup or a channel. +// The bot must be an administrator in the supergroup for this to work and must have the appropriate admin rights. +func (a API) PromoteChatMember(chatID, userID int64, opts *PromoteOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("user_id", itoa(userID)) + return res, client.get(a.base, "promoteChatMember", addValues(vals, opts), &res) +} + +// SetChatAdministratorCustomTitle is used to set a custom title for an administrator in a supergroup promoted by the bot. +func (a API) SetChatAdministratorCustomTitle(chatID, userID int64, customTitle string) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("user_id", itoa(userID)) + vals.Set("custom_title", customTitle) + return res, client.get(a.base, "setChatAdministratorCustomTitle", vals, &res) +} + +// BanChatSenderChat is used to ban a channel chat in a supergroup or a channel. +// The owner of the chat will not be able to send messages and join live streams on behalf of the chat, unless it is unbanned first. +// The bot must be an administrator in the supergroup or channel for this to work and must have the appropriate administrator rights. +func (a API) BanChatSenderChat(chatID, senderChatID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("sender_chat_id", itoa(senderChatID)) + return res, client.get(a.base, "banChatSenderChat", vals, &res) +} + +// UnbanChatSenderChat is used to unban a previously channel chat in a supergroup or channel. +// The bot must be an administrator for this to work and must have the appropriate administrator rights. +func (a API) UnbanChatSenderChat(chatID, senderChatID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("sender_chat_id", itoa(senderChatID)) + return res, client.get(a.base, "unbanChatSenderChat", vals, &res) +} + +// SetChatPermissions is used to set default chat permissions for all members. +// The bot must be an administrator in the supergroup for this to work and must have the can_restrict_members admin rights. +func (a API) SetChatPermissions(chatID int64, permissions ChatPermissions, opts *ChatPermissionsOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + perm, err := json.Marshal(permissions) + if err != nil { + return + } + + vals.Set("chat_id", itoa(chatID)) + vals.Set("permissions", string(perm)) + return res, client.get(a.base, "setChatPermissions", addValues(vals, opts), &res) +} + +// ExportChatInviteLink is used to generate a new primary invite link for a chat; +// any previously generated primary link is revoked. +// The bot must be an administrator in the supergroup for this to work and must have the appropriate admin rights. +func (a API) ExportChatInviteLink(chatID int64) (res APIResponseString, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "exportChatInviteLink", vals, &res) +} + +// CreateChatInviteLink is used to create an additional invite link for a chat. +// The bot must be an administrator in the supergroup for this to work and must have the appropriate admin rights. +// The link can be revoked using the method RevokeChatInviteLink. +func (a API) CreateChatInviteLink(chatID int64, opts *InviteLinkOptions) (res APIResponseInviteLink, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "createChatInviteLink", addValues(vals, opts), &res) +} + +// EditChatInviteLink is used to edit a non-primary invite link created by the bot. +// The bot must be an administrator in the supergroup for this to work and must have the appropriate admin rights. +func (a API) EditChatInviteLink(chatID int64, inviteLink string, opts *InviteLinkOptions) (res APIResponseInviteLink, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("invite_link", inviteLink) + return res, client.get(a.base, "editChatInviteLink", addValues(vals, opts), &res) +} + +// CreateChatSubscriptionInviteLink is used to create a subscription invite link for a channel chat. +// The bot must have the can_invite_users administrator rights. +// The link can be edited using the method editChatSubscriptionInviteLink or revoked using the method revokeChatInviteLink. +func (a API) CreateChatSubscriptionInviteLink(chatID int64, subscriptionPeriod, subscriptionPrice int, opts *ChatSubscriptionInviteOptions) (res APIResponseInviteLink, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("subscription_period", itoa(int64(subscriptionPeriod))) + vals.Set("subscription_price", itoa(int64(subscriptionPrice))) + return res, client.get(a.base, "createChatSubscriptionInviteLink", addValues(vals, opts), &res) +} + +// EditChatSubscriptionInviteLink is used to creeditate a subscription invite link for a channel chat. +// The bot must have the can_invite_users administrator rights. +func (a API) EditChatSubscriptionInviteLink(chatID int64, inviteLink string, opts *ChatSubscriptionInviteOptions) (res APIResponseInviteLink, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("invite_link", inviteLink) + return res, client.get(a.base, "editChatSubscriptionInviteLink", addValues(vals, opts), &res) +} + +// RevokeChatInviteLink is used to revoke an invite link created by the bot. +// If the primary link is revoked, a new link is automatically generated. +// The bot must be an administrator in the supergroup for this to work and must have the appropriate admin rights. +func (a API) RevokeChatInviteLink(chatID int64, inviteLink string) (res APIResponseInviteLink, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("invite_link", inviteLink) + return res, client.get(a.base, "editChatInviteLink", vals, &res) +} + +// ApproveChatJoinRequest is used to approve a chat join request. +// The bot must be an administrator in the chat for this to work and must have the CanInviteUsers administrator right. +func (a API) ApproveChatJoinRequest(chatID, userID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("user_id", itoa(userID)) + return res, client.get(a.base, "approveChatJoinRequest", vals, &res) +} + +// DeclineChatJoinRequest is used to decline a chat join request. +// The bot must be an administrator in the chat for this to work and must have the CanInviteUsers administrator right. +func (a API) DeclineChatJoinRequest(chatID, userID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("user_id", itoa(userID)) + return res, client.get(a.base, "declineChatJoinRequest", vals, &res) +} + +// SetChatPhoto is used to set a new profile photo for the chat. +// Photos can't be changed for private chats. +// The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. +func (a API) SetChatPhoto(file InputFile, chatID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.postFile(a.base, "setChatPhoto", "photo", file, InputFile{}, vals, &res) +} + +// DeleteChatPhoto is used to delete a chat photo. +// Photos can't be changed for private chats. +// The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. +func (a API) DeleteChatPhoto(chatID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "deleteChatPhoto", vals, &res) +} + +// SetChatTitle is used to change the title of a chat. +// Titles can't be changed for private chats. +// The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. +func (a API) SetChatTitle(chatID int64, title string) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("title", title) + return res, client.get(a.base, "setChatTitle", vals, &res) +} + +// SetChatDescription is used to change the description of a group, a supergroup or a channel. +// The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. +func (a API) SetChatDescription(chatID int64, description string) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("description", description) + return res, client.get(a.base, "setChatDescription", vals, &res) +} + +// PinChatMessage is used to add a message to the list of pinned messages in the chat. +// If the chat is not a private chat, the bot must be an administrator in the chat for this to work +// and must have the 'can_pin_messages' admin right in a supergroup or 'can_edit_messages' admin right in a channel. +func (a API) PinChatMessage(chatID int64, messageID int, opts *PinMessageOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("message_id", itoa(int64(messageID))) + return res, client.get(a.base, "pinChatMessage", addValues(vals, opts), &res) +} + +// UnpinChatMessage is used to remove a message from the list of pinned messages in the chat. +// If the chat is not a private chat, the bot must be an administrator in the chat for this to work +// and must have the 'can_pin_messages' admin right in a supergroup or 'can_edit_messages' admin right in a channel. +func (a API) UnpinChatMessage(chatID int64, opts *UnpinMessageOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "unpinChatMessage", addValues(vals, opts), &res) +} + +// UnpinAllChatMessages is used to clear the list of pinned messages in a chat. +// If the chat is not a private chat, the bot must be an administrator in the chat for this to work +// and must have the 'can_pin_messages' admin right in a supergroup or 'can_edit_messages' admin right in a channel. +func (a API) UnpinAllChatMessages(chatID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "unpinAllChatMessages", vals, &res) +} + +// LeaveChat is used to make the bot leave a group, supergroup or channel. +func (a API) LeaveChat(chatID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "leaveChat", vals, &res) +} + +// GetChat is used to get up to date information about the chat. +// (current name of the user for one-on-one conversations, current username of a user, group or channel, etc.) +func (a API) GetChat(chatID int64) (res APIResponseChat, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "getChat", vals, &res) +} + +// GetChatAdministrators is used to get a list of administrators in a chat. +func (a API) GetChatAdministrators(chatID int64) (res APIResponseAdministrators, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "getChatAdministrators", vals, &res) +} + +// GetChatMemberCount is used to get the number of members in a chat. +func (a API) GetChatMemberCount(chatID int64) (res APIResponseInteger, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "getChatMemberCount", vals, &res) +} + +// GetChatMember is used to get information about a member of a chat. +func (a API) GetChatMember(chatID, userID int64) (res APIResponseChatMember, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("user_id", itoa(userID)) + return res, client.get(a.base, "getChatMember", vals, &res) +} + +// SetChatStickerSet is used to set a new group sticker set for a supergroup. +// The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. +// Use the field `CanSetStickerSet` optionally returned in GetChat requests to check if the bot can use this method. +func (a API) SetChatStickerSet(chatID int64, stickerSetName string) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("sticker_set_name", stickerSetName) + return res, client.get(a.base, "setChatStickerSet", vals, &res) +} + +// DeleteChatStickerSet is used to delete a group sticker set for a supergroup. +// The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. +// Use the field `CanSetStickerSet` optionally returned in GetChat requests to check if the bot can use this method. +func (a API) DeleteChatStickerSet(chatID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "deleteChatStickerSet", vals, &res) +} + +// CreateForumTopic is used to create a topic in a forum supergroup chat. +// The bot must be an administrator in the chat for this to work and must have the can_manage_topics administrator rights. +func (a API) CreateForumTopic(chatID int64, name string, opts *CreateTopicOptions) (res APIResponseForumTopic, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("name", name) + return res, client.get(a.base, "createForumTopic", addValues(vals, opts), &res) +} + +// EditForumTopic is used to edit name and icon of a topic in a forum supergroup chat. +// The bot must be an administrator in the chat for this to work and must have the can_manage_topics administrator rights. +func (a API) EditForumTopic(chatID, messageThreadID int64, opts *EditTopicOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("message_thread_id", itoa(messageThreadID)) + return res, client.get(a.base, "editForumTopic", addValues(vals, opts), &res) +} + +// CloseForumTopic is used to close an open topic in a forum supergroup chat. +// The bot must be an administrator in the chat for this to work and must have the can_manage_topics administrator rights. +func (a API) CloseForumTopic(chatID, messageThreadID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("message_thread_id", itoa(messageThreadID)) + return res, client.get(a.base, "closeForumTopic", vals, &res) +} + +// ReopenForumTopic is used to reopen a closed topic in a forum supergroup chat. +// The bot must be an administrator in the chat for this to work and must have the can_manage_topics administrator rights. +func (a API) ReopenForumTopic(chatID, messageThreadID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("message_thread_id", itoa(messageThreadID)) + return res, client.get(a.base, "reopenForumTopic", vals, &res) +} + +// DeleteForumTopic is used to delete a forum topic along with all its messages in a forum supergroup chat. +// The bot must be an administrator in the chat for this to work and must have the can_manage_topics administrator rights. +func (a API) DeleteForumTopic(chatID, messageThreadID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("message_thread_id", itoa(messageThreadID)) + return res, client.get(a.base, "deleteForumTopic", vals, &res) +} + +// UnpinAllForumTopicMessages is used to clear the list of pinned messages in a forum topic. +// The bot must be an administrator in the chat for this to work and must have the can_manage_topics administrator rights. +func (a API) UnpinAllForumTopicMessages(chatID, messageThreadID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("message_thread_id", itoa(messageThreadID)) + return res, client.get(a.base, "unpinAllForumTopicMessages", vals, &res) +} + +// EditGeneralForumTopic is used to edit the name of the 'General' topic in a forum supergroup chat. +// The bot must be an administrator in the chat for this to work and must have can_manage_topics administrator rights. +func (a API) EditGeneralForumTopic(chatID int64, name string) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("name", name) + return res, client.get(a.base, "editGeneralForumTopic", vals, &res) +} + +// CloseGeneralForumTopic is used to close an open 'General' topic in a forum supergroup chat. +// The bot must be an administrator in the chat for this to work and must have can_manage_topics administrator rights. +func (a API) CloseGeneralForumTopic(chatID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "closeGeneralForumTopic", vals, &res) +} + +// ReopenGeneralForumTopic is used to reopen a closed 'General' topic in a forum supergroup chat. +// The bot must be an administrator in the chat for this to work and must have can_manage_topics administrator rights. +// The topic will be automatically unhidden if it was hidden. +func (a API) ReopenGeneralForumTopic(chatID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "reopenGeneralForumTopic", vals, &res) +} + +// HideGeneralForumTopic is used to hide the 'General' topic in a forum supergroup chat. +// The bot must be an administrator in the chat for this to work and must have can_manage_topics administrator rights. +// The topic will be automatically closed if it was open. +func (a API) HideGeneralForumTopic(chatID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "hideGeneralForumTopic", vals, &res) +} + +// UnhideGeneralForumTopic is used to unhide the 'General' topic in a forum supergroup chat. +// The bot must be an administrator in the chat for this to work and must have can_manage_topics administrator rights. +func (a API) UnhideGeneralForumTopic(chatID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "unhideGeneralForumTopic", vals, &res) +} + +// UnpinAllGeneralForumTopicMessages is used to clear the list of pinned messages in a General forum topic. +// The bot must be an administrator in the chat for this to work and must have can_pin_messages administrator right in the supergroup. +func (a API) UnpinAllGeneralForumTopicMessages(chatID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "unpinAllGeneralForumTopicMessages", vals, &res) +} + +// AnswerCallbackQuery is used to send answers to callback queries sent from inline keyboards. +// The answer will be displayed to the user as a notification at the top of the chat screen or as an alert. +func (a API) AnswerCallbackQuery(callbackID string, opts *CallbackQueryOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("callback_query_id", callbackID) + return res, client.get(a.base, "answerCallbackQuery", addValues(vals, opts), &res) +} + +// GetUserChatBoosts is used to get the list of boosts added to a chat by a user. +// Requires administrator rights in the chat. +func (a API) GetUserChatBoosts(chatID, userID int64) (res APIResponseUserChatBoosts, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("user_id", itoa(userID)) + return res, client.get(a.base, "getUserChatBoosts", vals, &res) +} + +// GetBusinessConnection is used to get information about the connection of the bot with a business account. +func (a API) GetBusinessConnection(business_connection_id string) (res APIResponseBusinessConnection, err error) { + var vals = make(url.Values) + + vals.Set("business_connection_id", business_connection_id) + return res, client.get(a.base, "getBusinessConnection", vals, &res) +} + +// SetMyCommands is used to change the list of the bot's commands for the given scope and user language. +func (a API) SetMyCommands(opts *CommandOptions, commands ...BotCommand) (res APIResponseBool, err error) { + var vals = make(url.Values) + + jsn, _ := json.Marshal(commands) + vals.Set("commands", string(jsn)) + return res, client.get(a.base, "setMyCommands", addValues(vals, opts), &res) +} + +// DeleteMyCommands is used to delete the list of the bot's commands for the given scope and user language. +func (a API) DeleteMyCommands(opts *CommandOptions) (res APIResponseBool, err error) { + return res, client.get(a.base, "deleteMyCommands", urlValues(opts), &res) +} + +// GetMyCommands is used to get the current list of the bot's commands for the given scope and user language. +func (a API) GetMyCommands(opts *CommandOptions) (res APIResponseCommands, err error) { + return res, client.get(a.base, "getMyCommands", urlValues(opts), &res) +} + +// SetMyName is used to change the bot's name. +func (a API) SetMyName(name, languageCode string) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("name", name) + vals.Set("language_code", languageCode) + return res, client.get(a.base, "setMyName", vals, &res) +} + +// GetMyName is used to get the current bot name for the given user language. +func (a API) GetMyName(languageCode string) (res APIResponseBotName, err error) { + var vals = make(url.Values) + + vals.Set("language_code", languageCode) + return res, client.get(a.base, "getMyName", vals, &res) +} + +// SetMyDescription is used to to change the bot's description, which is shown in the chat with the bot if the chat is empty. +func (a API) SetMyDescription(description, languageCode string) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("description", description) + vals.Set("language_code", languageCode) + return res, client.get(a.base, "setMyDescription", vals, &res) +} + +// GetMyDescription is used to get the current bot description for the given user language. +func (a API) GetMyDescription(languageCode string) (res APIResponseBotDescription, err error) { + var vals = make(url.Values) + + vals.Set("language_code", languageCode) + return res, client.get(a.base, "getMyDescription", vals, &res) +} + +// SetMyShortDescription is used to to change the bot's short description, +// which is shown on the bot's profile page and is sent together with the link when users share the bot. +func (a API) SetMyShortDescription(shortDescription, languageCode string) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("short_description", shortDescription) + vals.Set("language_code", languageCode) + return res, client.get(a.base, "setMyShortDescription", vals, &res) +} + +// GetMyShortDescription is used to get the current bot short description for the given user language. +func (a API) GetMyShortDescription(languageCode string) (res APIResponseBotShortDescription, err error) { + var vals = make(url.Values) + + vals.Set("language_code", languageCode) + return res, client.get(a.base, "getMyDescription", vals, &res) +} + +// SetMyProfilePhoto is used to change the profile photo of the bot. +func (a API) SetMyProfilePhoto(profilePhoto InputProfilePhoto) (res APIResponseBool, err error) { + return res, client.postProfilePhoto(a.base, "setMyProfilePhoto", nil, &res, profilePhoto) +} + +// RemoveMyProfilePhoto is used to remove one of the bot profile photos. +func (a API) RemoveMyProfilePhoto(photoID string) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("photo_id", photoID) + return res, client.get(a.base, "removeMyProfilePhoto", vals, &res) +} + +// EditMessageText is used to edit text and game messages. +func (a API) EditMessageText(text string, msg MessageIDOptions, opts *MessageTextOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + vals.Set("text", text) + return res, client.get(a.base, "editMessageText", addValues(addValues(vals, msg), opts), &res) +} + +// EditMessageCaption is used to edit captions of messages. +func (a API) EditMessageCaption(msg MessageIDOptions, opts *MessageCaptionOptions) (res APIResponseMessage, err error) { + return res, client.get(a.base, "editMessageCaption", addValues(urlValues(msg), opts), &res) +} + +// EditMessageMedia is used to edit animation, audio, document, photo or video messages, or to add media to text messages. +// If a message is part of a message album, then it can be edited only to an audio for audio albums, +// only to a document for document albums and to a photo or a video otherwise. +// When an inline message is edited, a new file can't be uploaded; +// Use a previously uploaded file via its file_id or specify a URL. +func (a API) EditMessageMedia(msg MessageIDOptions, media InputMedia, opts *MessageMediaOptions) (res APIResponseMessage, err error) { + return res, client.postMedia(a.base, "editMessageMedia", true, addValues(urlValues(msg), opts), &res, media) +} + +// EditMessageReplyMarkup is used to edit only the reply markup of messages. +func (a API) EditMessageReplyMarkup(msg MessageIDOptions, opts *MessageReplyMarkupOptions) (res APIResponseMessage, err error) { + return res, client.get(a.base, "editMessageReplyMarkup", addValues(urlValues(msg), opts), &res) +} + +// StopPoll is used to stop a poll which was sent by the bot. +func (a API) StopPoll(chatID int64, messageID int, opts *StopPollOptions) (res APIResponsePoll, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("message_id", itoa(int64(messageID))) + return res, client.get(a.base, "stopPoll", addValues(vals, opts), &res) +} + +// DeleteMessage is used to delete a message, including service messages, with the following limitations: +// - A message can only be deleted if it was sent less than 48 hours ago. +// - A dice message in a private chat can only be deleted if it was sent more than 24 hours ago. +// - Bots can delete outgoing messages in private chats, groups, and supergroups. +// - Bots can delete incoming messages in private chats. +// - Bots granted can_post_messages permissions can delete outgoing messages in channels. +// - If the bot is an administrator of a group, it can delete any message there. +// - If the bot has can_delete_messages permission in a supergroup or a channel, it can delete any message there. +func (a API) DeleteMessage(chatID int64, messageID int) (res APIResponseBase, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("message_id", itoa(int64(messageID))) + return res, client.get(a.base, "deleteMessage", vals, &res) +} + +// DeleteMessages is used to delete multiple messages simultaneously. +// If some of the specified messages can't be found, they are skipped. +func (a API) DeleteMessages(chatID int64, messageIDs []int) (res APIResponseBool, err error) { + var vals = make(url.Values) + + msgIDs, err := json.Marshal(messageIDs) + if err != nil { + return res, err + } + + vals.Set("chat_id", itoa(chatID)) + vals.Set("message_ids", string(msgIDs)) + return res, client.get(a.base, "deleteMessages", vals, &res) +} + +// GetAvailableGifts returns the list of gifts that can be sent by the bot to users. +func (a API) GetAvailableGifts() (res APIResponseGifts, err error) { + return res, client.get(a.base, "getAvailableGifts", nil, &res) +} + +// SendGift sends a gift to the given user. +// The gift can't be converted to Telegram Stars by the user. +func (a API) SendGift(userID int64, giftID string, opts *GiftOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("user_id", itoa(userID)) + vals.Set("gift_id", giftID) + return res, client.get(a.base, "sendGift", addValues(vals, opts), &res) +} + +// VerifyUser verifies a user on behalf of the organization which is represented by the bot. +func (a API) VerifyUser(userID int64, opts *VerifyOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("user_id", itoa(userID)) + return res, client.get(a.base, "verifyUser", addValues(vals, opts), &res) +} + +// VerifyChat verifies a chat on behalf of the organization which is represented by the bot. +func (a API) VerifyChat(chatID int64, opts *VerifyOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "verifyChat", addValues(vals, opts), &res) +} + +// RemoveUserVerification removes verification from a user who is currently verified on behalf of the organization represented by the bot. +func (a API) RemoveUserVerification(userID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("user_id", itoa(userID)) + return res, client.get(a.base, "verifyUser", vals, &res) +} + +// RemoveChatVerification removes verification from a chat who is currently verified on behalf of the organization represented by the bot. +func (a API) RemoveChatVerification(chatID int64) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "verifyChat", vals, &res) +} diff --git a/shared/echotron/api_test.go b/shared/echotron/api_test.go new file mode 100644 index 0000000..cb1456b --- /dev/null +++ b/shared/echotron/api_test.go @@ -0,0 +1,2174 @@ +package echotron + +import ( + "fmt" + "io" + "os" + "reflect" + "testing" + "time" +) + +var ( + msgTmp *Message + animationTmp *Message + pollTmp *Message + locationTmp *Message + inviteTmp *ChatInviteLink + chatSubInviteTmp *ChatInviteLink + filePath string + currentBotDesc string + currentBotShortDesc string + msgThreadID int64 + currentBotName = "bot name unset" + api = NewAPI("1713461126:AAEV5sgVo513Vz4PT33mpp0ZykJqrnSluzM") + chatID = int64(14870908) + banUserID = int64(41876271) + channelID = int64(-1001563144067) + groupID = int64(-1001265771214) + pinMsgID = int(11) + photoID = "AgACAgQAAxkDAAMrYFtODxV2LL6-kR_6qSbG9n8dIOIAAti1MRug29lSkNq_9o8PC5uMd7EnXQADAQADAgADbQADeooGAAEeBA" + animationID = "CgACAgQAAxkDAAICQGBcoGs7GFJ-tR5AkbRRLFTbvdxXAAJ1CAAC1zHgUu-ciZqanytIHgQ" + audioID = "CQACAgQAAxkDAAIBCmBbamz_DqKk2GmrzmoM0SrzRN6wAAK9CAACoNvZUgPyk-87OM_YHgQ" + documentID = "BQACAgQAAxkDAANmYFtSXcF5kTtwgHeqVUngyuuJMx4AAnQIAAKg29lSb4HP4x-qMT8eBA" + paidVideoID = "BAACAgQAAx0EXSuvgwADGGa7vNTyDxfQiyICxWhnLUfhJphkAAIvFAACHozgUVOTwR-Bak97NQQ" + videoID = "BAACAgQAAxkDAANxYFtaxF1kfc7nVY_Mtfba3u5dMooAAoYIAAKg29lSpwABJrcveXZlHgQ" + videoNoteID = "DQACAgQAAxkDAAIBumBbfT5jPC_cvyEcr0_8DpmFDz2PAALVCgACOX7hUjGZ_MmnZVVeHgQ" + voiceID = "AwACAgQAAxkDAAPXYFtmoFriwJFVGDgPPpfUBljgnYAAAq8IAAKg29lStEWfrNMMAxgeBA" + photoURL = "https://github.com/NicoNex/echotron/raw/master/assets/tests/echotron_test.png" + animationURL = "https://github.com/NicoNex/echotron/raw/master/assets/tests/animation.mp4" + audioURL = "https://github.com/NicoNex/echotron/raw/master/assets/tests/audio.mp3" + documentURL = "https://github.com/NicoNex/echotron/raw/master/assets/tests/document.pdf" + logoInvURL = "https://github.com/NicoNex/echotron/raw/master/assets/tests/echotron_thumb_inv.jpg" + videoURL = "https://github.com/NicoNex/echotron/raw/master/assets/tests/video.webm" + videoNoteURL = "https://github.com/NicoNex/echotron/raw/master/assets/tests/video_note.mp4" + voiceURL = "https://github.com/NicoNex/echotron/raw/master/assets/tests/audio.mp3" + + commands = []BotCommand{ + {Command: "test1", Description: "Test command 1"}, + {Command: "test2", Description: "Test command 2"}, + {Command: "test3", Description: "Test command 3"}, + } + + keyboard = ReplyKeyboardMarkup{ + Keyboard: [][]KeyboardButton{ + { + {Text: "test 1"}, + {Text: "test 2"}, + }, + { + {Text: "test 3"}, + {Text: "test 4"}, + }, + }, + ResizeKeyboard: true, + } + + inlineKeyboard = InlineKeyboardMarkup{ + InlineKeyboard: [][]InlineKeyboardButton{ + { + {Text: "test1", CallbackData: "test1"}, + {Text: "test2", CallbackData: "test2"}, + }, + { + {Text: "test3", CallbackData: "test3"}, + }, + }, + } + + inlineKeyboardEdit = InlineKeyboardMarkup{ + InlineKeyboard: [][]InlineKeyboardButton{ + { + {Text: "test1", CallbackData: "test1"}, + {Text: "test2", CallbackData: "test2"}, + }, + { + {Text: "test3", CallbackData: "test3"}, + {Text: "edit", CallbackData: "edit"}, + }, + }, + } +) + +func openBytes(path string) (data []byte, err error) { + file, err := os.Open(path) + + if err != nil { + return + } + + data, err = io.ReadAll(file) + + if err != nil { + return + } + + return +} + +func TestGetUpdates(t *testing.T) { + _, err := api.GetUpdates( + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSetWebhook(t *testing.T) { + _, err := api.SetWebhook( + "example.com", + false, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSetWebhookWrongURL(t *testing.T) { + _, err := api.SetWebhook( + "example.com_", + false, + nil, + ) + + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestDeleteWebhook(t *testing.T) { + _, err := api.DeleteWebhook( + false, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestGetWebhookInfo(t *testing.T) { + _, err := api.GetWebhookInfo() + + if err != nil { + t.Fatal(err) + } +} + +func TestGetMe(t *testing.T) { + _, err := api.GetMe() + + if err != nil { + t.Fatal(err) + } +} + +func TestSendMessage(t *testing.T) { + res, err := api.SendMessage( + "TestSendMessage *bold* _italic_ `monospace`", + chatID, + &MessageOptions{ + ParseMode: MarkdownV2, + }, + ) + + if err != nil { + t.Fatal(err) + } + + msgTmp = res.Result +} + +func TestSetMessageReaction(t *testing.T) { + _, err := api.SetMessageReaction( + chatID, + msgTmp.ID, + &MessageReactionOptions{ + Reaction: []ReactionType{ + ReactionType{ + Type: "emoji", + Emoji: "👍", + }, + }, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestForwardMessage(t *testing.T) { + _, err := api.ForwardMessage( + chatID, + chatID, // fromChatID + msgTmp.ID, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestForwardMessages(t *testing.T) { + msg, _ := api.SendMessage( + "TestForwardMessages", + chatID, + nil, + ) + + _, err := api.ForwardMessages( + chatID, + chatID, // fromChatID + []int{msgTmp.ID, msg.Result.ID}, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestForwardMessagesWrongMsgID(t *testing.T) { + _, err := api.ForwardMessages( + chatID, + chatID, // fromChatID + []int{}, + nil, + ) + + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestCopyMessage(t *testing.T) { + _, err := api.CopyMessage( + chatID, + chatID, // fromChatID + msgTmp.ID, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestCopyMessages(t *testing.T) { + msg, _ := api.SendMessage( + "TestCopyMessages", + chatID, + nil, + ) + + _, err := api.CopyMessages( + chatID, + chatID, // fromChatID + []int{msgTmp.ID, msg.Result.ID}, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestCopyMessagesWrongMsgID(t *testing.T) { + _, err := api.CopyMessages( + chatID, + chatID, // fromChatID + []int{}, + nil, + ) + + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestSendMessageReply(t *testing.T) { + _, err := api.SendMessage( + "TestSendMessageReply", + chatID, + &MessageOptions{ + ReplyParameters: ReplyParameters{ + MessageID: msgTmp.ID, + }, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendMessageWithKeyboard(t *testing.T) { + _, err := api.SendMessage( + "TestSendMessageWithKeyboard", + chatID, + &MessageOptions{ + ReplyMarkup: keyboard, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPhoto(t *testing.T) { + _, err := api.SendPhoto( + NewInputFilePath("assets/tests/echotron_test.png"), + chatID, + &PhotoOptions{ + Caption: "TestSendPhoto", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPhotoByID(t *testing.T) { + _, err := api.SendPhoto( + NewInputFileID(photoID), + chatID, + &PhotoOptions{ + Caption: "TestSendPhotoByID", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPhotoURL(t *testing.T) { + _, err := api.SendPhoto( + NewInputFileURL(photoURL), + chatID, + &PhotoOptions{ + Caption: "TestSendPhotoURL", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPhotoBytes(t *testing.T) { + data, err := openBytes("assets/tests/echotron_test.png") + + if err != nil { + t.Fatal(err) + } + + _, err = api.SendPhoto( + NewInputFileBytes("echotron_test.png", data), + chatID, + &PhotoOptions{ + Caption: "TestSendPhotoBytes", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPhotoWithKeyboard(t *testing.T) { + _, err := api.SendPhoto( + NewInputFilePath("assets/tests/echotron_test.png"), + chatID, + &PhotoOptions{ + Caption: "TestSendPhotoWithKeyboard", + ReplyMarkup: keyboard, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendAudio(t *testing.T) { + _, err := api.SendAudio( + NewInputFilePath("assets/tests/audio.mp3"), + chatID, + &AudioOptions{ + Caption: "TestSendAudio", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendAudioByID(t *testing.T) { + _, err := api.SendAudio( + NewInputFileID(audioID), + chatID, + &AudioOptions{ + Caption: "TestSendAudioByID", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendAudioURL(t *testing.T) { + _, err := api.SendAudio( + NewInputFileURL(audioURL), + chatID, + &AudioOptions{ + Caption: "TestSendAudioURL", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendAudioWithKeyboard(t *testing.T) { + _, err := api.SendAudio( + NewInputFilePath("assets/tests/audio.mp3"), + chatID, + &AudioOptions{ + Caption: "TestSendAudioWithKeyboard", + ReplyMarkup: keyboard, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendAudioBytes(t *testing.T) { + data, err := openBytes("assets/tests/audio.mp3") + + if err != nil { + t.Fatal(err) + } + + _, err = api.SendAudio( + NewInputFileBytes("audio.mp3", data), + chatID, + &AudioOptions{ + Caption: "TestSendAudioBytes", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendAudioThumbnail(t *testing.T) { + _, err := api.SendAudio( + NewInputFilePath("assets/tests/audio.mp3"), + chatID, + &AudioOptions{ + Caption: "TestSendAudio", + Thumbnail: NewInputFilePath("assets/tests/echotron_thumb.jpg"), + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendDocument(t *testing.T) { + _, err := api.SendDocument( + NewInputFilePath("assets/tests/document.pdf"), + chatID, + &DocumentOptions{ + Caption: "TestSendDocument", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendDocumentByID(t *testing.T) { + _, err := api.SendDocument( + NewInputFileID(documentID), + chatID, + &DocumentOptions{ + Caption: "TestSendDocumentByID", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendDocumentURL(t *testing.T) { + _, err := api.SendDocument( + NewInputFileURL(documentURL), + chatID, + &DocumentOptions{ + Caption: "TestSendDocumentURL", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendDocumentWithKeyboard(t *testing.T) { + _, err := api.SendDocument( + NewInputFilePath("assets/tests/document.pdf"), + chatID, + &DocumentOptions{ + Caption: "TestSendDocumentWithKeyboard", + ReplyMarkup: keyboard, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendDocumentBytes(t *testing.T) { + file, err := os.Open("assets/tests/document.pdf") + + if err != nil { + t.Fatal(err) + } + + data, err := io.ReadAll(file) + + if err != nil { + t.Fatal(err) + } + + _, err = api.SendDocument( + NewInputFileBytes("document.pdf", data), + chatID, + &DocumentOptions{ + Caption: "TestSendDocumentBytes", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVideo(t *testing.T) { + _, err := api.SendVideo( + NewInputFilePath("assets/tests/video.webm"), + chatID, + &VideoOptions{ + Caption: "TestSendVideo", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVideoByID(t *testing.T) { + _, err := api.SendVideo( + NewInputFileID(videoID), + chatID, + &VideoOptions{ + Caption: "TestSendVideoByID", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVideoURL(t *testing.T) { + _, err := api.SendVideo( + NewInputFileURL(videoURL), + chatID, + &VideoOptions{ + Caption: "TestSendVideoURL", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVideoWithKeyboard(t *testing.T) { + _, err := api.SendVideo( + NewInputFilePath("assets/tests/video.webm"), + chatID, + &VideoOptions{ + Caption: "TestSendVideoWithKeyboard", + ReplyMarkup: keyboard, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVideoBytes(t *testing.T) { + data, err := openBytes("assets/tests/video.webm") + + if err != nil { + t.Fatal(err) + } + + _, err = api.SendVideo( + NewInputFileBytes("video.webm", data), + chatID, + &VideoOptions{ + Caption: "TestSendVideoBytes", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendAnimation(t *testing.T) { + res, err := api.SendAnimation( + NewInputFilePath("assets/tests/animation.mp4"), + chatID, + &AnimationOptions{ + Caption: "TestSendAnimation", + }, + ) + + if err != nil { + t.Fatal(err) + } + + animationTmp = res.Result +} + +func TestSendAnimationByID(t *testing.T) { + _, err := api.SendAnimation( + NewInputFileID(animationID), + chatID, + &AnimationOptions{ + Caption: "TestSendAnimationByID", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendAnimationURL(t *testing.T) { + res, err := api.SendAnimation( + NewInputFileURL(animationURL), + chatID, + &AnimationOptions{ + Caption: "TestSendAnimationURL", + }, + ) + + if err != nil { + t.Fatal(err) + } + + animationTmp = res.Result +} + +func TestSendAnimationWithKeyboard(t *testing.T) { + _, err := api.SendAnimation( + NewInputFilePath("assets/tests/animation.mp4"), + chatID, + &AnimationOptions{ + Caption: "TestSendAnimationWithKeyboard", + ReplyMarkup: keyboard, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendAnimationBytes(t *testing.T) { + data, err := openBytes("assets/tests/animation.mp4") + + if err != nil { + t.Fatal(err) + } + + _, err = api.SendAnimation( + NewInputFileBytes("animation.mp4", data), + chatID, + &AnimationOptions{ + Caption: "TestSendAnimationBytes", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVoice(t *testing.T) { + _, err := api.SendVoice( + NewInputFilePath("assets/tests/audio.mp3"), + chatID, + &VoiceOptions{ + Caption: "TestSendVoice", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVoiceByID(t *testing.T) { + _, err := api.SendVoice( + NewInputFileID(voiceID), + chatID, + &VoiceOptions{ + Caption: "TestSendVoiceByID", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVoiceURL(t *testing.T) { + _, err := api.SendVoice( + NewInputFileURL(voiceURL), + chatID, + &VoiceOptions{ + Caption: "TestSendVoiceURL", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVoiceWithKeyboard(t *testing.T) { + _, err := api.SendVoice( + NewInputFilePath("assets/tests/audio.mp3"), + chatID, + &VoiceOptions{ + Caption: "TestSendVoiceWithKeyboard", + ReplyMarkup: keyboard, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVoiceBytes(t *testing.T) { + data, err := openBytes("assets/tests/audio.mp3") + + if err != nil { + t.Fatal(err) + } + + _, err = api.SendVoice( + NewInputFileBytes("audio.mp3", data), + chatID, + &VoiceOptions{ + Caption: "TestSendVoiceBytes", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVideoNote(t *testing.T) { + _, err := api.SendVideoNote( + NewInputFilePath("assets/tests/video_note.mp4"), + chatID, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVideoNoteURL(t *testing.T) { + _, err := api.SendVideoNote( + NewInputFileURL(videoNoteURL), + chatID, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVideoNoteByID(t *testing.T) { + _, err := api.SendVideoNote( + NewInputFileID(videoNoteID), + chatID, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVideoNoteWithKeyboard(t *testing.T) { + _, err := api.SendVideoNote( + NewInputFilePath("assets/tests/video_note.mp4"), + chatID, + &VideoNoteOptions{ + ReplyMarkup: keyboard, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVideoNoteBytes(t *testing.T) { + data, err := openBytes("assets/tests/video_note.mp4") + + if err != nil { + t.Fatal(err) + } + + _, err = api.SendVideoNote( + NewInputFileBytes("video_note.mp4", data), + chatID, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPaidMediaPhoto(t *testing.T) { + _, err := api.SendPaidMedia( + channelID, + 1, + []GroupableInputMedia{ + InputPaidMediaPhoto{ + Type: InputPaidMediaTypePhoto, + Media: NewInputFilePath("assets/logo.png"), + }, + }, + &PaidMediaOptions{ + Caption: "TestSendPaidMediaPhoto", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPaidMediaPhotoByID(t *testing.T) { + _, err := api.SendPaidMedia( + channelID, + 1, + []GroupableInputMedia{ + InputPaidMediaPhoto{ + Type: InputPaidMediaTypePhoto, + Media: NewInputFileID(photoID), + }, + }, + &PaidMediaOptions{ + Caption: "TestSendPaidMediaPhotoByID", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPaidMediaPhotoURL(t *testing.T) { + _, err := api.SendPaidMedia( + channelID, + 1, + []GroupableInputMedia{ + InputPaidMediaPhoto{ + Type: InputPaidMediaTypePhoto, + Media: NewInputFileURL(photoURL), + }, + }, + &PaidMediaOptions{ + Caption: "TestSendPaidMediaPhotoURL", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPaidMediaPhotoBytes(t *testing.T) { + data, err := openBytes("assets/tests/echotron_test.png") + + if err != nil { + t.Fatal(err) + } + + _, err = api.SendPaidMedia( + channelID, + 1, + []GroupableInputMedia{ + InputPaidMediaPhoto{ + Type: InputPaidMediaTypePhoto, + Media: NewInputFileBytes("echotron_test.png", data), + }, + }, + &PaidMediaOptions{ + Caption: "TestSendPaidMediaPhotoBytes", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPaidMediaPhotoWithKeyboard(t *testing.T) { + _, err := api.SendPaidMedia( + channelID, + 1, + []GroupableInputMedia{ + InputPaidMediaPhoto{ + Type: InputPaidMediaTypePhoto, + Media: NewInputFilePath("assets/logo.png"), + }, + }, + &PaidMediaOptions{ + Caption: "TestSendPaidMediaPhotoWithKeyboard", + ReplyMarkup: inlineKeyboard, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPaidMediaVideo(t *testing.T) { + _, err := api.SendPaidMedia( + channelID, + 1, + []GroupableInputMedia{ + InputPaidMediaVideo{ + Type: InputPaidMediaTypeVideo, + Media: NewInputFilePath("assets/tests/video_note.mp4"), + }, + }, + &PaidMediaOptions{ + Caption: "TestSendPaidMediaVideo", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPaidMediaVideoByID(t *testing.T) { + _, err := api.SendPaidMedia( + channelID, + 1, + []GroupableInputMedia{ + InputPaidMediaVideo{ + Type: InputPaidMediaTypeVideo, + Media: NewInputFileID(paidVideoID), + }, + }, + &PaidMediaOptions{ + Caption: "TestSendPaidMediaVideoByID", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPaidMediaVideoURL(t *testing.T) { + _, err := api.SendPaidMedia( + channelID, + 1, + []GroupableInputMedia{ + InputPaidMediaVideo{ + Type: InputPaidMediaTypeVideo, + Media: NewInputFileURL(videoNoteURL), + }, + }, + &PaidMediaOptions{ + Caption: "TestSendPaidMediaVideoURL", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPaidMediaVideoBytes(t *testing.T) { + data, err := openBytes("assets/tests/video_note.mp4") + + if err != nil { + t.Fatal(err) + } + + _, err = api.SendPaidMedia( + channelID, + 1, + []GroupableInputMedia{ + InputPaidMediaVideo{ + Type: InputPaidMediaTypeVideo, + Media: NewInputFileBytes("video_note.mp4", data), + }, + }, + &PaidMediaOptions{ + Caption: "TestSendPaidMediaVideoBytes", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPaidMediaVideoWithKeyboard(t *testing.T) { + _, err := api.SendPaidMedia( + channelID, + 1, + []GroupableInputMedia{ + InputPaidMediaVideo{ + Type: InputPaidMediaTypeVideo, + Media: NewInputFilePath("assets/tests/video_note.mp4"), + }, + }, + &PaidMediaOptions{ + Caption: "TestSendPaidMediaVideoWithKeyboard", + ReplyMarkup: inlineKeyboard, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPaidMediaGroup(t *testing.T) { + _, err := api.SendPaidMedia( + channelID, + 1, + []GroupableInputMedia{ + InputPaidMediaPhoto{ + Type: InputPaidMediaTypePhoto, + Media: NewInputFilePath("assets/logo.png"), + }, + InputPaidMediaPhoto{ + Type: InputPaidMediaTypePhoto, + Media: NewInputFileID(photoID), + }, + InputPaidMediaPhoto{ + Type: InputPaidMediaTypePhoto, + Media: NewInputFileURL(logoInvURL), + }, + InputPaidMediaVideo{ + Type: InputPaidMediaTypeVideo, + Media: NewInputFilePath("assets/tests/video_note.mp4"), + }, + InputPaidMediaVideo{ + Type: InputPaidMediaTypeVideo, + Media: NewInputFileID(paidVideoID), + }, + InputPaidMediaVideo{ + Type: InputPaidMediaTypeVideo, + Media: NewInputFileURL(videoNoteURL), + }, + }, + &PaidMediaOptions{ + Caption: "TestSendPaidMediaGroup", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendMediaGroupPhoto(t *testing.T) { + _, err := api.SendMediaGroup( + chatID, + []GroupableInputMedia{ + InputMediaPhoto{ + Type: MediaTypePhoto, + Media: NewInputFileID(photoID), + Caption: "TestSendMediaGroup1", + }, + InputMediaPhoto{ + Type: MediaTypePhoto, + Media: NewInputFilePath("assets/logo.png"), + Caption: "TestSendMediaGroup2", + }, + InputMediaPhoto{ + Type: MediaTypePhoto, + Media: NewInputFileURL(logoInvURL), + Caption: "TestSendMediaGroup3", + }, + }, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendMediaGroupVideo(t *testing.T) { + _, err := api.SendMediaGroup( + chatID, + []GroupableInputMedia{ + InputMediaVideo{ + Type: MediaTypeVideo, + Media: NewInputFileID(videoID), + Caption: "TestSendMediaGroup1", + }, + InputMediaVideo{ + Type: MediaTypeVideo, + Media: NewInputFilePath("assets/tests/video.webm"), + Caption: "TestSendMediaGroup2", + }, + InputMediaVideo{ + Type: MediaTypeVideo, + Media: NewInputFileURL(videoURL), + Caption: "TestSendMediaGroup3", + }, + }, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendMediaGroupDocument(t *testing.T) { + _, err := api.SendMediaGroup( + chatID, + []GroupableInputMedia{ + InputMediaDocument{ + Type: MediaTypeDocument, + Media: NewInputFileID(documentID), + Caption: "TestSendMediaGroup1", + }, + InputMediaDocument{ + Type: MediaTypeDocument, + Media: NewInputFilePath("assets/tests/document.pdf"), + Caption: "TestSendMediaGroup2", + }, + InputMediaDocument{ + Type: MediaTypeDocument, + Media: NewInputFileURL(documentURL), + Caption: "TestSendMediaGroup3", + }, + }, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendMediaGroupThumbnail(t *testing.T) { + _, err := api.SendMediaGroup( + chatID, + []GroupableInputMedia{ + InputMediaAudio{ + Type: MediaTypeAudio, + Media: NewInputFilePath("assets/tests/audio_inv.mp3"), + Thumbnail: NewInputFilePath("assets/tests/echotron_thumb_inv.jpg"), + Caption: "TestSendMediaGroupThumbnail1", + }, + InputMediaAudio{ + Type: MediaTypeAudio, + Media: NewInputFilePath("assets/tests/audio.mp3"), + Thumbnail: NewInputFilePath("assets/tests/echotron_thumb.jpg"), + Caption: "TestSendMediaGroupThumbnail2", + }, + InputMediaAudio{ + Type: MediaTypeAudio, + Media: NewInputFileURL(audioURL), + Thumbnail: NewInputFileURL(logoInvURL), + Caption: "TestSendMediaGroupThumbnail3", + }, + }, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendLocation(t *testing.T) { + res, err := api.SendLocation( + chatID, + 0.0, + 0.0, + &LocationOptions{ + LivePeriod: 60, + HorizontalAccuracy: 50, + }, + ) + + if err != nil { + t.Fatal(err) + } + + locationTmp = res.Result +} + +func TestEditMessageLiveLocation(t *testing.T) { + _, err := api.EditMessageLiveLocation( + NewMessageID(chatID, locationTmp.ID), + 0.0, + 0.0, + &EditLocationOptions{ + HorizontalAccuracy: 50, + ReplyMarkup: inlineKeyboard, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestStopMessageLiveLocation(t *testing.T) { + _, err := api.StopMessageLiveLocation( + NewMessageID(chatID, locationTmp.ID), + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendVenue(t *testing.T) { + _, err := api.SendVenue( + chatID, + 0.0, + 0.0, + "TestSendVenue", + "TestSendVenueAddress", + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendContact(t *testing.T) { + _, err := api.SendContact( + "1234567890", + "Name", + chatID, + &ContactOptions{ + LastName: "Surname", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendPoll(t *testing.T) { + res, err := api.SendPoll( + chatID, + "TestSendPoll", + []InputPollOption{ + {Text: "Option 1"}, + {Text: "Option 2"}, + {Text: "Option 3"}, + }, + nil, + ) + + if err != nil { + t.Fatal(err) + } + + pollTmp = res.Result +} + +func TestSendPollWrongOptions(t *testing.T) { + _, err := api.SendPoll( + chatID, + "TestSendPoll", + []InputPollOption{}, + nil, + ) + + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestSendDice(t *testing.T) { + _, err := api.SendDice( + chatID, + Die, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendChatAction(t *testing.T) { + _, err := api.SendChatAction( + Typing, + chatID, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestGetUserProfilePhotos(t *testing.T) { + _, err := api.GetUserProfilePhotos( + chatID, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestGetFile(t *testing.T) { + res, err := api.GetFile( + photoID, + ) + + if err != nil { + t.Fatal(err) + } + + filePath = res.Result.FilePath +} + +func TestDownloadFile(t *testing.T) { + res, err := api.DownloadFile( + filePath, + ) + + if err != nil { + t.Fatal(err) + } + + if len(res) == 0 { + t.Fatal("empty file received") + } +} + +func TestBanChatMember(t *testing.T) { + _, err := api.BanChatMember( + channelID, + banUserID, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestUnbanChatMember(t *testing.T) { + _, err := api.UnbanChatMember( + channelID, + banUserID, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestRestrictChatMember(t *testing.T) { + _, err := api.RestrictChatMember( + groupID, + banUserID, + ChatPermissions{ + CanSendMessages: true, + }, + &RestrictOptions{ + UseIndependentChatPermissions: true, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestPromoteChatMember(t *testing.T) { + _, err := api.PromoteChatMember( + groupID, + banUserID, + &PromoteOptions{ + CanManageChat: true, + CanPostMessages: true, + CanEditMessages: true, + CanDeleteMessages: true, + CanManageVideoChats: true, + CanRestrictMembers: true, + CanPromoteMembers: true, + CanChangeInfo: true, + CanInviteUsers: true, + CanPinMessages: true, + CanPostStories: true, + CanEditStories: true, + CanDeleteStories: true, + CanManageTopics: true, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestBanChatSenderChat(t *testing.T) { + _, err := api.BanChatSenderChat( + channelID, + groupID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestUnbanChatSenderChat(t *testing.T) { + _, err := api.UnbanChatSenderChat( + channelID, + groupID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSetChatPermissions(t *testing.T) { + _, err := api.SetChatPermissions( + groupID, + ChatPermissions{ + CanSendMessages: true, + CanSendAudios: true, + CanSendDocuments: true, + CanSendPhotos: true, + CanSendVideos: true, + CanSendVideoNotes: true, + CanSendVoiceNotes: true, + CanSendPolls: true, + CanSendOtherMessages: true, + CanAddWebPagePreviews: true, + CanChangeInfo: true, + CanInviteUsers: true, + CanPinMessages: true, + }, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestExportChatInviteLink(t *testing.T) { + _, err := api.ExportChatInviteLink( + channelID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestCreateChatInviteLink(t *testing.T) { + res, err := api.CreateChatInviteLink( + channelID, nil, + ) + + if err != nil { + t.Fatal(err) + } + + inviteTmp = res.Result +} + +func TestEditChatInviteLink(t *testing.T) { + _, err := api.EditChatInviteLink( + channelID, + inviteTmp.InviteLink, + &InviteLinkOptions{ + ExpireDate: time.Now().Unix() + 300, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestCreateChatSubscriptionInviteLink(t *testing.T) { + res, err := api.CreateChatSubscriptionInviteLink( + channelID, + 2592000, + 1, + &ChatSubscriptionInviteOptions{ + Name: "TestCreateChatSubscriptionInviteLink", + }, + ) + + if err != nil { + t.Fatal(err) + } + + chatSubInviteTmp = res.Result +} + +func TestEditChatSubscriptionInviteLink(t *testing.T) { + _, err := api.EditChatSubscriptionInviteLink( + channelID, + chatSubInviteTmp.InviteLink, + &ChatSubscriptionInviteOptions{ + Name: "TestEditChatSubscriptionInviteLink", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestRevokeChatInviteLink(t *testing.T) { + _, err := api.RevokeChatInviteLink( + channelID, + inviteTmp.InviteLink, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSetChatPhoto(t *testing.T) { + _, err := api.SetChatPhoto( + NewInputFilePath("assets/tests/echotron_test.png"), + groupID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestDeleteChatPhoto(t *testing.T) { + _, err := api.DeleteChatPhoto( + groupID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSetChatTitle(t *testing.T) { + _, err := api.SetChatTitle( + groupID, + "Echotron Coverage Supergroup", + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSetChatDescription(t *testing.T) { + _, err := api.SetChatDescription( + groupID, + fmt.Sprintf( + "This supergroup is used to test some of the methods of the Echotron library for Telegram bots.\n\nLast changed: %d", + time.Now().Unix(), + ), + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestPinChatMessage(t *testing.T) { + _, err := api.PinChatMessage( + groupID, + pinMsgID, + &PinMessageOptions{ + DisableNotification: true, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestUnpinChatMessage(t *testing.T) { + _, err := api.UnpinChatMessage( + groupID, + &UnpinMessageOptions{ + MessageID: pinMsgID, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestUnpinAllChatMessages(t *testing.T) { + _, err := api.UnpinAllChatMessages( + groupID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestGetChat(t *testing.T) { + res, err := api.GetChat( + chatID, + ) + + if err != nil { + t.Fatal(err) + } + + if res.Result.Type != "private" && res.Result.Type != "group" && + res.Result.Type != "supergroup" && res.Result.Type != "channel" { + + t.Fatal("wrong chat type, got:", res.Result.Type) + } +} + +func TestGetChatAdministrators(t *testing.T) { + _, err := api.GetChatAdministrators( + groupID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestGetChatMemberCount(t *testing.T) { + _, err := api.GetChatMemberCount( + groupID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestGetChatMember(t *testing.T) { + _, err := api.GetChatMember( + groupID, + chatID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestCreateForumTopic(t *testing.T) { + res, err := api.CreateForumTopic( + groupID, + "Test Topic", + &CreateTopicOptions{ + IconColor: Green, + }, + ) + + if err != nil { + t.Fatal(err) + } + + msgThreadID = res.Result.MessageThreadID +} + +func TestEditForumTopic(t *testing.T) { + _, err := api.EditForumTopic( + groupID, + msgThreadID, + &EditTopicOptions{ + Name: "Testing Topic", + IconCustomEmojiID: "5411138633765757782", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestCloseForumTopic(t *testing.T) { + _, err := api.CloseForumTopic( + groupID, + msgThreadID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestReopenForumTopic(t *testing.T) { + _, err := api.ReopenForumTopic( + groupID, + msgThreadID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestUnpinAllForumTopicMessages(t *testing.T) { + res, err := api.SendMessage( + "Test", + groupID, + &MessageOptions{ + MessageThreadID: msgThreadID, + }, + ) + + if err != nil { + t.Fatal(err) + } + + _, err = api.PinChatMessage( + groupID, + res.Result.ID, + &PinMessageOptions{ + DisableNotification: true, + }, + ) + + if err != nil { + t.Fatal(err) + } + + _, err = api.UnpinAllForumTopicMessages( + groupID, + msgThreadID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestDeleteForumTopic(t *testing.T) { + _, err := api.DeleteForumTopic( + groupID, + msgThreadID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestEditGeneralForumTopic(t *testing.T) { + _, err := api.EditGeneralForumTopic( + groupID, + fmt.Sprintf( + "General | %d", + time.Now().Unix(), + ), + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestCloseGeneralForumTopic(t *testing.T) { + _, err := api.CloseGeneralForumTopic( + groupID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestHideGeneralForumTopic(t *testing.T) { + _, err := api.HideGeneralForumTopic( + groupID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestUnhideGeneralForumTopic(t *testing.T) { + _, err := api.UnhideGeneralForumTopic( + groupID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestReopenGeneralForumTopic(t *testing.T) { + _, err := api.ReopenGeneralForumTopic( + groupID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestUnpinAllGeneralForumTopicMessages(t *testing.T) { + _, err := api.UnpinAllGeneralForumTopicMessages( + groupID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestGetUserChatBoosts(t *testing.T) { + _, err := api.GetUserChatBoosts( + channelID, + chatID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSetMyCommands(t *testing.T) { + opts := &CommandOptions{ + LanguageCode: "it", + Scope: BotCommandScope{Type: BCSTChat, ChatID: chatID}, + } + + _, err := api.SetMyCommands( + opts, + commands..., + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestGetMyCommands(t *testing.T) { + res, err := api.GetMyCommands( + nil, + ) + + if err != nil { + t.Fatal(err) + } + + for i, cmd := range res.Result { + if !reflect.DeepEqual(*cmd, commands[i]) { + t.Logf("expected command in %d: %+v", i, commands[i]) + t.Logf("command in %d from API: %+v", i, cmd) + t.Fatal("error: commands mismatch") + } + } +} + +func TestDeleteMyCommands(t *testing.T) { + _, err := api.DeleteMyCommands( + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSetMyName(t *testing.T) { + currentBotName = fmt.Sprintf( + "Echotron Coverage Bot - %d", + time.Now().Unix(), + ) + + _, err := api.SetMyName(currentBotName, "") + + if err != nil { + t.Fatal(err) + } +} + +func TestGetMyName(t *testing.T) { + res, err := api.GetMyName("") + + if err != nil { + t.Fatal(err) + } + + if res.Result.Name != currentBotName { + t.Logf("expected bot name [\"%s\"]\n", currentBotName) + t.Logf("got bot name [\"%s\"]\n", res.Result.Name) + t.Fatal("error: bot name mismatch") + } +} + +func TestSetMyDescription(t *testing.T) { + currentBotDesc = fmt.Sprintf( + "Echotron Coverage Bot - %d", + time.Now().Unix(), + ) + + _, err := api.SetMyDescription(currentBotDesc, "") + + if err != nil { + t.Fatal(err) + } +} + +func TestGetMyDescription(t *testing.T) { + res, err := api.GetMyDescription("") + + if err != nil { + t.Fatal(err) + } + + if res.Result.Description != currentBotDesc { + t.Logf("expected bot description [\"%s\"]\n", currentBotDesc) + t.Logf("got bot description [\"%s\"]\n", res.Result.Description) + t.Fatal("error: bot description mismatch") + } +} + +func TestSetMyShortDescription(t *testing.T) { + currentBotShortDesc = fmt.Sprintf( + "Echotron Coverage Bot - %d", + time.Now().Unix(), + ) + + _, err := api.SetMyShortDescription(currentBotShortDesc, "") + + if err != nil { + t.Fatal(err) + } +} + +func TestEditMessageText(t *testing.T) { + _, err := api.EditMessageText( + "edited message", + NewMessageID(chatID, msgTmp.ID), + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestEditMessageTextWithKeyboard(t *testing.T) { + _, err := api.EditMessageText( + "edited message with keyboard", + NewMessageID(chatID, msgTmp.ID), + &MessageTextOptions{ + ReplyMarkup: inlineKeyboard, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestEditMessageCaption(t *testing.T) { + _, err := api.EditMessageCaption( + NewMessageID(chatID, animationTmp.ID), + &MessageCaptionOptions{ + Caption: "TestEditMessageCaption", + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestEditMessageMedia(t *testing.T) { + _, err := api.EditMessageMedia( + NewMessageID(chatID, animationTmp.ID), + InputMediaAnimation{ + Type: MediaTypeAnimation, + Media: NewInputFileID(animationID), + Caption: "TestEditMessageMedia", + }, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestEditMessageMediaBytes(t *testing.T) { + _, err := api.EditMessageMedia( + NewMessageID(chatID, animationTmp.ID), + InputMediaAnimation{ + Type: MediaTypeAnimation, + Media: NewInputFilePath("assets/tests/animation.mp4"), + Caption: "TestEditMessageMediaBytes", + }, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestEditMessageMediaURL(t *testing.T) { + _, err := api.EditMessageMedia( + NewMessageID(chatID, animationTmp.ID), + InputMediaAnimation{ + Type: MediaTypeAnimation, + Media: NewInputFileURL(animationURL), + Caption: "TestEditMessageMediaURL", + }, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestEditMessageReplyMarkup(t *testing.T) { + _, err := api.EditMessageReplyMarkup( + NewMessageID(chatID, msgTmp.ID), + &MessageReplyMarkupOptions{ + ReplyMarkup: inlineKeyboardEdit, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestStopPoll(t *testing.T) { + _, err := api.StopPoll( + chatID, + pollTmp.ID, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestDeleteMessage(t *testing.T) { + _, err := api.DeleteMessage( + chatID, + msgTmp.ID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestDeleteMessages(t *testing.T) { + msg, _ := api.SendMessage( + "TestDeleteMessages", + chatID, + nil, + ) + + _, err := api.DeleteMessages( + chatID, + []int{msgTmp.ID, msg.Result.ID}, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestDeleteMessagesWrongMsgIDs(t *testing.T) { + _, err := api.DeleteMessages( + chatID, + []int{}, + ) + + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/shared/echotron/apierror.go b/shared/echotron/apierror.go new file mode 100644 index 0000000..7974046 --- /dev/null +++ b/shared/echotron/apierror.go @@ -0,0 +1,42 @@ +/* + * Echotron + * Copyright (C) 2018 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +import "fmt" + +// APIError represents an error returned by the Telegram API. +type APIError struct { + desc string + code int +} + +// ErrorCode returns the error code received from the Telegram API. +func (a *APIError) ErrorCode() int { + return a.code +} + +// Description returns the error description received from the Telegram API. +func (a *APIError) Description() string { + return a.desc +} + +// Error returns the error string. +func (a *APIError) Error() string { + return fmt.Sprintf("API error: %d %s", a.code, a.desc) +} diff --git a/shared/echotron/apierror_test.go b/shared/echotron/apierror_test.go new file mode 100644 index 0000000..0f07f9f --- /dev/null +++ b/shared/echotron/apierror_test.go @@ -0,0 +1,17 @@ +package echotron + +import "testing" + +var a APIError + +func TestErrorCode(_ *testing.T) { + a.ErrorCode() +} + +func TestDescription(_ *testing.T) { + a.Description() +} + +func TestError(_ *testing.T) { + _ = a.Error() +} diff --git a/shared/echotron/assets/banner.jpg b/shared/echotron/assets/banner.jpg new file mode 100644 index 0000000..bf38ecb Binary files /dev/null and b/shared/echotron/assets/banner.jpg differ diff --git a/shared/echotron/assets/logo.png b/shared/echotron/assets/logo.png new file mode 100644 index 0000000..fd0ddfd Binary files /dev/null and b/shared/echotron/assets/logo.png differ diff --git a/shared/echotron/assets/readme_banner.png b/shared/echotron/assets/readme_banner.png new file mode 100644 index 0000000..0766314 Binary files /dev/null and b/shared/echotron/assets/readme_banner.png differ diff --git a/shared/echotron/assets/tests/animation.mp4 b/shared/echotron/assets/tests/animation.mp4 new file mode 100644 index 0000000..1f645e0 Binary files /dev/null and b/shared/echotron/assets/tests/animation.mp4 differ diff --git a/shared/echotron/assets/tests/audio.mp3 b/shared/echotron/assets/tests/audio.mp3 new file mode 100644 index 0000000..90c09c2 Binary files /dev/null and b/shared/echotron/assets/tests/audio.mp3 differ diff --git a/shared/echotron/assets/tests/audio_inv.mp3 b/shared/echotron/assets/tests/audio_inv.mp3 new file mode 100644 index 0000000..75a701f Binary files /dev/null and b/shared/echotron/assets/tests/audio_inv.mp3 differ diff --git a/shared/echotron/assets/tests/document.pdf b/shared/echotron/assets/tests/document.pdf new file mode 100644 index 0000000..e25081e Binary files /dev/null and b/shared/echotron/assets/tests/document.pdf differ diff --git a/shared/echotron/assets/tests/echotron_sticker.png b/shared/echotron/assets/tests/echotron_sticker.png new file mode 100644 index 0000000..7034472 Binary files /dev/null and b/shared/echotron/assets/tests/echotron_sticker.png differ diff --git a/shared/echotron/assets/tests/echotron_test.png b/shared/echotron/assets/tests/echotron_test.png new file mode 100644 index 0000000..7034472 Binary files /dev/null and b/shared/echotron/assets/tests/echotron_test.png differ diff --git a/shared/echotron/assets/tests/echotron_thumb.jpg b/shared/echotron/assets/tests/echotron_thumb.jpg new file mode 100644 index 0000000..4d0faa3 Binary files /dev/null and b/shared/echotron/assets/tests/echotron_thumb.jpg differ diff --git a/shared/echotron/assets/tests/echotron_thumb.png b/shared/echotron/assets/tests/echotron_thumb.png new file mode 100644 index 0000000..50db086 Binary files /dev/null and b/shared/echotron/assets/tests/echotron_thumb.png differ diff --git a/shared/echotron/assets/tests/echotron_thumb_inv.jpg b/shared/echotron/assets/tests/echotron_thumb_inv.jpg new file mode 100644 index 0000000..c0d2d47 Binary files /dev/null and b/shared/echotron/assets/tests/echotron_thumb_inv.jpg differ diff --git a/shared/echotron/assets/tests/video.webm b/shared/echotron/assets/tests/video.webm new file mode 100644 index 0000000..72b90e8 Binary files /dev/null and b/shared/echotron/assets/tests/video.webm differ diff --git a/shared/echotron/assets/tests/video_note.mp4 b/shared/echotron/assets/tests/video_note.mp4 new file mode 100644 index 0000000..d1ef9f3 Binary files /dev/null and b/shared/echotron/assets/tests/video_note.mp4 differ diff --git a/shared/echotron/chatadminrights.go b/shared/echotron/chatadminrights.go new file mode 100644 index 0000000..4597290 --- /dev/null +++ b/shared/echotron/chatadminrights.go @@ -0,0 +1,64 @@ +/* + * Echotron + * Copyright (C) 2022 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +// ChatAdministratorRights represents the rights of an administrator in a chat. +type ChatAdministratorRights struct { + IsAnonymous bool `json:"is_anonymous"` + CanManageChat bool `json:"can_manage_chat"` + CanDeleteMessages bool `json:"can_delete_messages"` + CanManageVideoChats bool `json:"can_manage_video_chats"` + CanRestrictMembers bool `json:"can_restrict_members"` + CanPromoteMembers bool `json:"can_promote_members"` + CanChangeInfo bool `json:"can_change_info"` + CanInviteUsers bool `json:"can_invite_users"` + CanPostStories bool `json:"can_post_stories"` + CanEditStories bool `json:"can_edit_stories"` + CanDeleteStories bool `json:"can_delete_stories"` + CanPostMessages bool `json:"can_post_messages,omitempty"` + CanEditMessages bool `json:"can_edit_messages,omitempty"` + CanPinMessages bool `json:"can_pin_messages,omitempty"` + CanManageTopics bool `json:"can_manage_topics,omitempty"` +} + +// SetMyDefaultAdministratorRightsOptions contains the optional parameters used by +// the SetMyDefaultAdministratorRights method. +type SetMyDefaultAdministratorRightsOptions struct { + Rights ChatAdministratorRights `query:"rights"` + ForChannels bool `query:"for_channels"` +} + +// GetMyDefaultAdministratorRightsOptions contains the optional parameters used by +// the GetMyDefaultAdministratorRights method. +type GetMyDefaultAdministratorRightsOptions struct { + ForChannels bool `query:"for_channels"` +} + +// SetMyDefaultAdministratorRights is used to change the default administrator rights +// requested by the bot when it's added as an administrator to groups or channels. +// These rights will be suggested to users, but they are are free to modify the list +// before adding the bot. +func (a API) SetMyDefaultAdministratorRights(opts *SetMyDefaultAdministratorRightsOptions) (res APIResponseBool, err error) { + return res, client.get(a.base, "setMyDefaultAdministratorRights", urlValues(opts), &res) +} + +// GetMyDefaultAdministratorRights is used to get the current default administrator rights of the bot. +func (a API) GetMyDefaultAdministratorRights(opts *GetMyDefaultAdministratorRightsOptions) (res APIResponseChatAdministratorRights, err error) { + return res, client.get(a.base, "getMyDefaultAdministratorRights", urlValues(opts), &res) +} diff --git a/shared/echotron/chatadminrights_test.go b/shared/echotron/chatadminrights_test.go new file mode 100644 index 0000000..c3a0d43 --- /dev/null +++ b/shared/echotron/chatadminrights_test.go @@ -0,0 +1,48 @@ +package echotron + +import ( + "reflect" + "testing" +) + +var ( + rights = ChatAdministratorRights{ + IsAnonymous: true, + CanManageChat: true, + CanDeleteMessages: true, + CanManageVideoChats: true, + CanRestrictMembers: true, + CanPromoteMembers: true, + CanChangeInfo: true, + CanInviteUsers: true, + CanPostStories: true, + CanEditStories: true, + CanDeleteStories: true, + } +) + +func TestSetMyDefaultAdministratorRights(t *testing.T) { + _, err := api.SetMyDefaultAdministratorRights( + &SetMyDefaultAdministratorRightsOptions{ + Rights: rights, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestGetMyDefaultAdministratorRights(t *testing.T) { + res, err := api.GetMyDefaultAdministratorRights(nil) + + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(*res.Result, rights) { + t.Logf("expected: %+v", rights) + t.Logf("got: %+v", res.Result) + t.Fatal("error: chat administrator rights mismatch") + } +} diff --git a/shared/echotron/dispatcher.go b/shared/echotron/dispatcher.go new file mode 100644 index 0000000..74b3af6 --- /dev/null +++ b/shared/echotron/dispatcher.go @@ -0,0 +1,226 @@ +/* + * Echotron + * Copyright (C) 2018 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +import ( + "compress/gzip" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "sync" +) + +type smap sync.Map + +func (s *smap) load(id int64) (Bot, bool) { + bot, ok := (*sync.Map)(s).Load(id) + if ok { + return bot.(Bot), ok + } + return nil, ok +} + +func (s *smap) store(id int64, bot Bot) { + (*sync.Map)(s).Store(id, bot) +} + +func (s *smap) delete(id int64) { + (*sync.Map)(s).Delete(id) +} + +// Bot is the interface that must be implemented by your definition of +// the struct thus it represent each open session with a user on Telegram. +type Bot interface { + // Update will be called upon receiving any update from Telegram. + Update(*Update) +} + +// NewBotFn is called every time echotron receives an update with a chat ID never +// encountered before. +type NewBotFn func(chatId int64) Bot + +// The Dispatcher passes the updates from the Telegram Bot API to the Bot instance +// associated with each chatID. When a new chat ID is found, the provided function +// of type NewBotFn will be called. +type Dispatcher struct { + sessions smap + newBot NewBotFn + updates chan *Update + httpServer *http.Server + api API +} + +// NewDispatcher returns a new instance of the Dispatcher object. +// Calls the Update function of the bot associated with each chat ID. +// If a new chat ID is found, newBotFn will be called first. +func NewDispatcher(token string, newBotFn NewBotFn) *Dispatcher { + d := &Dispatcher{ + api: NewAPI(token), + newBot: newBotFn, + updates: make(chan *Update), + } + go d.listen() + return d +} + +// DelSession deletes the Bot instance, seen as a session, from the +// map with all of them. +func (d *Dispatcher) DelSession(chatID int64) { + d.sessions.delete(chatID) +} + +// AddSession allows to arbitrarily create a new Bot instance. +func (d *Dispatcher) AddSession(chatID int64) { + d.sessions.store(chatID, d.newBot(chatID)) +} + +// Poll is a wrapper function for PollOptions. +func (d *Dispatcher) Poll() error { + return d.PollOptions(true, UpdateOptions{Timeout: 120}) +} + +// PollOptions starts the polling loop so that the dispatcher calls the function Update +// upon receiving any update from Telegram. +func (d *Dispatcher) PollOptions(dropPendingUpdates bool, opts UpdateOptions) error { + var ( + timeout = opts.Timeout + isFirstRun = true + ) + + // deletes webhook if present to run in long polling mode + if _, err := d.api.DeleteWebhook(dropPendingUpdates); err != nil { + return err + } + + for { + if isFirstRun { + opts.Timeout = 0 + } + + response, err := d.api.GetUpdates(&opts) + if err != nil { + return err + } + + if !dropPendingUpdates || !isFirstRun { + for _, u := range response.Result { + d.updates <- u + } + } + + if l := len(response.Result); l > 0 { + opts.Offset = response.Result[l-1].ID + 1 + } + + if isFirstRun { + isFirstRun = false + opts.Timeout = timeout + } + } +} + +func (d *Dispatcher) instance(chatID int64) Bot { + bot, ok := d.sessions.load(chatID) + if !ok { + bot = d.newBot(chatID) + d.sessions.store(chatID, bot) + } + return bot +} + +func (d *Dispatcher) listen() { + for update := range d.updates { + bot := d.instance(update.ChatID()) + go bot.Update(update) + } +} + +// ListenWebhook is a wrapper function for ListenWebhookOptions. +func (d *Dispatcher) ListenWebhook(webhookURL string) error { + return d.ListenWebhookOptions(webhookURL, false, nil) +} + +// ListenWebhookOptions sets a webhook and listens for incoming updates. +// The webhookUrl should be provided in the following format: ':/', +// eg: 'https://example.com:443/bot_token'. +// ListenWebhook will then proceed to communicate the webhook url '/' to Telegram +// and run a webserver that listens to ':' and handles the path. +func (d *Dispatcher) ListenWebhookOptions(webhookURL string, dropPendingUpdates bool, opts *WebhookOptions) error { + u, err := url.Parse(webhookURL) + if err != nil { + return err + } + + whURL := fmt.Sprintf("%s%s", u.Hostname(), u.EscapedPath()) + if _, err = d.api.SetWebhook(whURL, dropPendingUpdates, opts); err != nil { + return err + } + + if d.httpServer != nil { + mux := http.NewServeMux() + mux.Handle("/", d.httpServer.Handler) + mux.HandleFunc(u.EscapedPath(), d.HandleWebhook) + d.httpServer.Handler = mux + return d.httpServer.ListenAndServe() + } + http.HandleFunc(u.EscapedPath(), d.HandleWebhook) + return http.ListenAndServe(fmt.Sprintf(":%s", u.Port()), nil) +} + +// SetHTTPServer allows to set a custom http.Server for ListenWebhook and ListenWebhookOptions. +func (d *Dispatcher) SetHTTPServer(s *http.Server) { + d.httpServer = s +} + +// HandleWebhook is the http.HandlerFunc for the webhook URL. +// Useful if you've already a http server running and want to handle the request yourself. +func (d *Dispatcher) HandleWebhook(w http.ResponseWriter, r *http.Request) { + var update Update + + jsn, err := readRequest(r) + if err != nil { + log.Println("echotron.Dispatcher", "HandleWebhook", err) + return + } + + if err := json.Unmarshal(jsn, &update); err != nil { + log.Println("echotron.Dispatcher", "HandleWebhook", err) + return + } + + d.updates <- &update +} + +func readRequest(r *http.Request) ([]byte, error) { + switch r.Header.Get("Content-Encoding") { + case "gzip": + reader, err := gzip.NewReader(r.Body) + if err != nil { + return []byte{}, err + } + defer reader.Close() + return io.ReadAll(reader) + + default: + return io.ReadAll(r.Body) + } +} diff --git a/shared/echotron/dispatcher_test.go b/shared/echotron/dispatcher_test.go new file mode 100644 index 0000000..0d27eee --- /dev/null +++ b/shared/echotron/dispatcher_test.go @@ -0,0 +1,175 @@ +package echotron + +import ( + "testing" + "time" +) + +type test struct{} + +func (t test) Update(_ *Update) {} + +var dsp *Dispatcher + +func TestNewDispatcher(t *testing.T) { + if dsp = NewDispatcher("token", func(_ int64) Bot { return test{} }); dsp == nil { + t.Fatal("dispatcher is nil") + } +} + +func TestAddSession(t *testing.T) { + dsp.AddSession(0) + + if _, ok := dsp.sessions.load(0); !ok { + t.Fatal("could not add session") + } +} + +func TestDelSession(t *testing.T) { + dsp.DelSession(0) + + if _, ok := dsp.sessions.load(0); ok { + t.Fatal("could not delete session") + } +} + +func TestListenWebhook(_ *testing.T) { + dsp.ListenWebhook("http://example.com:8443/test") + time.Sleep(time.Second) +} + +func TestPoll(_ *testing.T) { + dsp.Poll() + + dsp.updates <- &Update{} + + dsp.updates <- &Update{ + ChatJoinRequest: &ChatJoinRequest{ + Chat: Chat{ID: 0}, + }, + } + + dsp.updates <- &Update{ + ChatBoost: &ChatBoostUpdated{ + Chat: Chat{ID: 0}, + }, + } + + dsp.updates <- &Update{ + RemovedChatBoost: &ChatBoostRemoved{ + Chat: Chat{ID: 0}, + }, + } + + dsp.updates <- &Update{ + Message: &Message{ + Chat: Chat{ID: 0}, + }, + } + + dsp.updates <- &Update{ + EditedMessage: &Message{ + Chat: Chat{ID: 0}, + }, + } + + dsp.updates <- &Update{ + ChannelPost: &Message{ + Chat: Chat{ID: 0}, + }, + } + + dsp.updates <- &Update{ + EditedChannelPost: &Message{ + Chat: Chat{ID: 0}, + }, + } + + dsp.updates <- &Update{ + BusinessConnection: &BusinessConnection{ + User: User{ID: 0}, + }, + } + + dsp.updates <- &Update{ + BusinessMessage: &Message{ + Chat: Chat{ID: 0}, + }, + } + + dsp.updates <- &Update{ + EditedBusinessMessage: &Message{ + Chat: Chat{ID: 0}, + }, + } + + dsp.updates <- &Update{ + DeletedBusinessMessages: &BusinessMessagesDeleted{ + Chat: Chat{ID: 0}, + }, + } + + dsp.updates <- &Update{ + MessageReaction: &MessageReactionUpdated{ + Chat: Chat{ID: 0}, + }, + } + + dsp.updates <- &Update{ + MessageReactionCount: &MessageReactionCountUpdated{ + Chat: Chat{ID: 0}, + }, + } + + dsp.updates <- &Update{ + InlineQuery: &InlineQuery{ + From: &User{ID: 0}, + }, + } + + dsp.updates <- &Update{ + ChosenInlineResult: &ChosenInlineResult{ + From: &User{ID: 0}, + }, + } + + dsp.updates <- &Update{ + CallbackQuery: &CallbackQuery{ + Message: &Message{ + Chat: Chat{ID: 0}, + }, + }, + } + + dsp.updates <- &Update{ + ShippingQuery: &ShippingQuery{ + From: User{ID: 0}, + }, + } + + dsp.updates <- &Update{ + PreCheckoutQuery: &PreCheckoutQuery{ + From: User{ID: 0}, + }, + } + + dsp.updates <- &Update{ + PollAnswer: &PollAnswer{ + User: &User{ID: 0}, + }, + } + + dsp.updates <- &Update{ + MyChatMember: &ChatMemberUpdated{ + Chat: Chat{ID: 0}, + }, + } + + dsp.updates <- &Update{ + ChatMember: &ChatMemberUpdated{ + Chat: Chat{ID: 0}, + }, + } + + time.Sleep(time.Second) +} diff --git a/shared/echotron/games.go b/shared/echotron/games.go new file mode 100644 index 0000000..9addcc2 --- /dev/null +++ b/shared/echotron/games.go @@ -0,0 +1,73 @@ +/* + * Echotron + * Copyright (C) 2018 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +import "net/url" + +// Game represents a game. +type Game struct { + Title string `json:"title"` + Description string `json:"description"` + Photo []PhotoSize `json:"photo"` + Text string `json:"text,omitempty"` + TextEntities []MessageEntity `json:"text_entities,omitempty"` + Animation Animation `json:"animation,omitempty"` +} + +// CallbackGame is a placeholder, currently holds no information. +type CallbackGame struct{} + +// GameHighScore represents one row of the high scores table for a game. +type GameHighScore struct { + User User `json:"user"` + Position int `json:"position"` + Score int `json:"score"` +} + +// GameScoreOptions contains the optional parameters used in SetGameScore method. +type GameScoreOptions struct { + Force bool `query:"force"` + DisableEditMessage bool `query:"disable_edit_message"` +} + +// SendGame is used to send a Game. +func (a API) SendGame(gameShortName string, chatID int64, opts *BaseOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + vals.Set("chat_id", itoa(chatID)) + vals.Set("game_short_name", gameShortName) + return res, client.get(a.base, "sendGame", addValues(vals, opts), &res) +} + +// SetGameScore is used to set the score of the specified user in a game. +func (a API) SetGameScore(userID int64, score int, msgID MessageIDOptions, opts *GameScoreOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + vals.Set("user_id", itoa(userID)) + vals.Set("score", itoa(int64(score))) + return res, client.get(a.base, "setGameScore", addValues(addValues(vals, msgID), opts), &res) +} + +// GetGameHighScores is used to get data for high score tables. +func (a API) GetGameHighScores(userID int64, opts MessageIDOptions) (res APIResponseGameHighScore, err error) { + var vals = make(url.Values) + + vals.Set("user_id", itoa(userID)) + return res, client.get(a.base, "getGameHighScores", addValues(vals, opts), &res) +} diff --git a/shared/echotron/games_test.go b/shared/echotron/games_test.go new file mode 100644 index 0000000..79b1aa1 --- /dev/null +++ b/shared/echotron/games_test.go @@ -0,0 +1,74 @@ +/* + * Echotron + * Copyright (C) 2018 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +import "testing" + +var ( + gameMsgTmp *Message + highScores []*GameHighScore +) + +func TestSendGame(t *testing.T) { + resp, err := api.SendGame( + "echotron_coverage_game", + chatID, + nil, + ) + + if err != nil { + t.Fatal(err) + } + + gameMsgTmp = resp.Result +} + +func TestGameHighScores(t *testing.T) { + resp, err := api.GetGameHighScores( + chatID, + NewMessageID(chatID, gameMsgTmp.ID), + ) + + if err != nil { + t.Fatal(err) + } + + highScores = resp.Result +} + +func TestSetGameScore(t *testing.T) { + var score int + + if len(highScores) > 0 { + score = highScores[0].Score + 1 + } + + _, err := api.SetGameScore( + chatID, + score, + NewMessageID(chatID, gameMsgTmp.ID), + &GameScoreOptions{ + Force: true, + }, + ) + + if err != nil { + t.Fatal(err) + } +} diff --git a/shared/echotron/go.mod b/shared/echotron/go.mod new file mode 100644 index 0000000..fb5c529 --- /dev/null +++ b/shared/echotron/go.mod @@ -0,0 +1,5 @@ +module github.com/NicoNex/echotron/v3 + +go 1.19 + +require golang.org/x/time v0.5.0 diff --git a/shared/echotron/go.sum b/shared/echotron/go.sum new file mode 100644 index 0000000..a2652c5 --- /dev/null +++ b/shared/echotron/go.sum @@ -0,0 +1,2 @@ +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/shared/echotron/helpers.go b/shared/echotron/helpers.go new file mode 100644 index 0000000..aa462aa --- /dev/null +++ b/shared/echotron/helpers.go @@ -0,0 +1,164 @@ +/* + * Echotron + * Copyright (C) 2018 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" +) + +// content contains a file's name, its type and its data. +type content struct { + fname string + ftype string + fdata []byte +} + +func check(r APIResponse) error { + if b := r.Base(); !b.Ok { + return &APIError{code: b.ErrorCode, desc: b.Description} + } + return nil +} + +func processMedia(media, thumbnail InputFile) (im mediaEnvelope, cnt []content, err error) { + switch { + case media.id != "": + im = mediaEnvelope{ + media: media.id, + thumbnail: "", + } + + case media.url != "": + im = mediaEnvelope{ + media: media.url, + thumbnail: "", + } + + case media.path != "" && len(media.content) == 0: + if media.content, media.path, err = readFile(media); err != nil { + return + } + fallthrough + + case media.path != "" && len(media.content) > 0: + cnt = append(cnt, content{media.path, media.path, media.content}) + im = mediaEnvelope{ + media: fmt.Sprintf("attach://%s", media.path), + thumbnail: "", + } + } + + switch { + case thumbnail.path != "" && len(thumbnail.content) == 0: + if thumbnail.content, thumbnail.path, err = readFile(thumbnail); err != nil { + return + } + fallthrough + + case thumbnail.path != "" && len(thumbnail.content) > 0: + cnt = append(cnt, content{thumbnail.path, thumbnail.path, thumbnail.content}) + im.thumbnail = fmt.Sprintf("attach://%s", thumbnail.path) + } + + return +} + +func processSticker(sticker InputFile) (se stickerEnvelope, cnt []content, err error) { + switch { + case sticker.id != "": + se.Sticker = sticker.id + + case sticker.url != "": + se.Sticker = sticker.url + + case sticker.path != "" && len(sticker.content) == 0: + if sticker.content, sticker.path, err = readFile(sticker); err != nil { + return + } + fallthrough + + case sticker.path != "" && len(sticker.content) > 0: + cnt = append(cnt, content{sticker.path, sticker.path, sticker.content}) + se.Sticker = fmt.Sprintf("attach://%s", sticker.path) + } + + return +} + +func readFile(im InputFile) (content []byte, path string, err error) { + content, err = os.ReadFile(im.path) + if err != nil { + return + } + path = filepath.Base(im.path) + + return +} + +func toContent(ftype string, f InputFile) (content, error) { + if f.path != "" && len(f.content) == 0 { + var err error + if f.content, f.path, err = readFile(f); err != nil { + return content{}, err + } + } + + return content{f.path, ftype, f.content}, nil +} + +func toInputMedia(media []GroupableInputMedia) (ret []InputMedia) { + ret = make([]InputMedia, len(media)) + + for i, v := range media { + ret[i] = v + } + + return ret +} + +func joinURL(base, endpoint string, vals url.Values) (addr string, err error) { + addr, err = url.JoinPath(base, endpoint) + if err != nil { + return + } + + if vals != nil { + if queries := vals.Encode(); queries != "" { + addr = fmt.Sprintf("%s?%s", addr, queries) + } + } + + return +} + +func itoa(i int64) string { + return strconv.FormatInt(i, 10) +} + +func ftoa(f float64) string { + return strconv.FormatFloat(f, 'f', -1, 64) +} + +func btoa(b bool) string { + return strconv.FormatBool(b) +} diff --git a/shared/echotron/inline.go b/shared/echotron/inline.go new file mode 100644 index 0000000..1d545c0 --- /dev/null +++ b/shared/echotron/inline.go @@ -0,0 +1,601 @@ +/* + * Echotron + * Copyright (C) 2018 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +import ( + "encoding/json" + "net/url" +) + +// InlineQueryType is a custom type for the various InlineQueryResult*'s Type field. +type InlineQueryType string + +// These are all the possible types for the various InlineQueryResult*'s Type field. +const ( + InlineArticle InlineQueryType = "article" + InlinePhoto = "photo" + InlineGIF = "gif" + InlineMPEG4GIF = "mpeg4_gif" + InlineVideo = "video" + InlineAudio = "audio" + InlineVoice = "voice" + InlineDocument = "document" + InlineLocation = "location" + InlineVenue = "venue" + InlineContact = "contact" + InlineGame = "game" + InlineSticker = "sticker" +) + +// InlineQuery represents an incoming inline query. +// When the user sends an empty query, your bot could return some default or trending results. +type InlineQuery struct { + From *User `json:"from"` + Location *Location `json:"location,omitempty"` + ID string `json:"id"` + Query string `json:"query"` + Offset string `json:"offset"` + ChatType string `json:"chat_type,omitempty"` +} + +// ChosenInlineResult represents a result of an inline query that was chosen by the user and sent to their chat partner. +type ChosenInlineResult struct { + ResultID string `json:"result_id"` + From *User `json:"from"` + Location *Location `json:"location,omitempty"` + InlineMessageID string `json:"inline_message_id,omitempty"` + Query string `json:"query"` +} + +// InlineQueryResult represents an interface that implements all the various InlineQueryResult* types. +type InlineQueryResult interface { + ImplementsInlineQueryResult() +} + +// InlineQueryResultArticle represents a link to an article or web page. +type InlineQueryResultArticle struct { + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + Type InlineQueryType `json:"type"` + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + ThumbnailURL string `json:"thumbnail_url,omitempty"` + URL string `json:"url,omitempty"` + ThumbnailWidth int `json:"thumbnail_width,omitempty"` + ThumbnailHeight int `json:"thumbnail_height,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultArticle) ImplementsInlineQueryResult() {} + +// InlineQueryResultPhoto represents a link to a photo. +// By default, this photo will be sent by the user with optional caption. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the photo. +type InlineQueryResultPhoto struct { + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + Title string `json:"title,omitempty"` + ThumbnailURL string `json:"thumbnail_url"` + PhotoURL string `json:"photo_url"` + ParseMode string `json:"parse_mode,omitempty"` + ID string `json:"id"` + Description string `json:"description,omitempty"` + Caption string `json:"caption,omitempty"` + Type InlineQueryType `json:"type"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + PhotoHeight int `json:"photo_height,omitempty"` + PhotoWidth int `json:"photo_width,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultPhoto) ImplementsInlineQueryResult() {} + +// InlineQueryResultGif represents a link to an animated GIF file. +// By default, this animated GIF file will be sent by the user with optional caption. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the animation. +type InlineQueryResultGif struct { + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + Title string `json:"title,omitempty"` + GifURL string `json:"gif_url"` + ParseMode string `json:"parse_mode,omitempty"` + Caption string `json:"caption,omitempty"` + ThumbnailURL string `json:"thumbnail_url"` + ID string `json:"id"` + ThumbnailMimeType string `json:"thumbnail_mime_type,omitempty"` + Type InlineQueryType `json:"type"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + GifDuration int `json:"gif_duration,omitempty"` + GifHeight int `json:"gif_height,omitempty"` + GifWidth int `json:"gif_width,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultGif) ImplementsInlineQueryResult() {} + +// InlineQueryResultMpeg4Gif represents a link to a video animation (H.264/MPEG-4 AVC video without sound). +// By default, this animated MPEG-4 file will be sent by the user with optional caption. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the animation. +type InlineQueryResultMpeg4Gif struct { + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + Title string `json:"title,omitempty"` + Mpeg4URL string `json:"mpeg4_url"` + ParseMode string `json:"parse_mode,omitempty"` + Caption string `json:"caption,omitempty"` + ThumbnailURL string `json:"thumbnail_url"` + ID string `json:"id"` + ThumbnailMimeType string `json:"thumbnail_mime_type,omitempty"` + Type InlineQueryType `json:"type"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + Mpeg4Duration int `json:"mpeg4_duration,omitempty"` + Mpeg4Height int `json:"mpeg4_height,omitempty"` + Mpeg4Width int `json:"mpeg4_width,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultMpeg4Gif) ImplementsInlineQueryResult() {} + +// InlineQueryResultVideo represents a link to a page containing an embedded video player or a video file. +// By default, this video file will be sent by the user with an optional caption. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the video. +type InlineQueryResultVideo struct { + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + Description string `json:"description,omitempty"` + MimeType string `json:"mime_type"` + ThumbnailURL string `json:"thumbnail_url"` + Title string `json:"title"` + Caption string `json:"caption,omitempty"` + ID string `json:"id"` + VideoURL string `json:"video_url"` + ParseMode string `json:"parse_mode,omitempty"` + Type InlineQueryType `json:"type"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + VideoHeight int `json:"video_height,omitempty"` + VideoDuration int `json:"video_duration,omitempty"` + VideoWidth int `json:"video_width,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultVideo) ImplementsInlineQueryResult() {} + +// InlineQueryResultAudio represents a link to an MP3 audio file. +// By default, this audio file will be sent by the user. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the audio. +type InlineQueryResultAudio struct { + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + Type InlineQueryType `json:"type"` + ID string `json:"id"` + AudioURL string `json:"audio_url"` + ParseMode string `json:"parse_mode,omitempty"` + Performer string `json:"performer,omitempty"` + Title string `json:"title"` + Caption string `json:"caption,omitempty"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + AudioDuration int `json:"audio_duration,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultAudio) ImplementsInlineQueryResult() {} + +// InlineQueryResultVoice represents a link to a voice recording in an .OGG container encoded with OPUS. +// By default, this voice recording will be sent by the user. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the the voice message. +type InlineQueryResultVoice struct { + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + Type InlineQueryType `json:"type"` + ID string `json:"id"` + Caption string `json:"caption,omitempty"` + ParseMode string `json:"parse_mode,omitempty"` + VoiceURL string `json:"voice_url"` + Title string `json:"title"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + VoiceDuration int `json:"voice_duration,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultVoice) ImplementsInlineQueryResult() {} + +// InlineQueryResultDocument represents a link to a file. +// By default, this file will be sent by the user with an optional caption. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the file. +// Currently, only .PDF and .ZIP files can be sent using this method. +type InlineQueryResultDocument struct { + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + MimeType string `json:"mime_type"` + Caption string `json:"caption,omitempty"` + ParseMode string `json:"parse_mode,omitempty"` + ThumbnailURL string `json:"thumbnail_url,omitempty"` + DocumentURL string `json:"document_url"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + ID string `json:"id"` + Type InlineQueryType `json:"type"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + ThumbnailWidth int `json:"thumbnail_width,omitempty"` + ThumbnailHeight int `json:"thumbnail_height,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultDocument) ImplementsInlineQueryResult() {} + +// InlineQueryResultLocation represents a location on a map. +// By default, the location will be sent by the user. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the location. +type InlineQueryResultLocation struct { + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + ID string `json:"id"` + ThumbnailURL string `json:"thumbnail_url,omitempty"` + Title string `json:"title"` + Type InlineQueryType `json:"type"` + LivePeriod int `json:"live_period,omitempty"` + HorizontalAccuracy float64 `json:"horizontal_accuracy,omitempty"` + ProximityAlertRadius int `json:"proximity_alert_radius,omitempty"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + ThumbnailWidth int `json:"thumbnail_width,omitempty"` + ThumbnailHeight int `json:"thumbnail_height,omitempty"` + Heading int `json:"heading,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultLocation) ImplementsInlineQueryResult() {} + +// InlineQueryResultVenue represents a venue. +// By default, the venue will be sent by the user. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the venue. +type InlineQueryResultVenue struct { + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + GooglePlaceType string `json:"google_place_type,omitempty"` + ThumbnailURL string `json:"thumbnail_url,omitempty"` + Title string `json:"title"` + Address string `json:"address"` + FoursquareID string `json:"foursquare_id,omitempty"` + ID string `json:"id"` + GooglePlaceID string `json:"google_place_id,omitempty"` + FoursquareType string `json:"foursquare_type,omitempty"` + Type InlineQueryType `json:"type"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + ThumbnailWidth int `json:"thumbnail_width,omitempty"` + ThumbnailHeight int `json:"thumbnail_height,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultVenue) ImplementsInlineQueryResult() {} + +// InlineQueryResultContact represents a contact with a phone number. +// By default, this contact will be sent by the user. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the contact. +type InlineQueryResultContact struct { + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + ID string `json:"id"` + PhoneNumber string `json:"phone_number"` + FirstName string `json:"first_name"` + VCard string `json:"vcard,omitempty"` + Type InlineQueryType `json:"type"` + ThumbnailURL string `json:"thumbnail_url,omitempty"` + LastName string `json:"last_name,omitempty"` + ThumbnailWidth int `json:"thumbnail_width,omitempty"` + ThumbnailHeight int `json:"thumbnail_height,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultContact) ImplementsInlineQueryResult() {} + +// InlineQueryResultGame represents a Game. +type InlineQueryResultGame struct { + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + Type InlineQueryType `json:"type"` + ID string `json:"id"` + GameShortName string `json:"game_short_name"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultGame) ImplementsInlineQueryResult() {} + +// InlineQueryResultCachedPhoto represents a link to a photo stored on the Telegram servers. +// By default, this photo will be sent by the user with an optional caption. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the photo. +type InlineQueryResultCachedPhoto struct { + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + Type InlineQueryType `json:"type"` + ID string `json:"id"` + Description string `json:"description,omitempty"` + Caption string `json:"caption,omitempty"` + ParseMode string `json:"parse_mode,omitempty"` + PhotoFileID string `json:"photo_file_id"` + Title string `json:"title,omitempty"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultCachedPhoto) ImplementsInlineQueryResult() {} + +// InlineQueryResultCachedGif represents a link to an animated GIF file stored on the Telegram servers. +// By default, this animated GIF file will be sent by the user with an optional caption. +// Alternatively, you can use InputMessageContent to send a message with specified content instead of the animation. +type InlineQueryResultCachedGif struct { + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + Type InlineQueryType `json:"type"` + Title string `json:"title,omitempty"` + Caption string `json:"caption,omitempty"` + ParseMode string `json:"parse_mode,omitempty"` + ID string `json:"id"` + GifFileID string `json:"gif_file_id"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultCachedGif) ImplementsInlineQueryResult() {} + +// InlineQueryResultCachedMpeg4Gif represents a link to a video animation (H.264/MPEG-4 AVC video without sound) stored on the Telegram servers. +// By default, this animated MPEG-4 file will be sent by the user with an optional caption. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the animation. +type InlineQueryResultCachedMpeg4Gif struct { + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + Type InlineQueryType `json:"type"` + Title string `json:"title,omitempty"` + Caption string `json:"caption,omitempty"` + ParseMode string `json:"parse_mode,omitempty"` + ID string `json:"id"` + Mpeg4FileID string `json:"mpeg4_file_id"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultCachedMpeg4Gif) ImplementsInlineQueryResult() {} + +// InlineQueryResultCachedSticker represents a link to a sticker stored on the Telegram servers. +// By default, this sticker will be sent by the user. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the sticker. +type InlineQueryResultCachedSticker struct { + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + Type InlineQueryType `json:"type"` + ID string `json:"id"` + StickerFileID string `json:"sticker_file_id"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultCachedSticker) ImplementsInlineQueryResult() {} + +// InlineQueryResultCachedDocument represents a link to a file stored on the Telegram servers. +// By default, this file will be sent by the user with an optional caption. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the file. +type InlineQueryResultCachedDocument struct { + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + Type InlineQueryType `json:"type"` + ID string `json:"id"` + Description string `json:"description,omitempty"` + Caption string `json:"caption,omitempty"` + ParseMode string `json:"parse_mode,omitempty"` + Title string `json:"title"` + DocumentFileID string `json:"document_file_id"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultCachedDocument) ImplementsInlineQueryResult() {} + +// InlineQueryResultCachedVideo represents a link to a video file stored on the Telegram servers. +// By default, this video file will be sent by the user with an optional caption. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the video. +type InlineQueryResultCachedVideo struct { + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + Type InlineQueryType `json:"type"` + ID string `json:"id"` + Description string `json:"description,omitempty"` + Caption string `json:"caption,omitempty"` + ParseMode string `json:"parse_mode,omitempty"` + VideoFileID string `json:"video_file_id"` + Title string `json:"title"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultCachedVideo) ImplementsInlineQueryResult() {} + +// InlineQueryResultCachedVoice represents a link to a voice message stored on the Telegram servers. +// By default, this voice message will be sent by the user. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the voice message. +type InlineQueryResultCachedVoice struct { + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + Type InlineQueryType `json:"type"` + Title string `json:"title"` + Caption string `json:"caption,omitempty"` + ParseMode string `json:"parse_mode,omitempty"` + ID string `json:"id"` + VoiceFileID string `json:"voice_file_id"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultCachedVoice) ImplementsInlineQueryResult() {} + +// InlineQueryResultCachedAudio represents a link to an MP3 audio file stored on the Telegram servers. +// By default, this audio file will be sent by the user. +// Alternatively, you can use InputMessageContent to send a message with the specified content instead of the audio. +type InlineQueryResultCachedAudio struct { + ReplyMarkup ReplyMarkup `json:"reply_markup,omitempty"` + InputMessageContent InputMessageContent `json:"input_message_content,omitempty"` + AudioFileID string `json:"audio_file_id"` + Caption string `json:"caption,omitempty"` + ParseMode string `json:"parse_mode,omitempty"` + Type InlineQueryType `json:"type"` + ID string `json:"id"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` +} + +// ImplementsInlineQueryResult is used to implement the InlineQueryResult interface. +func (i InlineQueryResultCachedAudio) ImplementsInlineQueryResult() {} + +// InputMessageContent represents an interface that implements all the various Input*MessageContent types. +type InputMessageContent interface { + ImplementsInputMessageContent() +} + +// InputTextMessageContent represents the content of a text message to be sent as the result of an inline query. +type InputTextMessageContent struct { + LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"` + MessageText string `json:"message_text"` + ParseMode string `json:"parse_mode,omitempty"` + Entities []*MessageEntity `json:"entities,omitempty"` +} + +// ImplementsInputMessageContent is used to implement the InputMessageContent interface. +func (i InputTextMessageContent) ImplementsInputMessageContent() {} + +// InputLocationMessageContent represents the content of a location message to be sent as the result of an inline query. +type InputLocationMessageContent struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + HorizontalAccuracy float64 `json:"horizontal_accuracy,omitempty"` + LivePeriod int `json:"live_period,omitempty"` + Heading int `json:"heading,omitempty"` + ProximityAlertRadius int `json:"proximity_alert_radius,omitempty"` +} + +// ImplementsInputMessageContent is used to implement the InputMessageContent interface. +func (i InputLocationMessageContent) ImplementsInputMessageContent() {} + +// InputVenueMessageContent represents the content of a venue message to be sent as the result of an inline query. +type InputVenueMessageContent struct { + GooglePlaceID string `json:"google_place_id,omitempty"` + GooglePlaceType string `json:"google_place_type,omitempty"` + Title string `json:"title"` + Address string `json:"address"` + FoursquareID string `json:"foursquare_id,omitempty"` + FoursquareType string `json:"foursquare_type,omitempty"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` +} + +// ImplementsInputMessageContent is used to implement the InputMessageContent interface. +func (i InputVenueMessageContent) ImplementsInputMessageContent() {} + +// InputContactMessageContent represents the content of a contact message to be sent as the result of an inline query. +type InputContactMessageContent struct { + PhoneNumber string `json:"phone_number"` + FirstName string `json:"first_name"` + LastName string `json:"last_name,omitempty"` + VCard string `json:"vcard,omitempty"` +} + +// ImplementsInputMessageContent is used to implement the InputMessageContent interface. +func (i InputContactMessageContent) ImplementsInputMessageContent() {} + +// InputInvoiceMessageContent represents the content of an invoice message to be sent as the result of an inline query. +type InputInvoiceMessageContent struct { + SuggestedTipAmounts *[]int `json:"suggested_tip_amounts,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` + Description string `json:"description"` + Payload string `json:"string"` + ProviderToken string `json:"provider_token,omitempty"` + Currency string `json:"currency"` + Title string `json:"title"` + ProviderData string `json:"provider_data,omitempty"` + Prices []LabeledPrice `json:"prices"` + PhotoSize int `json:"photo_size,omitempty"` + MaxTipAmount int `json:"max_tip_amount,omitempty"` + PhotoWidth int `json:"photo_width,omitempty"` + PhotoHeight int `json:"photo_height,omitempty"` + NeedName bool `json:"need_name,omitempty"` + NeedPhoneNumber bool `json:"need_phone_number,omitempty"` + NeedEmail bool `json:"need_email,omitempty"` + NeedShippingAddress bool `json:"need_shipping_address,omitempty"` + SendPhoneNumberToProvider bool `json:"send_phone_number_to_provider,omitempty"` + SendEmailToProvider bool `json:"send_email_to_provider,omitempty"` + IsFlexible bool `json:"is_flexible,omitempty"` +} + +// ImplementsInputMessageContent is used to implement the InputMessageContent interface. +func (i InputInvoiceMessageContent) ImplementsInputMessageContent() {} + +// InlineQueryResultsButton represents a button to be shown above inline query results. +// You MUST use exactly one of the fields. +type InlineQueryResultsButton struct { + WebApp WebAppInfo `json:"web_app,omitempty"` + StartParameter string `json:"start_parameter,omitempty"` + Text string `json:"text"` +} + +// PreparedInlineMessage describes an inline message to be sent by a user of a Mini App. +type PreparedInlineMessage struct { + ID string `json:"id"` + ExpirationDate int `json:"expiration_date"` +} + +// InlineQueryOptions is a custom type which contains the various options required by the AnswerInlineQuery method. +type InlineQueryOptions struct { + Button InlineQueryResultsButton `query:"button"` + NextOffset string `query:"next_offset"` + CacheTime int `query:"cache_time"` + IsPersonal bool `query:"is_personal"` +} + +// PreparedInlineMessageOptions is a custom type which contains the various options required by the SavePreparedInlineMessage method. +type PreparedInlineMessageOptions struct { + AllowUserChats bool `query:"allow_user_chats"` + AllowBotChats bool `query:"allow_bot_chats"` + AllowGroupChats bool `query:"allow_group_chats"` + AllowChannelChats bool `query:"allow_channel_chats"` +} + +// AnswerInlineQuery is used to send answers to an inline query. +func (a API) AnswerInlineQuery(inlineQueryID string, results []InlineQueryResult, opts *InlineQueryOptions) (res APIResponseBase, err error) { + var vals = make(url.Values) + + jsn, _ := json.Marshal(results) + vals.Set("inline_query_id", inlineQueryID) + vals.Set("results", string(jsn)) + return res, client.get(a.base, "answerInlineQuery", addValues(vals, opts), &res) +} + +// SavePreparedInlineMessage stores a message that can be sent by a user of a Mini App. +func (a API) SavePreparedInlineMessage(userID int64, result InlineQueryResult, opts *PreparedInlineMessageOptions) (res APIResponsePreparedInlineMessage, err error) { + var vals = make(url.Values) + + jsn, _ := json.Marshal(result) + vals.Set("user_id", itoa(userID)) + vals.Set("result", string(jsn)) + return res, client.get(a.base, "savePreparedInlineMessage", addValues(vals, opts), &res) +} diff --git a/shared/echotron/inline_test.go b/shared/echotron/inline_test.go new file mode 100644 index 0000000..21f1390 --- /dev/null +++ b/shared/echotron/inline_test.go @@ -0,0 +1,128 @@ +package echotron + +import "testing" + +func TestInlineQueryResultArticle(_ *testing.T) { + i := InlineQueryResultArticle{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultPhoto(_ *testing.T) { + i := InlineQueryResultPhoto{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultGif(_ *testing.T) { + i := InlineQueryResultGif{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultMpeg4Gif(_ *testing.T) { + i := InlineQueryResultMpeg4Gif{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultVideo(_ *testing.T) { + i := InlineQueryResultVideo{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultAudio(_ *testing.T) { + i := InlineQueryResultAudio{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultVoice(_ *testing.T) { + i := InlineQueryResultVoice{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultDocument(_ *testing.T) { + i := InlineQueryResultDocument{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultLocation(_ *testing.T) { + i := InlineQueryResultLocation{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultVenue(_ *testing.T) { + i := InlineQueryResultVenue{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultContact(_ *testing.T) { + i := InlineQueryResultContact{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultGame(_ *testing.T) { + i := InlineQueryResultGame{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultCachedPhoto(_ *testing.T) { + i := InlineQueryResultCachedPhoto{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultCachedGif(_ *testing.T) { + i := InlineQueryResultCachedGif{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultCachedMpeg4Gif(_ *testing.T) { + i := InlineQueryResultCachedMpeg4Gif{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultCachedSticker(_ *testing.T) { + i := InlineQueryResultCachedSticker{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultCachedDocument(_ *testing.T) { + i := InlineQueryResultCachedDocument{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultCachedVideo(_ *testing.T) { + i := InlineQueryResultCachedVideo{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultCachedVoice(_ *testing.T) { + i := InlineQueryResultCachedVoice{} + i.ImplementsInlineQueryResult() +} + +func TestInlineQueryResultCachedAudio(_ *testing.T) { + i := InlineQueryResultCachedAudio{} + i.ImplementsInlineQueryResult() +} + +func TestInputTextMessageContent(_ *testing.T) { + i := InputTextMessageContent{} + i.ImplementsInputMessageContent() +} + +func TestInputLocationMessageContent(_ *testing.T) { + i := InputLocationMessageContent{} + i.ImplementsInputMessageContent() +} + +func TestInputVenueMessageContent(_ *testing.T) { + i := InputVenueMessageContent{} + i.ImplementsInputMessageContent() +} + +func TestInputContactMessageContent(_ *testing.T) { + i := InputContactMessageContent{} + i.ImplementsInputMessageContent() +} + +func TestInputInvoiceMessageContent(_ *testing.T) { + i := InputInvoiceMessageContent{} + i.ImplementsInputMessageContent() +} diff --git a/shared/echotron/menubutton.go b/shared/echotron/menubutton.go new file mode 100644 index 0000000..e166704 --- /dev/null +++ b/shared/echotron/menubutton.go @@ -0,0 +1,57 @@ +/* + * Echotron + * Copyright (C) 2022 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +// MenuButtonType is a custom type for the various MenuButton*'s Type field. +type MenuButtonType string + +// These are all the possible types for the various MenuButton*'s Type field. +const ( + MenuButtonTypeCommands MenuButtonType = "commands" + MenuButtonTypeWebApp = "web_app" + MenuButtonTypeDefault = "default" +) + +// MenuButton is a unique type for MenuButtonCommands, MenuButtonWebApp and MenuButtonDefault +type MenuButton struct { + WebApp *WebAppInfo `json:"web_app,omitempty"` + Type MenuButtonType `json:"type"` + Text string `json:"text,omitempty"` +} + +// SetChatMenuButtonOptions contains the optional parameters used by the SetChatMenuButton method. +type SetChatMenuButtonOptions struct { + MenuButton MenuButton `query:"menu_button"` + ChatID int64 `query:"chat_id"` +} + +// GetChatMenuButtonOptions contains the optional parameters used by the GetChatMenuButton method. +type GetChatMenuButtonOptions struct { + ChatID int64 `query:"chat_id"` +} + +// SetChatMenuButton is used to change the bot's menu button in a private chat, or the default menu button. +func (a API) SetChatMenuButton(opts *SetChatMenuButtonOptions) (res APIResponseBool, err error) { + return res, client.get(a.base, "setChatMenuButton", urlValues(opts), &res) +} + +// GetChatMenuButton is used to get the current value of the bot's menu button in a private chat, or the default menu button. +func (a API) GetChatMenuButton(opts *GetChatMenuButtonOptions) (res APIResponseMenuButton, err error) { + return res, client.get(a.base, "getChatMenuButton", urlValues(opts), &res) +} diff --git a/shared/echotron/menubutton_test.go b/shared/echotron/menubutton_test.go new file mode 100644 index 0000000..e2316ec --- /dev/null +++ b/shared/echotron/menubutton_test.go @@ -0,0 +1,38 @@ +package echotron + +import ( + "reflect" + "testing" +) + +var ( + menuBtn = MenuButton{ + Type: MenuButtonTypeCommands, + } +) + +func TestSetChatMenuButton(t *testing.T) { + _, err := api.SetChatMenuButton( + &SetChatMenuButtonOptions{ + MenuButton: menuBtn, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestGetChatMenuButton(t *testing.T) { + res, err := api.GetChatMenuButton(nil) + + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(*res.Result, menuBtn) { + t.Logf("expected menu button: %+v", menuBtn) + t.Logf("got menu button: %+v", res.Result) + t.Fatal("error: menu buttons mismatch") + } +} diff --git a/shared/echotron/network.go b/shared/echotron/network.go new file mode 100644 index 0000000..8079f11 --- /dev/null +++ b/shared/echotron/network.go @@ -0,0 +1,437 @@ +/* + * Echotron + * Copyright (C) 2018 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/time/rate" +) + +type lclient struct { + *http.Client + *sync.RWMutex + cl map[string]*rate.Limiter // chat based limiter + gl *rate.Limiter // global limiter + climiter func() *rate.Limiter +} + +var client = &lclient{ + Client: new(http.Client), + RWMutex: new(sync.RWMutex), + cl: make(map[string]*rate.Limiter), + gl: rate.NewLimiter(rate.Every(time.Second/30), 30), + climiter: func() *rate.Limiter { + return rate.NewLimiter(rate.Every(time.Minute/20), 20) + }, +} + +// SetGlobalRequestLimit sets the global rate limit for requests to the Telegram API. +// An interval of 0 disables the rate limiter, allowing unlimited requests. +// By default the interval of this limiter is set to time.Second/30 and the +// burstSize is set to 30. +func SetGlobalRequestLimit(interval time.Duration, burstSize int) { + client.Lock() + client.gl = rate.NewLimiter(rate.Every(interval), burstSize) + client.Unlock() +} + +// SetChatRequestLimit sets the per-chat rate limit for requests to the Telegram API. +// An interval of 0 disables the rate limiter, allowing unlimited requests. +// By default the interval of this limiter is set to time.Minute/20 and the +// burstSize is set to 20. +func SetChatRequestLimit(interval time.Duration, burstSize int) { + client.Lock() + client.cl = make(map[string]*rate.Limiter) + client.climiter = func() *rate.Limiter { + return rate.NewLimiter(rate.Every(interval), burstSize) + } + client.Unlock() +} + +func (c lclient) wait(chatID string) error { + c.RLock() + defer c.RUnlock() + + ctx := context.Background() + // If the chatID is empty, it's a general API call like GetUpdates, GetMe + // and similar, so skip the per-chat request limit wait. + if chatID != "" { + // If no limiter exists for a chat, create one. + l, ok := c.cl[chatID] + if !ok { + l = c.climiter() + c.cl[chatID] = l + } + + // Make sure to respect the single chat limit of requests. + if err := l.Wait(ctx); err != nil { + return err + } + } + + // Make sure to respect the global limit of requests. + return c.gl.Wait(ctx) +} + +func (c lclient) doGet(reqURL string) ([]byte, error) { + resp, err := c.Get(reqURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return data, nil +} + +func (c lclient) doPost(reqURL string, files ...content) ([]byte, error) { + var ( + buf = new(bytes.Buffer) + w = multipart.NewWriter(buf) + ) + + for _, f := range files { + part, err := w.CreateFormFile(f.ftype, filepath.Base(f.fname)) + if err != nil { + return nil, err + } + part.Write(f.fdata) + } + w.Close() + + req, err := http.NewRequest(http.MethodPost, reqURL, buf) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", w.FormDataContentType()) + + res, err := c.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + return io.ReadAll(res.Body) +} + +func (c lclient) doPostForm(reqURL string, keyVals map[string]string) ([]byte, error) { + var form = make(url.Values) + + for k, v := range keyVals { + form.Add(k, v) + } + + req, err := http.NewRequest(http.MethodPost, reqURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.PostForm = form + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + res, err := c.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + return io.ReadAll(res.Body) +} + +func (c lclient) sendFile(file, thumbnail InputFile, url, fileType string) (res []byte, err error) { + var cnt []content + + if file.id != "" { + url = fmt.Sprintf("%s&%s=%s", url, fileType, file.id) + } else if file.url != "" { + url = fmt.Sprintf("%s&%s=%s", url, fileType, file.url) + } else if c, e := toContent(fileType, file); e == nil { + cnt = append(cnt, c) + } else { + err = e + } + + if c, e := toContent("thumbnail", thumbnail); e == nil { + cnt = append(cnt, c) + } else { + err = e + } + + if len(cnt) > 0 { + res, err = c.doPost(url, cnt...) + } else { + res, err = c.doGet(url) + } + return +} + +func (c lclient) get(base, endpoint string, vals url.Values, v APIResponse) error { + url, err := url.JoinPath(base, endpoint) + if err != nil { + return err + } + + if vals != nil { + if queries := vals.Encode(); queries != "" { + url = fmt.Sprintf("%s?%s", url, queries) + } + } + + if err := c.wait(vals.Get("chat_id")); err != nil { + return err + } + + cnt, err := c.doGet(url) + if err != nil { + return err + } + + if err := json.Unmarshal(cnt, v); err != nil { + return err + } + return check(v) +} + +func (c lclient) postFile(base, endpoint, fileType string, file, thumbnail InputFile, vals url.Values, v APIResponse) error { + url, err := joinURL(base, endpoint, vals) + if err != nil { + return err + } + + if err := c.wait(vals.Get("chat_id")); err != nil { + return err + } + + cnt, err := c.sendFile(file, thumbnail, url, fileType) + if err != nil { + return err + } + + if err := json.Unmarshal(cnt, v); err != nil { + return err + } + return check(v) +} + +func (c lclient) postMedia(base, endpoint string, editSingle bool, vals url.Values, v APIResponse, files ...InputMedia) error { + url, err := joinURL(base, endpoint, vals) + if err != nil { + return err + } + + if err := c.wait(vals.Get("chat_id")); err != nil { + return err + } + + cnt, err := c.sendMediaFiles(url, editSingle, files...) + if err != nil { + return err + } + + if err := json.Unmarshal(cnt, v); err != nil { + return err + } + return check(v) +} + +func (c lclient) postStickers(base, endpoint string, vals url.Values, v APIResponse, stickers ...InputSticker) error { + url, err := joinURL(base, endpoint, vals) + if err != nil { + return err + } + + if err := c.wait(vals.Get("chat_id")); err != nil { + return err + } + + cnt, err := c.sendStickers(url, stickers...) + if err != nil { + return err + } + if err := json.Unmarshal(cnt, v); err != nil { + return err + } + return check(v) +} + +func (c lclient) postProfilePhoto(base, endpoint string, vals url.Values, v APIResponse, profilePhoto InputProfilePhoto) error { + url, err := joinURL(base, endpoint, vals) + if err != nil { + return err + } + + if err := c.wait(""); err != nil { + return err + } + + cnt, err := c.sendProfilePhoto(url, profilePhoto) + if err != nil { + return err + } + if err := json.Unmarshal(cnt, v); err != nil { + return err + } + return check(v) +} + +func (c lclient) sendMediaFiles(url string, editSingle bool, files ...InputMedia) (res []byte, err error) { + var ( + med []mediaEnvelope + cnt []content + jsn []byte + ) + + for _, file := range files { + var im mediaEnvelope + var cntArr []content + + media := file.media() + thumbnail := file.thumbnail() + + im, cntArr, err = processMedia(media, thumbnail) + if err != nil { + return + } + + im.InputMedia = file + + med = append(med, im) + cnt = append(cnt, cntArr...) + } + + if editSingle { + jsn, err = json.Marshal(med[0]) + } else { + jsn, err = json.Marshal(med) + } + + if err != nil { + return + } + + url = fmt.Sprintf("%s&media=%s", url, jsn) + + if len(cnt) > 0 { + return c.doPost(url, cnt...) + } + return c.doGet(url) +} + +func (c lclient) sendStickers(url string, stickers ...InputSticker) (res []byte, err error) { + var ( + sti []stickerEnvelope + cnt []content + jsn []byte + ) + + for _, s := range stickers { + var se stickerEnvelope + var cntArr []content + + se, cntArr, err = processSticker(s.Sticker) + if err != nil { + return + } + + se.InputSticker = s + + sti = append(sti, se) + cnt = append(cnt, cntArr...) + } + + if len(sti) == 1 { + jsn, _ = json.Marshal(sti[0]) + url = fmt.Sprintf("%s&sticker=%s", url, jsn) + } else { + jsn, _ = json.Marshal(sti) + url = fmt.Sprintf("%s&stickers=%s", url, jsn) + } + + if len(cnt) > 0 { + return c.doPost(url, cnt...) + } + return c.doGet(url) +} + +func (c lclient) sendProfilePhoto(url string, profilePhoto InputProfilePhoto) (res []byte, err error) { + var ( + pp profilePhotoEnvelope + cnt []content + jsn []byte + ) + + file := profilePhoto.file() + + switch file := file; { + case file.id != "": + pp.ProfilePhoto = file.id + pp.Animation = file.id + + case file.url != "": + pp.ProfilePhoto = file.url + pp.Animation = file.url + + case file.path != "" && len(file.content) == 0: + if file.content, file.path, err = readFile(file); err != nil { + return + } + fallthrough + + case file.path != "" && len(file.content) > 0: + cnt = append(cnt, content{file.path, file.path, file.content}) + pp.ProfilePhoto = fmt.Sprintf("attach://%s", file.path) + pp.Animation = fmt.Sprintf("attach://%s", file.path) + } + + switch profilePhoto.(type) { + case InputProfilePhotoStatic: + pp.Animation = "" + case InputProfilePhotoAnimated: + pp.ProfilePhoto = "" + } + + pp.InputProfilePhoto = profilePhoto + jsn, err = json.Marshal(pp) + if err != nil { + return + } + + sep := "?" + if strings.Contains(url, "?") { + sep = "&" + } + url = fmt.Sprintf("%s%sprofile_photo=%s", url, sep, jsn) + + if len(cnt) > 0 { + return c.doPost(url, cnt...) + } + return c.doGet(url) +} diff --git a/shared/echotron/options.go b/shared/echotron/options.go new file mode 100644 index 0000000..349004e --- /dev/null +++ b/shared/echotron/options.go @@ -0,0 +1,861 @@ +/* + * Echotron + * Copyright (C) 2018 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +// ParseMode is a custom type for the various frequent options used by some methods of the API. +type ParseMode string + +// These are all the possible options that can be used by some methods. +const ( + Markdown ParseMode = "Markdown" + MarkdownV2 = "MarkdownV2" + HTML = "HTML" +) + +// PollType is a custom type for the various types of poll that can be sent. +type PollType string + +// These are all the possible poll types. +const ( + Quiz PollType = "quiz" + Regular = "regular" + Any = "" +) + +// DiceEmoji is a custom type for the various emojis that can be sent through the SendDice method. +type DiceEmoji string + +// These are all the possible emojis that can be sent through the SendDice method. +const ( + Die DiceEmoji = "🎲" + Darts = "🎯" + Basket = "🏀" + Goal = "⚽️" + Bowling = "🎳" + Slot = "🎰" +) + +// ChatAction is a custom type for the various actions that can be sent through the SendChatAction method. +type ChatAction string + +// These are all the possible actions that can be sent through the SendChatAction method. +const ( + Typing ChatAction = "typing" + UploadPhoto = "upload_photo" + RecordVideo = "record_video" + UploadVideo = "upload_video" + RecordAudio = "record_audio" + UploadAudio = "upload_audio" + UploadDocument = "upload_document" + FindLocation = "find_location" + RecordVideoNote = "record_video_note" + UploadVideoNote = "upload_video_note" + ChooseSticker = "choose_sticker" +) + +// MessageEntityType is a custom type for the various MessageEntity types used in various methods. +type MessageEntityType string + +// These are all the possible types for MessageEntityType. +const ( + MentionEntity MessageEntityType = "mention" + HashtagEntity = "hashtag" + CashtagEntity = "cashtag" + BotCommandEntity = "bot_command" + UrlEntity = "url" + EmailEntity = "email" + PhoneNumberEntity = "phone_number" + BoldEntity = "bold" + ItalicEntity = "italic" + UnderlineEntity = "underline" + StrikethroughEntity = "strikethrough" + SpoilerEntity = "spoiler" + BlockQuoteEntity = "blockquote" + ExpandableBlockQuoteEntity = "expandable_blockquote" + CodeEntity = "code" + PreEntity = "pre" + TextLinkEntity = "text_link" + TextMentionEntity = "text_mention" + CustomEmojiEntity = "custom_emoji" +) + +// UpdateType is a custom type for the various update types that a bot can be subscribed to. +type UpdateType string + +// These are all the possible types that a bot can be subscribed to. +const ( + MessageUpdate UpdateType = "message" + EditedMessageUpdate = "edited_message" + ChannelPostUpdate = "channel_post" + EditedChannelPostUpdate = "edited_channel_post" + InlineQueryUpdate = "inline_query" + ChosenInlineResultUpdate = "chosen_inline_result" + CallbackQueryUpdate = "callback_query" + ShippingQueryUpdate = "shipping_query" + PreCheckoutQueryUpdate = "pre_checkout_query" + PollUpdate = "poll" + PollAnswerUpdate = "poll_answer" + MyChatMemberUpdate = "my_chat_member" + ChatMemberUpdate = "chat_member" +) + +// ReplyMarkup is an interface for the various keyboard types. +type ReplyMarkup interface { + ImplementsReplyMarkup() +} + +// KeyboardButton represents a button in a keyboard. +type KeyboardButton struct { + RequestPoll *KeyboardButtonPollType `json:"request_poll,omitempty"` + WebApp *WebAppInfo `json:"web_app,omitempty"` + RequestUsers *KeyboardButtonRequestUsers `json:"request_users,omitempty"` + RequestChat *KeyboardButtonRequestChat `json:"request_chat,omitempty"` + IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` + Style ButtonStyle `json:"style,omitempty"` + Text string `json:"text"` + RequestContact bool `json:"request_contact,omitempty"` + RequestLocation bool `json:"request_location,omitempty"` +} + +// ButtonStyle represents supported button style values. +type ButtonStyle string + +// These are all the possible values for ButtonStyle. +const ( + PrimaryButtonStyle ButtonStyle = "primary" + DangerButtonStyle = "danger" + SuccessButtonStyle = "success" +) + +// KeyboardButtonPollType represents type of a poll, which is allowed to be created and sent when the corresponding button is pressed. +type KeyboardButtonPollType struct { + Type PollType `json:"type"` +} + +// KeyboardButtonRequestUsers defines the criteria used to request suitable users. +// The identifiers of the selected users will be shared with the bot when the corresponding button is pressed. +type KeyboardButtonRequestUsers struct { + RequestID int `json:"request_id"` + MaxQuantity int `json:"max_quantity,omitempty"` + UserIsBot bool `json:"user_is_bot,omitempty"` + UserIsPremium bool `json:"user_is_premium,omitempty"` + RequestName bool `json:"request_name,omitempty"` + RequestUsername bool `json:"request_username,omitempty"` + RequestPhoto bool `json:"request_photo,omitempty"` +} + +// KeyboardButtonRequestChat defines the criteria used to request a suitable chat. +// The identifier of the selected chat will be shared with the bot when the corresponding button is pressed. +type KeyboardButtonRequestChat struct { + UserAdministratorRights *ChatAdministratorRights `json:"user_administrator_rights,omitempty"` + BotAdministratorRights *ChatAdministratorRights `json:"bot_administrator_rights,omitempty"` + RequestID int `json:"request_id"` + ChatIsChannel bool `json:"chat_is_channel,omitempty"` + ChatIsForum bool `json:"chat_is_forum,omitempty"` + ChatHasUsername bool `json:"chat_has_username,omitempty"` + ChatIsCreated bool `json:"chat_is_created,omitempty"` + BotIsMember bool `json:"bot_is_member,omitempty"` + RequestName bool `json:"request_name,omitempty"` + RequestUsername bool `json:"request_username,omitempty"` + RequestPhoto bool `json:"request_photo,omitempty"` +} + +// ReplyKeyboardMarkup represents a custom keyboard with reply options. +type ReplyKeyboardMarkup struct { + InputFieldPlaceholder string `json:"input_field_placeholder,omitempty"` + Keyboard [][]KeyboardButton `json:"keyboard"` + IsPersistent bool `json:"is_persistent,omitempty"` + ResizeKeyboard bool `json:"resize_keyboard,omitempty"` + OneTimeKeyboard bool `json:"one_time_keyboard,omitempty"` + Selective bool `json:"selective,omitempty"` +} + +// ImplementsReplyMarkup is a dummy method which exists to implement the interface ReplyMarkup. +func (i ReplyKeyboardMarkup) ImplementsReplyMarkup() {} + +// ReplyKeyboardRemove is used to remove the current custom keyboard and display the default letter-keyboard. +// By default, custom keyboards are displayed until a new keyboard is sent by a bot. +// An exception is made for one-time keyboards that are hidden immediately after the user presses a button (see ReplyKeyboardMarkup). +// RemoveKeyboard MUST BE true. +type ReplyKeyboardRemove struct { + RemoveKeyboard bool `json:"remove_keyboard"` + Selective bool `json:"selective"` +} + +// ImplementsReplyMarkup is a dummy method which exists to implement the interface ReplyMarkup. +func (r ReplyKeyboardRemove) ImplementsReplyMarkup() {} + +// InlineKeyboardButton represents a button in an inline keyboard. +type InlineKeyboardButton struct { + CopyText *CopyTextButton `json:"copy_text,omitempty"` + CallbackGame *CallbackGame `json:"callback_game,omitempty"` + WebApp *WebAppInfo `json:"web_app,omitempty"` + LoginURL *LoginURL `json:"login_url,omitempty"` + SwitchInlineQueryChosenChat *SwitchInlineQueryChosenChat `json:"switch_inline_query_chosen_chat,omitempty"` + IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` + Style ButtonStyle `json:"style,omitempty"` + Text string `json:"text"` + CallbackData string `json:"callback_data,omitempty"` + SwitchInlineQuery string `json:"switch_inline_query,omitempty"` + SwitchInlineQueryCurrentChat string `json:"switch_inline_query_current_chat,omitempty"` + URL string `json:"url,omitempty"` + Pay bool `json:"pay,omitempty"` +} + +// CopyTextButton represents an inline keyboard button that copies specified text to the clipboard. +type CopyTextButton struct { + Text string `json:"text"` +} + +// InlineKeyboardMarkup represents an inline keyboard. +type InlineKeyboardMarkup struct { + InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard" query:"inline_keyboard"` +} + +// ImplementsReplyMarkup is a dummy method which exists to implement the interface ReplyMarkup. +func (i InlineKeyboardMarkup) ImplementsReplyMarkup() {} + +// ForceReply is used to display a reply interface to the user (act as if the user has selected the bot's message and tapped 'Reply'). +// This can be extremely useful if you want to create user-friendly step-by-step interfaces without having to sacrifice privacy mode. +type ForceReply struct { + InputFieldPlaceholder string `json:"input_field_placeholder,omitempty"` + ForceReply bool `json:"force_reply"` + Selective bool `json:"selective"` +} + +// ImplementsReplyMarkup is a dummy method which exists to implement the interface ReplyMarkup. +func (f ForceReply) ImplementsReplyMarkup() {} + +// UpdateOptions contains the optional parameters used by the GetUpdates method. +type UpdateOptions struct { + AllowedUpdates []UpdateType `query:"allowed_updates"` + Offset int `query:"offset"` + Limit int `query:"limit"` + Timeout int `query:"timeout"` +} + +// WebhookOptions contains the optional parameters used by the SetWebhook method. +type WebhookOptions struct { + IPAddress string `query:"ip_address"` + SecretToken string `query:"secret_token"` + Certificate InputFile + AllowedUpdates []UpdateType `query:"allowed_updates"` + MaxConnections int `query:"max_connections"` +} + +// BaseOptions contains the optional parameters used frequently in some Telegram API methods. +type BaseOptions struct { + BusinessConnectionID string `query:"business_connection_id"` + MessageEffectID string `query:"message_effect_id"` + ReplyMarkup ReplyMarkup `query:"reply_markup"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + MessageThreadID int `query:"message_thread_id"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// MessageOptions contains the optional parameters used by some Telegram API methods. +type MessageOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + BusinessConnectionID string `query:"business_connection_id"` + MessageEffectID string `query:"message_effect_id"` + ParseMode ParseMode `query:"parse_mode"` + LinkPreviewOptions LinkPreviewOptions `query:"link_preview_options"` + Entities []MessageEntity `query:"entities"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + MessageThreadID int64 `query:"message_thread_id"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// PinMessageOptions contains the optional parameters used by the PinChatMember method. +type PinMessageOptions struct { + BusinessConnectionID string `query:"business_connection_id"` + DisableNotification bool `query:"disable_notification"` +} + +// UnpinMessageOptions contains the optional parameters used by the UnpinChatMember method. +type UnpinMessageOptions struct { + BusinessConnectionID string `query:"business_connection_id"` + MessageID int `query:"message_id"` +} + +// ForwardOptions contains the optional parameters used by the ForwardMessage method. +type ForwardOptions struct { + MessageThreadID int `query:"message_thread_id"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + VideoStartTimestamp int `query:"video_start_timestamp"` +} + +// CopyOptions contains the optional parameters used by the CopyMessage method. +type CopyOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + ParseMode ParseMode `query:"parse_mode"` + Caption string `query:"caption"` + CaptionEntities []MessageEntity `query:"caption_entities"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + MessageThreadID int `query:"message_thread_id"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + ShowCaptionAboveMedia bool `query:"show_caption_above_media"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// CopyMessagesOptions contains the optional parameters used by the CopyMessages methods. +type CopyMessagesOptions struct { + MessageThreadID int `query:"message_thread_id"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + RemoveCaption bool `query:"remove_caption"` + VideoStartTimestamp int `query:"video_start_timestamp"` +} + +// StickerOptions contains the optional parameters used by the SendSticker method. +type StickerOptions struct { + BusinessConnectionID string `query:"business_connection_id"` + Emoji string `query:"emoji"` + MessageEffectID string `query:"message_effect_id"` + ReplyMarkup ReplyMarkup `query:"reply_markup"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + MessageThreadID int `query:"message_thread_id"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// InputFile is a struct which contains data about a file to be sent. +type InputFile struct { + id string + path string + url string + content []byte +} + +// NewInputFileID is a wrapper for InputFile which only fills the id field. +func NewInputFileID(ID string) InputFile { + return InputFile{id: ID} +} + +// NewInputFilePath is a wrapper for InputFile which only fills the path field. +func NewInputFilePath(filePath string) InputFile { + return InputFile{path: filePath} +} + +// NewInputFileURL is a wrapper for InputFile which only fills the url field. +func NewInputFileURL(url string) InputFile { + return InputFile{url: url} +} + +// NewInputFileBytes is a wrapper for InputFile which only fills the path and content fields. +func NewInputFileBytes(fileName string, content []byte) InputFile { + return InputFile{path: fileName, content: content} +} + +// PhotoOptions contains the optional parameters used by the SendPhoto method. +type PhotoOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + BusinessConnectionID string `query:"business_connection_id"` + MessageEffectID string `query:"message_effect_id"` + ParseMode ParseMode `query:"parse_mode"` + Caption string `query:"caption"` + CaptionEntities []MessageEntity `query:"caption_entities"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + MessageThreadID int `query:"message_thread_id"` + HasSpoiler bool `query:"has_spoiler"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + ShowCaptionAboveMedia bool `query:"show_caption_above_media"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// AudioOptions contains the optional parameters used by the SendAudio method. +type AudioOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + Title string `query:"title"` + MessageEffectID string `query:"message_effect_id"` + ParseMode ParseMode `query:"parse_mode"` + Caption string `query:"caption"` + Performer string `query:"performer"` + BusinessConnectionID string `query:"business_connection_id"` + Thumbnail InputFile + CaptionEntities []MessageEntity `query:"caption_entities"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + MessageThreadID int `query:"message_thread_id"` + Duration int `query:"duration"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// DocumentOptions contains the optional parameters used by the SendDocument method. +type DocumentOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + BusinessConnectionID string `query:"business_connection_id"` + MessageEffectID string `query:"message_effect_id"` + ParseMode ParseMode `query:"parse_mode"` + Caption string `query:"caption"` + Thumbnail InputFile + CaptionEntities []MessageEntity `query:"caption_entities"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + MessageThreadID int `query:"message_thread_id"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + DisableContentTypeDetection bool `query:"disable_content_type_detection"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// VideoOptions contains the optional parameters used by the SendVideo method. +// TODO: handle the cover correctly. +type VideoOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + BusinessConnectionID string `query:"business_connection_id"` + Caption string `query:"caption"` + MessageEffectID string `query:"message_effect_id"` + ParseMode ParseMode `query:"parse_mode"` + Thumbnail InputFile + CaptionEntities []MessageEntity `query:"caption_entities"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + MessageThreadID int `query:"message_thread_id"` + Duration int `query:"duration"` + Width int `query:"width"` + Height int `query:"height"` + HasSpoiler bool `query:"has_spoiler"` + SupportsStreaming bool `query:"supports_streaming"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + ShowCaptionAboveMedia bool `query:"show_caption_above_media"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` + Cover InputFile + StartTimestamp int `query:"start_timestamp"` +} + +// AnimationOptions contains the optional parameters used by the SendAnimation method. +type AnimationOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + BusinessConnectionID string `query:"business_connection_id"` + MessageEffectID string `query:"message_effect_id"` + ParseMode ParseMode `query:"parse_mode"` + Caption string `query:"caption"` + Thumbnail InputFile + CaptionEntities []MessageEntity `query:"caption_entities"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + MessageThreadID int `query:"message_thread_id"` + Duration int `query:"duration"` + Width int `query:"width"` + Height int `query:"height"` + HasSpoiler bool `query:"has_spoiler"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + ShowCaptionAboveMedia bool `query:"show_caption_above_media"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// VoiceOptions contains the optional parameters used by the SendVoice method. +type VoiceOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + BusinessConnectionID string `query:"business_connection_id"` + MessageEffectID string `query:"message_effect_id"` + ParseMode ParseMode `query:"parse_mode"` + Caption string `query:"caption"` + CaptionEntities []MessageEntity `query:"caption_entities"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + MessageThreadID int `query:"message_thread_id"` + Duration int `query:"duration"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// VideoNoteOptions contains the optional parameters used by the SendVideoNote method. +type VideoNoteOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + BusinessConnectionID string `query:"business_connection_id"` + MessageEffectID string `query:"message_effect_id"` + Thumbnail InputFile + ReplyParameters ReplyParameters `query:"reply_parameters"` + MessageThreadID int `query:"message_thread_id"` + Duration int `query:"duration"` + Length int `query:"length"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// PaidMediaOptions contains the optional parameters used by the SendPaidMedia method. +type PaidMediaOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + BusinessConnectionID string `query:"business_connection_id"` + Caption string `query:"caption"` + Payload string `query:"payload"` + ParseMode ParseMode `query:"parse_mode"` + CaptionEntities []MessageEntity `query:"caption_entities"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + ShowCaptionAboveMedia bool `query:"show_caption_above_media"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// MediaGroupOptions contains the optional parameters used by the SendMediaGroup method. +type MediaGroupOptions struct { + BusinessConnectionID string `query:"business_connection_id"` + MessageEffectID string `query:"message_effect_id"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + MessageThreadID int `query:"message_thread_id"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// This is a custom constant to set an infinite live period value in LocationOptions and EditLocationOptions. +const InfiniteLivePeriod = 0x7FFFFFFF + +// LocationOptions contains the optional parameters used by the SendLocation method. +type LocationOptions struct { + BusinessConnectionID string `query:"business_connection_id"` + MessageEffectID string `query:"message_effect_id"` + ReplyMarkup ReplyMarkup `query:"reply_markup"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + HorizontalAccuracy float64 `query:"horizontal_accuracy"` + MessageThreadID int `query:"message_thread_id"` + LivePeriod int `query:"live_period"` + ProximityAlertRadius int `query:"proximity_alert_radius"` + Heading int `query:"heading"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// EditLocationOptions contains the optional parameters used by the EditMessageLiveLocation method. +type EditLocationOptions struct { + BusinessConnectionID string `query:"business_connection_id"` + ReplyMarkup InlineKeyboardMarkup `query:"reply_markup"` + HorizontalAccuracy float64 `query:"horizontal_accuracy"` + Heading int `query:"heading"` + LivePeriod int `query:"live_period"` + ProximityAlertRadius int `query:"proximity_alert_radius"` +} + +// StopLocationOptions contains the optional parameters used by the StopMessageLiveLocation method. +type StopLocationOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + BusinessConnectionID string `query:"business_connection_id"` +} + +// VenueOptions contains the optional parameters used by the SendVenue method. +type VenueOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + BusinessConnectionID string `query:"business_connection_id"` + FoursquareID string `query:"foursquare_id"` + FoursquareType string `query:"foursquare_type"` + GooglePlaceType string `query:"google_place_type"` + GooglePlaceID string `query:"google_place_id"` + MessageEffectID string `query:"message_effect_id"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + MessageThreadID int `query:"message_thread_id"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// ContactOptions contains the optional parameters used by the SendContact method. +type ContactOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + BusinessConnectionID string `query:"business_connection_id"` + VCard string `query:"vcard"` + LastName string `query:"last_name"` + MessageEffectID string `query:"message_effect_id"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + MessageThreadID int `query:"message_thread_id"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// PollOptions contains the optional parameters used by the SendPoll method. +type PollOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + BusinessConnectionID string `query:"business_connection_id"` + Explanation string `query:"explanation"` + MessageEffectID string `query:"message_effect_id"` + ExplanationParseMode ParseMode `query:"explanation_parse_mode"` + QuestionParseMode ParseMode `query:"question_parse_mode"` + Type PollType `query:"type"` + ExplanationEntities []MessageEntity `query:"explanation_entities"` + QuestionEntities []MessageEntity `query:"question_entities"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + CorrectOptionID int `query:"correct_option_id"` + MessageThreadID int `query:"message_thread_id"` + CloseDate int `query:"close_date"` + OpenPeriod int `query:"open_period"` + IsClosed bool `query:"is_closed"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + AllowsMultipleAnswers bool `query:"allows_multiple_answers"` + IsAnonymous bool `query:"is_anonymous"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// StopPollOptions contains the optional parameters used by the StopPoll method. +type StopPollOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + BusinessConnectionID string `query:"business_connection_id"` +} + +// BanOptions contains the optional parameters used by the BanChatMember method. +type BanOptions struct { + UntilDate int `query:"until_date"` + RevokeMessages bool `query:"revoke_messages"` +} + +// UnbanOptions contains the optional parameters used by the UnbanChatMember method. +type UnbanOptions struct { + OnlyIfBanned bool `query:"only_if_banned"` +} + +// RestrictOptions contains the optional parameters used by the RestrictChatMember method. +type RestrictOptions struct { + UseIndependentChatPermissions bool `query:"use_independent_chat_permissions"` + UntilDate int `query:"until_date"` +} + +// PromoteOptions contains the optional parameters used by the PromoteChatMember method. +type PromoteOptions struct { + IsAnonymous bool `query:"is_anonymous,omitempty"` + CanManageChat bool `query:"can_manage_chat,omitempty"` + CanPostMessages bool `query:"can_post_messages,omitempty"` + CanEditMessages bool `query:"can_edit_messages,omitempty"` + CanDeleteMessages bool `query:"can_delete_messages,omitempty"` + CanManageVideoChats bool `query:"can_manage_video_chats,omitempty"` + CanRestrictMembers bool `query:"can_restrict_members,omitempty"` + CanPromoteMembers bool `query:"can_promote_members,omitempty"` + CanChangeInfo bool `query:"can_change_info,omitempty"` + CanInviteUsers bool `query:"can_invite_users,omitempty"` + CanPinMessages bool `query:"can_pin_messages,omitempty"` + CanPostStories bool `json:"can_post_stories,omitempty"` + CanEditStories bool `json:"can_edit_stories,omitempty"` + CanDeleteStories bool `json:"can_delete_stories,omitempty"` + CanManageTopics bool `query:"can_manage_topics,omitempty"` +} + +// UserProfileOptions contains the optional parameters used by the GetUserProfilePhotos method. +type UserProfileOptions struct { + Offset int `query:"offset"` + Limit int `query:"limit"` +} + +// UserProfileAudioOptions contains the optional parameters used by the GetUserProfileAudios method. +type UserProfileAudioOptions struct { + Offset int `query:"offset"` + Limit int `query:"limit"` +} + +// UserEmojiStatusOptions contains the optional parameters used by the SetUserEmojiStatus method. +type UserEmojiStatusOptions struct { + EmojiStatusCustomEmojiID string `query:"emoji_status_custom_emoji_id"` + EmojiStatusExpirationDate string `query:"emoji_status_expiration_date"` +} + +// ChatPermissionsOptions contains the optional parameters used by the SetChatPermissions method. +type ChatPermissionsOptions struct { + UseIndependentChatPermissions bool `query:"use_independent_chat_permissions"` +} + +// InviteLinkOptions contains the optional parameters used by the CreateChatInviteLink and EditChatInviteLink methods. +type InviteLinkOptions struct { + Name string `query:"name"` + ExpireDate int64 `query:"expire_date"` + MemberLimit int `query:"member_limit"` + CreatesJoinRequest bool `query:"creates_join_request"` +} + +// ChatSubscriptionInviteOptions contains the optional parameters used by the CreateChatSubscriptionInviteLink and EditChatSubscriptionInviteLink methods. +type ChatSubscriptionInviteOptions struct { + Name string `query:"name"` +} + +// CallbackQueryOptions contains the optional parameters used by the AnswerCallbackQuery method. +type CallbackQueryOptions struct { + Text string `query:"text"` + URL string `query:"url"` + CacheTime int `query:"cache_time"` + ShowAlert bool `query:"show_alert"` +} + +// MessageIDOptions is a struct which contains data about a message to edit. +type MessageIDOptions struct { + inlineMessageID string `query:"inline_message_id"` + chatID int64 `query:"chat_id"` + messageID int `query:"message_id"` +} + +// NewMessageID is a wrapper for MessageIDOptions which only fills the chatID and messageID fields. +func NewMessageID(chatID int64, messageID int) MessageIDOptions { + return MessageIDOptions{chatID: chatID, messageID: messageID} +} + +// NewInlineMessageID is a wrapper for MessageIDOptions which only fills the inlineMessageID fields. +func NewInlineMessageID(ID string) MessageIDOptions { + return MessageIDOptions{inlineMessageID: ID} +} + +// MessageTextOptions contains the optional parameters used by the EditMessageText method. +type MessageTextOptions struct { + ParseMode ParseMode `query:"parse_mode"` + BusinessConnectionID string `query:"business_connection_id"` + Entities []MessageEntity `query:"entities"` + ReplyMarkup InlineKeyboardMarkup `query:"reply_markup"` + LinkPreviewOptions LinkPreviewOptions `query:"link_preview_options"` +} + +// MessageCaptionOptions contains the optional parameters used by the EditMessageCaption method. +type MessageCaptionOptions struct { + Caption string `query:"caption"` + BusinessConnectionID string `query:"business_connection_id"` + ParseMode ParseMode `query:"parse_mode"` + CaptionEntities []MessageEntity `query:"caption_entities"` + ReplyMarkup InlineKeyboardMarkup `query:"reply_markup"` + ShowCaptionAboveMedia bool `query:"show_caption_above_media"` +} + +// MessageMediaOptions contains the optional parameters used by the EditMessageMedia method. +type MessageMediaOptions struct { + ReplyMarkup ReplyMarkup `query:"reply_markup"` + BusinessConnectionID string `query:"business_connection_id"` +} + +// MessageReplyMarkupOptions contains the optional parameters used by the EditMessageReplyMarkup method. +type MessageReplyMarkupOptions struct { + BusinessConnectionID string `query:"business_connection_id"` + ReplyMarkup InlineKeyboardMarkup `query:"reply_markup"` +} + +// CommandOptions contains the optional parameters used by the SetMyCommands, DeleteMyCommands and GetMyCommands methods. +type CommandOptions struct { + LanguageCode string `query:"language_code"` + Scope BotCommandScope `query:"scope"` +} + +// InvoiceOptions contains the optional parameters used by the SendInvoice API method. +type InvoiceOptions struct { + StartParameter string `query:"start_parameter"` + ProviderData string `query:"provider_data"` + PhotoURL string `query:"photo_url"` + ProviderToken string `query:"provider_token"` + MessageEffectID string `query:"message_effect_id"` + ReplyMarkup InlineKeyboardMarkup `query:"reply_markup"` + SuggestedTipAmount []int `query:"suggested_tip_amounts"` + ReplyParameters ReplyParameters `query:"reply_parameters"` + MaxTipAmount int `query:"max_tip_amount"` + PhotoSize int `query:"photo_size"` + PhotoWidth int `query:"photo_width"` + PhotoHeight int `query:"photo_height"` + MessageThreadID int `query:"message_thread_id"` + SendPhoneNumberToProvider bool `query:"send_phone_number_to_provider"` + NeedShippingAddress bool `query:"need_shipping_address"` + NeedPhoneNumber bool `query:"need_phone_number"` + SendEmailToProvider bool `query:"send_email_to_provider"` + IsFlexible bool `query:"is_flexible"` + DisableNotification bool `query:"disable_notification"` + ProtectContent bool `query:"protect_content"` + NeedName bool `query:"need_name"` + NeedEmail bool `query:"need_email"` + AllowPaidBroadcast bool `query:"allow_paid_broadcast"` +} + +// CreateInvoiceLinkOptions contains the optional parameters used by the CreateInvoiceLink API method. +// Currently, SubscriptionPeriod should always be set to 2592000 (30 days) if specified. +type CreateInvoiceLinkOptions struct { + ProviderData string `query:"provider_data"` + PhotoURL string `query:"photo_url"` + ProviderToken string `query:"provider_token"` + BusinessConnectionID string `query:"business_connection_id"` + SuggestedTipAmounts []int `query:"suggested_tip_amounts"` + PhotoSize int `query:"photo_size"` + PhotoWidth int `query:"photo_width"` + PhotoHeight int `query:"photo_height"` + MaxTipAmount int `query:"max_tip_amount"` + SubscriptionPeriod int `query:"subscription_period"` + NeedPhoneNumber bool `query:"need_phone_number"` + NeepShippingAddress bool `query:"need_shipping_address"` + SendPhoneNumberToProvider bool `query:"send_phone_number_to_provider"` + SendEmailToProvider bool `query:"send_email_to_provider"` + IsFlexible bool `query:"is_flexible"` + NeedName bool `query:"need_name"` + NeedEmail bool `query:"need_email"` +} + +// ShippingOption represents one shipping option. +type ShippingOption struct { + ID string `query:"id"` + Title string `query:"title"` + Prices []LabeledPrice `query:"prices"` +} + +// ShippingQueryOptions contains the optional parameters used by the AnswerShippingQuery API method. +type ShippingQueryOptions struct { + ErrorMessage string `query:"error_message"` + ShippingOptions []ShippingOption `query:"shipping_options"` +} + +// PreCheckoutOptions contains the optional parameters used by the AnswerPreCheckoutQuery API method. +type PreCheckoutOptions struct { + ErrorMessage string `query:"error_message"` +} + +// CreateTopicOptions contains the optional parameters used by the CreateForumTopic API method. +type CreateTopicOptions struct { + IconCustomEmojiID string `query:"icon_custom_emoji_id"` + IconColor IconColor `query:"icon_color"` +} + +// EditTopicOptions contains the optional parameters used by the EditForumTopic API method. +type EditTopicOptions struct { + Name string `query:"name"` + IconCustomEmojiID string `query:"icon_custom_emoji_id"` +} + +// ChatActionOptions contains the optional parameters used by the SendChatAction API method. +type ChatActionOptions struct { + BusinessConnectionID string `query:"business_connection_id"` + MessageThreadID int `query:"message_thread_id"` +} + +// MessageReactionOptions contains the optional parameters used by the SetMessageReaction API method. +type MessageReactionOptions struct { + Reaction []ReactionType `query:"reaction"` + IsBig bool `query:"is_big"` +} + +// GiftOptions contains the optional parameters used by the SendGift API method. +type GiftOptions struct { + Text string `query:"text"` + TextParseMode string `query:"text_parse_mode"` + TextEntities []MessageEntity `query:"text_entities"` + ChatID int64 `query:"chat_id"` + PayForUpgrade bool `query:"pay_for_upgrade"` +} + +// VerifyOptions contains the optional parameters used by the VerifyUser and VerifyChat API methods. +type VerifyOptions struct { + CustomDescription string `query:"custom_description"` +} diff --git a/shared/echotron/options_test.go b/shared/echotron/options_test.go new file mode 100644 index 0000000..9d4c428 --- /dev/null +++ b/shared/echotron/options_test.go @@ -0,0 +1,57 @@ +package echotron + +import ( + "reflect" + "testing" +) + +var ( + msgIDOpts = MessageIDOptions{ + chatID: 1, + messageID: 2, + } + + inlineMsgIDOpts = MessageIDOptions{ + inlineMessageID: "inline", + } +) + +func TestNewMessageID(t *testing.T) { + new := NewMessageID(1, 2) + + if !reflect.DeepEqual(new, msgIDOpts) { + t.Logf("expected MessageIDOptions: %+v", msgIDOpts) + t.Logf("got MessageIDOptions: %+v", new) + t.Fatal("error: MessageIDOptions mismatch") + } +} + +func TestNewInlineMessageID(t *testing.T) { + new := NewInlineMessageID("inline") + + if !reflect.DeepEqual(new, inlineMsgIDOpts) { + t.Logf("expected MessageIDOptions: %+v", inlineMsgIDOpts) + t.Logf("got MessageIDOptions: %+v", new) + t.Fatal("error: MessageIDOptions mismatch") + } +} + +func TestReplyKeyboardMarkupImplementsReplyMarkup(_ *testing.T) { + i := ReplyKeyboardMarkup{} + i.ImplementsReplyMarkup() +} + +func TestReplyKeyboardRemoveImplementsReplyMarkup(_ *testing.T) { + i := ReplyKeyboardRemove{} + i.ImplementsReplyMarkup() +} + +func TestInlineKeyboardMarkupImplementsReplyMarkup(_ *testing.T) { + i := InlineKeyboardMarkup{} + i.ImplementsReplyMarkup() +} + +func TestForceReplyImplementsReplyMarkup(_ *testing.T) { + i := ForceReply{} + i.ImplementsReplyMarkup() +} diff --git a/shared/echotron/passport.go b/shared/echotron/passport.go new file mode 100644 index 0000000..afacaad --- /dev/null +++ b/shared/echotron/passport.go @@ -0,0 +1,228 @@ +package echotron + +import ( + "encoding/json" + "net/url" +) + +// PassportData contains information about Telegram Passport data shared with the bot by the user. +type PassportData struct { + Credentials EncryptedCredentials `json:"encrypted_credentials"` + Data []EncryptedPassportElement `json:"encrypted_passport_element"` +} + +// PassportFile represents a file uploaded to Telegram Passport. +// Currently all Telegram Passport files are in JPEG format when decrypted and don't exceed 10MB. +type PassportFile struct { + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + FileSize int64 `json:"file_size"` + FileDate int64 `json:"file_date"` +} + +// EncryptedPassportElementType is a custom type for the various possible options used as Type in EncryptedPassportElement. +type EncryptedPassportElementType string + +// These are all the possible options that can be used as Type in EncryptedPassportElement. +const ( + TypePersonalDetails EncryptedPassportElementType = "personal_details" + TypePassport = "passport" + TypeDriverLicense = "driver_license" + TypeIdentityCard = "identity_card" + TypeInternalPassport = "internal_passport" + TypeAddress = "address" + TypeUtilityBill = "utility_bill" + TypeBankStatement = "bank_statement" + TypeRentalAgreement = "rental_agreement" + TypePassportRegistration = "passport_registration" + TypeTemporaryRegistration = "temporary_registration" + TypePhoneNumber = "phone_number" + TypeEmail = "email" +) + +// EncryptedPassportElement contains information about documents or other Telegram Passport elements shared with the bot by the user. +type EncryptedPassportElement struct { + Type EncryptedPassportElementType `json:"type"` + Data string `json:"data,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` + Email string `json:"email,omitempty"` + Files *[]PassportFile `json:"files,omitempty"` + FrontSide *PassportFile `json:"front_side,omitempty"` + ReverseSide *PassportFile `json:"reverse_side,omitempty"` + Selfie *PassportFile `json:"selfie,omitempty"` + Translation *[]PassportFile `json:"translation,omitempty"` + Hash string `json:"hash"` +} + +// EncryptedCredentials contains data required for decrypting and authenticating EncryptedPassportElement. +// See the Telegram Passport Documentation for a complete description of the data decryption and authentication processes. +// https://core.telegram.org/passport#receiving-information +type EncryptedCredentials struct { + Data string `json:"data"` + Hash string `json:"hash"` + Secret string `json:"secret"` +} + +// PassportElementErrorSource is a custom type for the various possible options used as Source in PassportElementSource. +type PassportElementErrorSource string + +// These are all the possible options that can be used as Source in PassportElementSource. +const ( + SourceData PassportElementErrorSource = "data" + SourceFrontSide = "front_side" + SourceReverseSide = "reverse_side" + SourceSelfie = "selfie" + SourceFile = "file" + SourceFiles = "files" + SourceTranslationFile = "translation_file" + SourceTranslationFiles = "translation_files" + SourceUnspecified = "unspecified" +) + +// PassportElementError is an interface for the various PassportElementError types. +type PassportElementError interface { + ImplementsPassportElementError() +} + +// PassportElementErrorDataField represents an issue in one of the data fields that was provided by the user. +// The error is considered resolved when the field's value changes. +// Source MUST BE SourceData. +// Type MUST BE one of TypePersonalDetails, TypePassport, TypeDriverLicense, TypeIdentityCard, TypeInternalPassport and TypeAddress. +type PassportElementErrorDataField struct { + Source PassportElementErrorSource `json:"source"` + Type EncryptedPassportElementType `json:"type"` + FieldName string `json:"field_name"` + DataHash string `json:"data_hash"` + Message string `json:"message"` +} + +// ImplementsPassportElementError us a dummy method which exists to implement the interface PassportElementError. +func (p PassportElementErrorDataField) ImplementsPassportElementError() {} + +// PassportElementErrorFrontSide represents an issue with the front side of a document. +// The error is considered resolved when the file with the front side of the document changes. +// Source MUST BE SourceFrontSide. +// Type MUST BE one of TypeDriverLicense and TypeIdentityCard. +type PassportElementErrorFrontSide struct { + Source PassportElementErrorSource `json:"source"` + Type EncryptedPassportElementType `json:"type"` + FileHash string `json:"file_hash"` + Message string `json:"message"` +} + +// ImplementsPassportElementError us a dummy method which exists to implement the interface PassportElementError. +func (p PassportElementErrorFrontSide) ImplementsPassportElementError() {} + +// PassportElementErrorReverseSide represents an issue with the reverse side of a document. +// The error is considered resolved when the file with the reverse side of the document changes. +// Source MUST BE SourceReverseSide. +// Type MUST BE one of TypeDriverLicense and TypeIdentityCard. +type PassportElementErrorReverseSide struct { + Source PassportElementErrorSource `json:"source"` + Type EncryptedPassportElementType `json:"type"` + FileHash string `json:"file_hash"` + Message string `json:"message"` +} + +// ImplementsPassportElementError us a dummy method which exists to implement the interface PassportElementError. +func (p PassportElementErrorReverseSide) ImplementsPassportElementError() {} + +// PassportElementErrorSelfie represents an issue with the selfie with a document. +// The error is considered resolved when the file with the selfie changes. +// Source MUST BE SourceSelfie. +// Type MUST BE one of TypePassport, TypeDriverLicense, TypeIdentityCard and TypeIdentityPassport. +type PassportElementErrorSelfie struct { + Source PassportElementErrorSource `json:"source"` + Type EncryptedPassportElementType `json:"type"` + FileHash string `json:"file_hash"` + Message string `json:"message"` +} + +// ImplementsPassportElementError us a dummy method which exists to implement the interface PassportElementError. +func (p PassportElementErrorSelfie) ImplementsPassportElementError() {} + +// PassportElementErrorFile represents an issue with the document scan. +// The error is considered resolved when the file with the document scan changes. +// Source MUST BE SourceFile. +// Type MUST BE one of TypePassport, TypeDriverLicense, TypeIdentityCard and TypeIdentityPassport. +type PassportElementErrorFile struct { + Source PassportElementErrorSource `json:"source"` + Type EncryptedPassportElementType `json:"type"` + FileHash string `json:"file_hash"` + Message string `json:"message"` +} + +// ImplementsPassportElementError us a dummy method which exists to implement the interface PassportElementError. +func (p PassportElementErrorFile) ImplementsPassportElementError() {} + +// PassportElementErrorFiles represents an issue with a list of scans. +// The error is considered resolved when the list of files containing the scans changes. +// Source MUST BE SourceFiles. +// Type MUST BE one of TypeUtilityBill, TypeBankStatement, TypeRentalAgreement, TypePassportRegistration and TypeTemporaryRegistration. +type PassportElementErrorFiles struct { + Source PassportElementErrorSource `json:"source"` + Type EncryptedPassportElementType `json:"type"` + Message string `json:"message"` + FileHashes []string `json:"file_hashes"` +} + +// ImplementsPassportElementError us a dummy method which exists to implement the interface PassportElementError. +func (p PassportElementErrorFiles) ImplementsPassportElementError() {} + +// PassportElementErrorTranslationFile represents an issue with one of the files that constitute the translation of the document. +// The error is considered resolved when the file changes. +// Source MUST BE SourceTranslationFile. +// Type MUST BE one of TypePassport, TypeDriverLicense, TypeIdentityCard, TypeInternalPassport, TypeUtilityBill, TypeBankStatement, +// TypeRentalAgreement, TypePassportRegistration and TypeTemporaryRegistration. +type PassportElementErrorTranslationFile struct { + Source PassportElementErrorSource `json:"source"` + Type EncryptedPassportElementType `json:"type"` + FileHash string `json:"file_hash"` + Message string `json:"message"` +} + +// ImplementsPassportElementError us a dummy method which exists to implement the interface PassportElementError. +func (p PassportElementErrorTranslationFile) ImplementsPassportElementError() {} + +// PassportElementErrorTranslationFiles represents an issue with the translated version of a document. +// The error is considered resolved when a file with the document translation changes. +// Source MUST BE SourceTranslationFiles. +// Type MUST BE one of TypePassport, TypeDriverLicense, TypeIdentityCard, TypeInternalPassport, TypeUtilityBill, TypeBankStatement, +// TypeRentalAgreement, TypePassportRegistration and TypeTemporaryRegistration. +type PassportElementErrorTranslationFiles struct { + Source PassportElementErrorSource `json:"source"` + Type EncryptedPassportElementType `json:"type"` + Message string `json:"message"` + FileHashes []string `json:"file_hashes"` +} + +// ImplementsPassportElementError us a dummy method which exists to implement the interface PassportElementError. +func (p PassportElementErrorTranslationFiles) ImplementsPassportElementError() {} + +// PassportElementErrorUnspecified represents an issue in an unspecified place. +// The error is considered resolved when new data is added. +type PassportElementErrorUnspecified struct { + Source PassportElementErrorSource `json:"source"` + Type EncryptedPassportElementType `json:"type"` + ElementHash string `json:"element_hash"` + Message string `json:"message"` +} + +// ImplementsPassportElementError us a dummy method which exists to implement the interface PassportElementError. +func (p PassportElementErrorUnspecified) ImplementsPassportElementError() {} + +// SetPassportDataErrors Informs a user that some of the Telegram Passport elements they provided contains errors. +// The user will not be able to re-submit their Passport to you until the errors are fixed. +// The contents of the field for which you returned the error must change. +func (a API) SetPassportDataErrors(userID int64, errors []PassportElementError) (res APIResponseBool, err error) { + var vals = make(url.Values) + + errorsArr, err := json.Marshal(errors) + if err != nil { + return res, err + } + + vals.Set("user_id", itoa(userID)) + vals.Set("errors", string(errorsArr)) + return res, client.get(a.base, "setPassportDataErrors", vals, &res) +} diff --git a/shared/echotron/passport_test.go b/shared/echotron/passport_test.go new file mode 100644 index 0000000..6a551db --- /dev/null +++ b/shared/echotron/passport_test.go @@ -0,0 +1,48 @@ +package echotron + +import "testing" + +func TestPassportElementErrorDataField(_ *testing.T) { + p := PassportElementErrorDataField{} + p.ImplementsPassportElementError() +} + +func TestPassportElementErrorFrontSide(_ *testing.T) { + p := PassportElementErrorFrontSide{} + p.ImplementsPassportElementError() +} + +func TestPassportElementErrorReverseSide(_ *testing.T) { + p := PassportElementErrorReverseSide{} + p.ImplementsPassportElementError() +} + +func TestPassportElementErrorSelfie(_ *testing.T) { + p := PassportElementErrorSelfie{} + p.ImplementsPassportElementError() +} + +func TestPassportElementErrorFile(_ *testing.T) { + p := PassportElementErrorFile{} + p.ImplementsPassportElementError() +} + +func TestPassportElementErrorFiles(_ *testing.T) { + p := PassportElementErrorFiles{} + p.ImplementsPassportElementError() +} + +func TestPassportElementErrorTranslationFile(_ *testing.T) { + p := PassportElementErrorTranslationFile{} + p.ImplementsPassportElementError() +} + +func TestPassportElementErrorTranslationFiles(_ *testing.T) { + p := PassportElementErrorTranslationFiles{} + p.ImplementsPassportElementError() +} + +func TestPassportElementErrorUnspecified(_ *testing.T) { + p := PassportElementErrorUnspecified{} + p.ImplementsPassportElementError() +} diff --git a/shared/echotron/payments.go b/shared/echotron/payments.go new file mode 100644 index 0000000..a289fab --- /dev/null +++ b/shared/echotron/payments.go @@ -0,0 +1,335 @@ +/* + * Echotron + * Copyright (C) 2018 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +import ( + "encoding/json" + "net/url" +) + +// LabeledPrice represents a portion of the price for goods or services. +type LabeledPrice struct { + Label string `json:"label"` + // Price of the product in the smallest units of the currency (integer, not float/double). + // For example, for a price of US$ 1.45 pass amount = 145. + // See the exp parameter in currencies.json, it shows the number of digits + // past the decimal point for each currency (2 for the majority of currencies). + Amount int `json:"amount"` +} + +// Invoice contains basic information about an invoice. +type Invoice struct { + Title string `json:"title"` + Description string `json:"description"` + StartParameter string `json:"start_parameter"` + // Three-letter ISO 4217 currency code. + Currency string `json:"currency"` + // Total amount in the smallest units of the currency (integer, not float/double). + // For example, for a price of US$ 1.45 pass amount = 145. + // See the exp parameter in currencies.json, it shows the number of digits + // past the decimal point for each currency (2 for the majority of currencies). + TotalAmount int `json:"total_amount"` +} + +// ShippingAddress represents a shipping address. +type ShippingAddress struct { + // ISO 3166-1 alpha-2 country code. + CountryCode string `json:"country_code"` + State string `json:"state"` + City string `json:"city"` + StreetLine1 string `json:"street_line1"` + StreetLine2 string `json:"street_line2"` + PostCode string `json:"post_code"` +} + +// OrderInfo represents information about an order. +type OrderInfo struct { + Name string `json:"name,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` + Email string `json:"email,omitempty"` + ShippingAddress ShippingAddress `json:"shipping_address,omitempty"` +} + +// SuccessfulPayment contains basic information about a successful payment. +type SuccessfulPayment struct { + OrderInfo OrderInfo `json:"order_info"` + Currency string `json:"currency"` + InvoicePayload string `json:"invoice_payload"` + ShippingOptionID string `json:"shipping_option_id"` + TelegramPaymentChargeID string `json:"telegram_payment_charge_id"` + ProviderPaymentChargeID string `json:"provider_payment_charge_id"` + TotalAmount int `json:"total_amount"` + SubscriptionExpirationDate int `json:"subscription_expiration_date,omitempty"` + IsRecurring bool `json:"is_recurring,omitempty"` + IsFirstRecurring bool `json:"is_first_recurring,omitempty"` +} + +// RefundedPayment contains basic information about a refunded payment. +type RefundedPayment struct { + Currency string `json:"currency"` + InvoicePayload string `json:"invoice_payload"` + TelegramPaymentChargeID string `json:"telegram_payment_charge_id"` + ProviderPaymentChargeID string `json:"provider_payment_charge_id,omitempty"` + TotalAmount int `json:"total_amount"` +} + +// ShippingQuery contains information about an incoming shipping query. +type ShippingQuery struct { + ShippingAddress ShippingAddress `json:"shipping_address"` + ID string `json:"id"` + InvoicePayload string `json:"invoice_payload"` + From User `json:"from"` +} + +// PreCheckoutQuery contains information about an incoming pre-checkout query. +type PreCheckoutQuery struct { + OrderInfo OrderInfo `json:"order_info,omitempty"` + Currency string `json:"currency"` + InvoicePayload string `json:"invoice_payload"` + ShippingOptionID string `json:"shipping_option_id,omitempty"` + ID string `json:"id"` + From User `json:"from"` + TotalAmount int `json:"total_amount"` +} + +// PaidMediaPurchased contains information about a paid media purchase. +type PaidMediaPurchased struct { + PaidMediaPayload string `json:"paid_media_payload"` + From User `json:"from"` +} + +// RevenueWithdrawalState describes the state of a revenue withdrawal operation. +type RevenueWithdrawalState interface { + ImplementsRevenueWithdrawalState() +} + +// RevenueWithdrawalStatePending describes the state of a withdrawal in progress. +type RevenueWithdrawalStatePending struct { + Type string `json:"type"` +} + +// ImplementsRevenueWithdrawalState is used to implement the RevenueWithdrawalState interface. +func (r RevenueWithdrawalStatePending) ImplementsRevenueWithdrawalState() {} + +// RevenueWithdrawalStateSucceeded describes the state of a succeeded withdrawal. +type RevenueWithdrawalStateSucceeded struct { + Type string `json:"type"` + URL string `json:"url"` + Date int `json:"date"` +} + +// ImplementsRevenueWithdrawalState is used to implement the RevenueWithdrawalState interface. +func (r RevenueWithdrawalStateSucceeded) ImplementsRevenueWithdrawalState() {} + +// RevenueWithdrawalStateFailed describes the state of a failed withdrawal, in which the transaction was refunded. +type RevenueWithdrawalStateFailed struct { + Type string `json:"type"` +} + +// ImplementsRevenueWithdrawalState is used to implement the RevenueWithdrawalState interface. +func (r RevenueWithdrawalStateFailed) ImplementsRevenueWithdrawalState() {} + +// AffiliateInfo +type AffiliateInfo struct { + AffiliateUser *User `json:"affiliate_user,omitempty"` + AffiliateChat *Chat `json:"affiliate_chat,omitempty"` + CommissionPerMille int `json:"commission_per_mille"` + Amount int `json:"amount"` + NanostarAmount int `json:"nanostar_amount,omitempty"` +} + +// TransactionPartner describes the source of a transaction, or its recipient for outgoing transactions. +type TransactionPartner interface { + ImplementsTransactionPartner() +} + +// TransactionPartnerAffiliateProgram describes the affiliate program that issued the affiliate commission received via this transaction. +// Type MUST be "affiliate_program". +type TransactionPartnerAffiliateProgram struct { + SponsorUser *User `json:"sponsor_user,omitempty"` + Type string `json:"type"` + CommissionPerMille int `json:"commission_per_mille,omitempty"` +} + +// ImplementsTransactionPartner is used to implement the TransactionPartner interface. +func (t TransactionPartnerAffiliateProgram) ImplementsTransactionPartner() {} + +// TransactionPartnerFragment describes a withdrawal transaction with Fragment. +// Type MUST be "fragment". +type TransactionPartnerFragment struct { + WithdrawalState RevenueWithdrawalState `json:"withdrawal_state"` + Type string `json:"type"` +} + +// ImplementsTransactionPartner is used to implement the TransactionPartner interface. +func (t TransactionPartnerFragment) ImplementsTransactionPartner() {} + +// TransactionPartnerUser describes a transaction with a user. +// Type MUST be "user". +type TransactionPartnerUser struct { + PaidMedia *[]PaidMedia `json:"paid_media,omitempty"` + Type string `json:"type"` + InvoicePayload string `json:"invoice_payload,omitempty"` + PaidMediaPayload string `json:"paid_media_payload,omitempty"` + User User `json:"user"` + Affiliate *AffiliateInfo `json:"affiliate,omitempty"` + Gift Gift `json:"gift,omitempty"` + SubscriptionPeriod int `json:"subscription_period,omitempty"` +} + +// TransactionPartnerChat describes a transaction with a chat. +type TransactionPartnerChat struct { + Type string `json:"type"` + Chat Chat `json:"chat"` + Gift Gift `json:"gift,omitempty"` +} + +// ImplementsTransactionPartner is used to implement the TransactionPartner interface. +func (t TransactionPartnerUser) ImplementsTransactionPartner() {} + +// TransactionPartnerTelegramAds describes a withdrawal transaction to the Telegram Ads platform. +// Type MUST be "telegram_ads". +type TransactionPartnerTelegramAds struct { + Type string `json:"type"` +} + +// ImplementsTransactionPartner is used to implement the TransactionPartner interface. +func (t TransactionPartnerTelegramAds) ImplementsTransactionPartner() {} + +// TransactionPartnerTelegramApi describes a transaction with payment for paid broadcasting. +// Type MUST be "telegram_api". +type TransactionPartnerTelegramApi struct { + Type string `json:"type"` + RequestCount int `json:"request_count"` +} + +// ImplementsTransactionPartner is used to implement the TransactionPartner interface. +func (t TransactionPartnerTelegramApi) ImplementsTransactionPartner() {} + +// TransactionPartnerOther describes a transaction with an unknown source or recipient. +// Type MUST be "other". +type TransactionPartnerOther struct { + Type string `json:"type"` +} + +// ImplementsTransactionPartner is used to implement the TransactionPartner interface. +func (t TransactionPartnerOther) ImplementsTransactionPartner() {} + +// StarTransaction describes a Telegram Star transaction. +type StarTransaction struct { + Source TransactionPartner `json:"source"` + Receiver TransactionPartner `json:"receiver"` + ID string `json:"id"` + Amount int `json:"amount"` + NanostarAmount int `json:"nanostar_amount,omitempty"` + Date int `json:"date"` +} + +// StarTransactions contains a list of Telegram Star transactions. +type StarTransactions struct { + Transaction []StarTransaction `json:"transaction"` +} + +// StarTransactionsOptions contains the optional parameters used by the GetStarTransactions method. +type StarTransactionsOptions struct { + Offset int `query:"offset"` + Limit int `query:"limit"` +} + +// SendInvoice is used to send invoices. +func (a API) SendInvoice(chatID int64, title, description, payload, currency string, prices []LabeledPrice, opts *InvoiceOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + p, err := json.Marshal(prices) + if err != nil { + return res, err + } + + vals.Set("chat_id", itoa(chatID)) + vals.Set("title", title) + vals.Set("description", description) + vals.Set("payload", payload) + vals.Set("currency", currency) + vals.Set("prices", string(p)) + return res, client.get(a.base, "sendInvoice", addValues(vals, opts), &res) +} + +// CreateInvoiceLink creates a link for an invoice. +func (a API) CreateInvoiceLink(title, description, payload, currency string, prices []LabeledPrice, opts *CreateInvoiceLinkOptions) (res APIResponseBase, err error) { + var vals = make(url.Values) + + p, err := json.Marshal(prices) + if err != nil { + return res, err + } + + vals.Set("title", title) + vals.Set("description", description) + vals.Set("payload", payload) + vals.Set("currency", currency) + vals.Set("prices", string(p)) + return res, client.get(a.base, "createInvoiceLink", addValues(vals, opts), &res) +} + +// AnswerShippingQuery is used to reply to shipping queries. +// If you sent an invoice requesting a shipping address and the parameter is_flexible was specified, +// the Bot API will send an Update with a shipping_query field to the bot. +func (a API) AnswerShippingQuery(shippingQueryID string, ok bool, opts *ShippingQueryOptions) (res APIResponseBase, err error) { + var vals = make(url.Values) + + vals.Set("shipping_query_id", shippingQueryID) + vals.Set("ok", btoa(ok)) + return res, client.get(a.base, "answerShippingQuery", addValues(vals, opts), &res) +} + +// AnswerPreCheckoutQuery is used to respond to such pre-checkout queries. +// Once the user has confirmed their payment and shipping details, +// the Bot API sends the final confirmation in the form of an Update with the field pre_checkout_query. +// NOTE: The Bot API must receive an answer within 10 seconds after the pre-checkout query was sent. +func (a API) AnswerPreCheckoutQuery(preCheckoutQueryID string, ok bool, opts *PreCheckoutOptions) (res APIResponseBase, err error) { + var vals = make(url.Values) + + vals.Set("pre_checkout_query_id", preCheckoutQueryID) + vals.Set("ok", btoa(ok)) + return res, client.get(a.base, "answerPreCheckoutQuery", addValues(vals, opts), &res) +} + +// GetStarTransactions returns the bot's Telegram Star transactions in chronological order. +func (a API) GetStarTransactions(opts *StarTransactionsOptions) (res APIResponseStarTransactions, err error) { + return res, client.get(a.base, "getStarTransactions", urlValues(opts), &res) +} + +// RefundStarPayment refunds a successful payment in Telegram Stars. +func (a API) RefundStarPayment(userID int64, telegramPaymentChargeID string) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("user_id", itoa(userID)) + vals.Set("telegram_payment_charge_id", telegramPaymentChargeID) + return res, client.get(a.base, "refundStarPayment", vals, &res) +} + +// EditUserStarSubscription allows the bot to cancel or re-enable extension of a subscription paid in Telegram Stars. +func (a API) EditUserStarSubscription(userID int64, telegramPaymentChargeID string, isCanceled bool) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("user_id", itoa(userID)) + vals.Set("telegram_payment_charge_id", telegramPaymentChargeID) + vals.Set("is_canceled", btoa(isCanceled)) + return res, client.get(a.base, "editUserStarSubscription", vals, &res) +} diff --git a/shared/echotron/payments_test.go b/shared/echotron/payments_test.go new file mode 100644 index 0000000..d8c9ba3 --- /dev/null +++ b/shared/echotron/payments_test.go @@ -0,0 +1,87 @@ +package echotron + +import "testing" + +func TestRevenueWithdrawalStatePending(t *testing.T) { + r := RevenueWithdrawalStatePending{} + r.ImplementsRevenueWithdrawalState() +} + +func TestRevenueWithdrawalStateSucceeded(t *testing.T) { + r := RevenueWithdrawalStateSucceeded{} + r.ImplementsRevenueWithdrawalState() +} + +func TestRevenueWithdrawalStateFailed(t *testing.T) { + r := RevenueWithdrawalStateFailed{} + r.ImplementsRevenueWithdrawalState() +} + +func TestTransactionPartnerFragment(t *testing.T) { + r := TransactionPartnerFragment{} + r.ImplementsTransactionPartner() +} + +func TestTransactionPartnerUser(t *testing.T) { + r := TransactionPartnerUser{} + r.ImplementsTransactionPartner() +} +func TestTransactionPartnerTelegramAds(t *testing.T) { + r := TransactionPartnerTelegramAds{} + r.ImplementsTransactionPartner() +} +func TestTransactionPartnerOther(t *testing.T) { + r := TransactionPartnerOther{} + r.ImplementsTransactionPartner() +} + +func TestSendInvoice(t *testing.T) { + _, err := api.SendInvoice( + chatID, + "TestSendInvoice", + "TestSendInvoiceDesc", + "echotron_test", + "XTR", + []LabeledPrice{ + { + Label: "Test", + Amount: 1, + }, + }, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestCreateInvoiceLink(t *testing.T) { + _, err := api.CreateInvoiceLink( + "TestCreateInvoiceLink", + "TestCreateInvoiceLinkDesc", + "echotron_test", + "XTR", + []LabeledPrice{ + { + Label: "Test", + Amount: 1, + }, + }, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestGetStarTransactions(t *testing.T) { + _, err := api.GetStarTransactions( + nil, + ) + + if err != nil { + t.Fatal(err) + } +} diff --git a/shared/echotron/querybuilder.go b/shared/echotron/querybuilder.go new file mode 100644 index 0000000..64ee0a1 --- /dev/null +++ b/shared/echotron/querybuilder.go @@ -0,0 +1,88 @@ +/* + * Echotron + * Copyright (C) 2018 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +import ( + "encoding/json" + "net/url" + "reflect" + "strconv" +) + +func toString(v reflect.Value) string { + switch v.Kind() { + case reflect.String: + return v.String() + + case reflect.Float64: + return strconv.FormatFloat(v.Float(), 'f', -1, 64) + + case reflect.Int, reflect.Int64: + return strconv.FormatInt(v.Int(), 10) + + case reflect.Bool: + return strconv.FormatBool(v.Bool()) + + case reflect.Struct, reflect.Interface, reflect.Slice, reflect.Array: + b, _ := json.Marshal(v.Interface()) + return string(b) + + default: + return "" + } +} + +func scan(i any, v url.Values) url.Values { + e := reflect.ValueOf(i) + + if e.Kind() == reflect.Pointer { + e = e.Elem() + } + + if e.Kind() == reflect.Invalid { + return v + } + + for i := 0; i < e.NumField(); i++ { + fTag := e.Type().Field(i).Tag + + if name := fTag.Get("query"); name != "" && !e.Field(i).IsZero() { + v.Set(name, toString(e.Field(i))) + } + } + + return v +} + +func urlValues(i any) url.Values { + if i == nil { + return nil + } + return scan(i, url.Values{}) +} + +func addValues(vals url.Values, i any) url.Values { + if i == nil { + return vals + } + if vals == nil { + vals = make(url.Values) + } + return scan(i, vals) +} diff --git a/shared/echotron/querybuilder_test.go b/shared/echotron/querybuilder_test.go new file mode 100644 index 0000000..2eab672 --- /dev/null +++ b/shared/echotron/querybuilder_test.go @@ -0,0 +1,72 @@ +package echotron + +import ( + "net/url" + "reflect" + "testing" +) + +type scanTest struct { + i any + predefined url.Values + expected url.Values +} + +func TestScan(t *testing.T) { + tests := []scanTest{ + { + i: CommandOptions{ + LanguageCode: "it", + Scope: BotCommandScope{Type: BCSTChat, ChatID: 33288}, + }, + predefined: url.Values{"foo": {"bar"}}, + expected: url.Values{ + "foo": {"bar"}, + "language_code": {"it"}, + "scope": {`{"type":"chat","chat_id":33288,"user_id":0}`}, + }, + }, + } + + for i, tt := range tests { + result := scan(tt.i, tt.predefined) + if !reflect.DeepEqual(tt.expected, result) { + t.Fatalf("test #%d: result differs from expected value\n", i) + } + } +} + +func TestToStringDefault(t *testing.T) { + ret := toString(reflect.ValueOf(nil)) + + if ret != "" { + t.Fatalf("expected empty string, got %+v", ret) + } +} + +func TestUrlValues(t *testing.T) { + ret := urlValues(nil) + + if ret != nil { + t.Fatalf("expected nil, got %+v", ret) + } +} + +func TestAddValues(t *testing.T) { + vals := url.Values{} + ret := addValues(vals, nil) + + if !reflect.DeepEqual(vals, ret) { + t.Fatalf("expected nil, got %+v", ret) + } +} + +func TestAddValuesNil(t *testing.T) { + opts := MessageOptions{ParseMode: MarkdownV2} + vals := urlValues(opts) + ret := addValues(nil, opts) + + if !reflect.DeepEqual(vals, ret) { + t.Fatalf("expected %+v, got %+v", vals, ret) + } +} diff --git a/shared/echotron/simpledsp.go b/shared/echotron/simpledsp.go new file mode 100644 index 0000000..29b5440 --- /dev/null +++ b/shared/echotron/simpledsp.go @@ -0,0 +1,137 @@ +/* + * Echotron + * Copyright (C) 2023 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "time" +) + +// PollingUpdates is a wrapper function for PollingUpdatesOptions. +func PollingUpdates(token string) <-chan *Update { + return PollingUpdatesOptions(token, true, UpdateOptions{Timeout: 120}) +} + +// PollingUpdatesOptions returns a read-only channel of incoming updates from the Telegram API. +func PollingUpdatesOptions(token string, dropPendingUpdates bool, opts UpdateOptions) <-chan *Update { + var updates = make(chan *Update) + + go func() { + defer close(updates) + + var ( + api = NewAPI(token) + timeout = opts.Timeout + isFirstRun = true + ) + + // deletes webhook if present to run in long polling mode + if _, err := api.DeleteWebhook(dropPendingUpdates); err != nil { + log.Println("echotron.PollingUpdates", err) + } + + for { + if isFirstRun { + opts.Timeout = 0 + } + + response, err := api.GetUpdates(&opts) + if err != nil { + log.Println("echotron.PollingUpdates", err) + time.Sleep(5 * time.Second) + continue + } + + if !dropPendingUpdates || !isFirstRun { + for _, u := range response.Result { + updates <- u + } + } + + if l := len(response.Result); l > 0 { + opts.Offset = response.Result[l-1].ID + 1 + } + + if isFirstRun { + isFirstRun = false + opts.Timeout = timeout + } + } + }() + + return updates +} + +// WebhookUpdates is a wrapper function for WebhookUpdatesOptions. +func WebhookUpdates(url, token string) <-chan *Update { + return WebhookUpdatesOptions(url, token, false, nil) +} + +// WebhookUpdatesOptions returns a read-only channel of incoming updates from the Telegram API. +// The webhookUrl should be provided in the following format: ':/', +// eg: 'https://example.com:443/bot_token'. +// WebhookUpdatesOptions will then proceed to communicate the webhook url '/' +// to Telegram and run a webserver that listens to ':' and handles the path. +func WebhookUpdatesOptions(whURL, token string, dropPendingUpdates bool, opts *WebhookOptions) <-chan *Update { + u, err := url.Parse(whURL) + if err != nil { + panic(err) + } + + wURL := u.Hostname() + u.EscapedPath() + api := NewAPI(token) + if _, err := api.SetWebhook(wURL, dropPendingUpdates, opts); err != nil { + panic(err) + } + + var updates = make(chan *Update) + http.HandleFunc(u.EscapedPath(), func(w http.ResponseWriter, r *http.Request) { + var update Update + + jsn, err := readRequest(r) + if err != nil { + log.Println("echotron.WebhookUpdates", err) + return + } + + if err := json.Unmarshal(jsn, &update); err != nil { + log.Println("echotron.WebhookUpdates", err) + return + } + + updates <- &update + }) + + go func() { + defer close(updates) + port := fmt.Sprintf(":%s", u.Port()) + for { + if err := http.ListenAndServe(port, nil); err != nil { + log.Println("echotron.WebhookUpdates", err) + time.Sleep(5 * time.Second) + } + } + }() + + return updates +} diff --git a/shared/echotron/simpledsp_test.go b/shared/echotron/simpledsp_test.go new file mode 100644 index 0000000..b83ccec --- /dev/null +++ b/shared/echotron/simpledsp_test.go @@ -0,0 +1,7 @@ +package echotron + +import "testing" + +func TestPollingUpdates(t *testing.T) { + PollingUpdates(api.token) +} diff --git a/shared/echotron/stickers.go b/shared/echotron/stickers.go new file mode 100644 index 0000000..241edcc --- /dev/null +++ b/shared/echotron/stickers.go @@ -0,0 +1,271 @@ +/* + * Echotron + * Copyright (C) 2018 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +import ( + "encoding/json" + "net/url" +) + +// Sticker represents a sticker. +type Sticker struct { + Thumbnail *PhotoSize `json:"thumbnail,omitempty"` + MaskPosition *MaskPosition `json:"mask_position,omitempty"` + Type StickerSetType `json:"type"` + FileUniqueID string `json:"file_unique_id"` + SetName string `json:"set_name,omitempty"` + FileID string `json:"file_id"` + Emoji string `json:"emoji,omitempty"` + CustomEmojiID string `json:"custom_emoji_id,omitempty"` + PremiumAnimation File `json:"premium_animation,omitempty"` + FileSize int `json:"file_size,omitempty"` + Width int `json:"width"` + Height int `json:"height"` + IsVideo bool `json:"is_video"` + IsAnimated bool `json:"is_animated"` + NeedsRepainting bool `json:"needs_repainting,omitempty"` +} + +// StickerSet represents a sticker set. +type StickerSet struct { + Thumbnail *PhotoSize `json:"thumbnail,omitempty"` + Title string `json:"title"` + Name string `json:"name"` + StickerType StickerSetType `json:"sticker_type"` + Stickers []Sticker `json:"stickers"` +} + +// StickerSetType represents the type of a sticker or of the entire set +type StickerSetType string + +const ( + RegularStickerSet StickerSetType = "regular" + MaskStickerSet = "mask" + CustomEmojiStickerSet = "custom_emoji" +) + +// StickerFormat is a custom type for the various sticker formats. +type StickerFormat string + +// These are all the possible sticker formats. +const ( + StaticFormat StickerFormat = "static" + AnimatedFormat = "animated" + VideoFormat = "video" +) + +// MaskPosition describes the position on faces where a mask should be placed by default. +type MaskPosition struct { + Point MaskPoint `json:"point"` + XShift float32 `json:"x_shift"` + YShift float32 `json:"y_shift"` + Scale float32 `json:"scale"` +} + +// MaskPoint is a custom type for the various part of face where a mask should be placed. +type MaskPoint string + +// These are all the possible parts of the face for a mask. +const ( + ForeheadPoint MaskPoint = "forehead" + EyesPoint = "eyes" + MouthPoint = "mouth" + ChinPoint = "chin" +) + +// NewStickerSetOptions contains the optional parameters used in the CreateNewStickerSet method. +type NewStickerSetOptions struct { + StickerType StickerSetType `query:"sticker_type"` + NeedsRepainting bool `query:"needs_repainting"` +} + +// InputSticker is a struct which describes a sticker to be added to a sticker set. +type InputSticker struct { + MaskPosition *MaskPosition `json:"mask_position,omitempty"` + Keywords *[]string `json:"keywords,omitempty"` + Format StickerFormat `json:"format"` + Sticker InputFile `json:"-"` + EmojiList []string `json:"emoji_list"` +} + +// stickerEnvelope is a generic struct for all the various structs under the InputSticker interface. +type stickerEnvelope struct { + Sticker string `json:"sticker"` + InputSticker +} + +// SendSticker is used to send static .WEBP or animated .TGS stickers. +func (a API) SendSticker(stickerID string, chatID int64, opts *StickerOptions) (res APIResponseMessage, err error) { + var vals = make(url.Values) + + vals.Set("sticker", stickerID) + vals.Set("chat_id", itoa(chatID)) + return res, client.get(a.base, "sendSticker", addValues(vals, opts), &res) +} + +// GetStickerSet is used to get a sticker set. +func (a API) GetStickerSet(name string) (res APIResponseStickerSet, err error) { + var vals = make(url.Values) + + vals.Set("name", name) + return res, client.get(a.base, "getStickerSet", vals, &res) +} + +// GetCustomEmojiStickers is used to get information about custom emoji stickers by their identifiers. +func (a API) GetCustomEmojiStickers(customEmojiIDs ...string) (res APIResponseStickers, err error) { + var vals = make(url.Values) + + jsn, _ := json.Marshal(customEmojiIDs) + vals.Set("custom_emoji_ids", string(jsn)) + return res, client.get(a.base, "getCustomEmojiStickers", vals, &res) +} + +// UploadStickerFile is used to upload a .PNG file with a sticker for later use in +// CreateNewStickerSet and AddStickerToSet methods (can be used multiple times). +func (a API) UploadStickerFile(userID int64, sticker InputFile, format StickerFormat) (res APIResponseFile, err error) { + var vals = make(url.Values) + + vals.Set("user_id", itoa(userID)) + vals.Set("sticker_format", string(format)) + return res, client.postFile(a.base, "uploadStickerFile", "sticker", sticker, InputFile{}, vals, &res) +} + +// CreateNewStickerSet is used to create a new sticker set owned by a user. +func (a API) CreateNewStickerSet(userID int64, name, title string, stickers []InputSticker, opts *NewStickerSetOptions) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("user_id", itoa(userID)) + vals.Set("name", name) + vals.Set("title", title) + return res, client.postStickers(a.base, "createNewStickerSet", addValues(vals, opts), &res, stickers...) +} + +// AddStickerToSet is used to add a new sticker to a set created by the bot. +func (a API) AddStickerToSet(userID int64, name string, sticker InputSticker) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("user_id", itoa(userID)) + vals.Set("name", name) + return res, client.postStickers(a.base, "addStickerToSet", vals, &res, sticker) +} + +// SetStickerPositionInSet is used to move a sticker in a set created by the bot to a specific position. +func (a API) SetStickerPositionInSet(sticker string, position int) (res APIResponseBase, err error) { + var vals = make(url.Values) + + vals.Set("sticker", sticker) + vals.Set("position", itoa(int64(position))) + return res, client.get(a.base, "setStickerPositionInSet", vals, &res) +} + +// DeleteStickerFromSet is used to delete a sticker from a set created by the bot. +func (a API) DeleteStickerFromSet(sticker string) (res APIResponseBase, err error) { + var vals = make(url.Values) + + vals.Set("sticker", sticker) + return res, client.get(a.base, "deleteStickerFromSet", vals, &res) +} + +// ReplaceStickerInSet is used to replace an existing sticker in a sticker set with a new one. +// The method is equivalent to calling DeleteStickerFromSet, then AddStickerToSet, then SetStickerPositionInSet. +func (a API) ReplaceStickerInSet(userID int64, name string, old_sticker string, sticker InputSticker) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("user_id", itoa(userID)) + vals.Set("name", name) + vals.Set("old_sticker", old_sticker) + return res, client.postStickers(a.base, "replaceStickerInSet", vals, &res, sticker) +} + +// SetStickerEmojiList is used to change the list of emoji assigned to a regular or custom emoji sticker. +// The sticker must belong to a sticker set created by the bot. +func (a API) SetStickerEmojiList(sticker string, emojis []string) (res APIResponseBool, err error) { + var vals = make(url.Values) + + jsn, _ := json.Marshal(emojis) + + vals.Set("sticker", sticker) + vals.Set("emoji_list", string(jsn)) + return res, client.get(a.base, "setStickerEmojiList", vals, &res) +} + +// SetStickerKeywords is used to change search keywords assigned to a regular or custom emoji sticker. +// The sticker must belong to a sticker set created by the bot. +func (a API) SetStickerKeywords(sticker string, keywords []string) (res APIResponseBool, err error) { + var vals = make(url.Values) + + jsn, _ := json.Marshal(keywords) + + vals.Set("sticker", sticker) + vals.Set("keywords", string(jsn)) + return res, client.get(a.base, "setStickerKeywords", vals, &res) +} + +// SetStickerMaskPosition is used to change the mask position of a mask sticker. +// The sticker must belong to a sticker set that was created by the bot. +func (a API) SetStickerMaskPosition(sticker string, mask MaskPosition) (res APIResponseBool, err error) { + var vals = make(url.Values) + + jsn, _ := json.Marshal(mask) + + vals.Set("sticker", sticker) + vals.Set("mask_position", string(jsn)) + return res, client.get(a.base, "setStickerMaskPosition", vals, &res) +} + +// SetStickerSetTitle is used to set the title of a created sticker set. +func (a API) SetStickerSetTitle(name, title string) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("name", name) + vals.Set("title", title) + return res, client.get(a.base, "setStickerSetTitle", vals, &res) +} + +// SetStickerSetThumbnail is used to set the thumbnail of a sticker set. +func (a API) SetStickerSetThumbnail(name string, userID int64, thumbnail InputFile, format StickerFormat) (res APIResponseBase, err error) { + var vals = make(url.Values) + + vals.Set("name", name) + vals.Set("user_id", itoa(userID)) + vals.Set("format", string(format)) + return res, client.postFile(a.base, "setStickerSetThumbnail", "thumbnail", thumbnail, InputFile{}, vals, &res) +} + +// SetCustomEmojiStickerSetThumbnail is used to set the thumbnail of a custom emoji sticker set. +func (a API) SetCustomEmojiStickerSetThumbnail(name, emojiID string) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("name", name) + vals.Set("custom_emoji_id", emojiID) + return res, client.get(a.base, "setCustomEmojiStickerSetThumbnail", vals, &res) +} + +// DeleteStickerSet is used to delete a sticker set that was created by the bot. +func (a API) DeleteStickerSet(name string) (res APIResponseBool, err error) { + var vals = make(url.Values) + + vals.Set("name", name) + return res, client.get(a.base, "DeleteStickerSet", vals, &res) +} + +// GetForumTopicIconStickers is used to get custom emoji stickers, which can be used as a forum topic icon by any user. +func (a API) GetForumTopicIconStickers() (res APIResponseStickers, err error) { + return res, client.get(a.base, "getForumTopicIconStickers", nil, &res) +} diff --git a/shared/echotron/stickers_test.go b/shared/echotron/stickers_test.go new file mode 100644 index 0000000..84c649c --- /dev/null +++ b/shared/echotron/stickers_test.go @@ -0,0 +1,229 @@ +/* + * Echotron + * Copyright (C) 2018 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +import ( + "fmt" + "testing" + "time" +) + +var ( + stickerFile *File + stickerSet *StickerSet + stickerSetName = fmt.Sprintf("set%d_by_echotron_coverage_bot", time.Now().Unix()) +) + +func TestUploadStickerFile(t *testing.T) { + resp, err := api.UploadStickerFile( + chatID, + NewInputFilePath("assets/tests/echotron_test.png"), + StaticFormat, + ) + + if err != nil { + t.Fatal(err) + } + + stickerFile = resp.Result +} + +func TestCreateNewStickerSet(t *testing.T) { + _, err := api.CreateNewStickerSet( + chatID, + stickerSetName, + "Echotron Coverage Pack", + []InputSticker{ + { + Sticker: NewInputFileID(stickerFile.FileID), + EmojiList: []string{"🤖"}, + Format: StaticFormat, + }, + { + Sticker: NewInputFilePath("assets/tests/echotron_test.png"), + EmojiList: []string{"🤖"}, + Format: StaticFormat, + }, + { + Sticker: NewInputFileURL(photoURL), + EmojiList: []string{"🤖"}, + Format: StaticFormat, + }, + }, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestAddStickerToSet(t *testing.T) { + _, err := api.AddStickerToSet( + chatID, + stickerSetName, + InputSticker{ + Sticker: NewInputFilePath("assets/tests/echotron_sticker.png"), + EmojiList: []string{"🤖"}, + Format: StaticFormat, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestGetCustomEmojiStickers(t *testing.T) { + _, err := api.GetCustomEmojiStickers( + "5407041870620531251", + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestGetStickerSet(t *testing.T) { + resp, err := api.GetStickerSet( + stickerSetName, + ) + + if err != nil { + t.Fatal(err) + } + + stickerSet = resp.Result +} + +func TestSetStickerPositionInSet(t *testing.T) { + _, err := api.SetStickerPositionInSet( + stickerSet.Stickers[1].FileID, + 0, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSetStickerEmojiList(t *testing.T) { + _, err := api.SetStickerEmojiList( + stickerSet.Stickers[0].FileID, + []string{"🤖", "👾"}, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSetStickerKeywords(t *testing.T) { + _, err := api.SetStickerKeywords( + stickerSet.Stickers[0].FileID, + []string{"echotron"}, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSetStickerSetTitle(t *testing.T) { + _, err := api.SetStickerSetTitle( + stickerSetName, + fmt.Sprintf("new_%s", stickerSetName), + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestReplaceStickerInSet(t *testing.T) { + _, err := api.ReplaceStickerInSet( + chatID, + stickerSetName, + stickerSet.Stickers[0].FileID, + InputSticker{ + Sticker: NewInputFileURL(photoURL), + EmojiList: []string{"🤖"}, + Format: StaticFormat, + }, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestDeleteStickerFromSet(t *testing.T) { + _, err := api.DeleteStickerFromSet( + stickerSet.Stickers[1].FileID, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSendSticker(t *testing.T) { + _, err := api.SendSticker( + stickerSet.Stickers[0].FileID, + chatID, + nil, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestSetStickerSetThumbnail(t *testing.T) { + _, err := api.SetStickerSetThumbnail( + stickerSetName, + chatID, + NewInputFilePath("assets/tests/echotron_thumb.png"), + StaticFormat, + ) + + if err != nil { + t.Fatal(err) + } +} + +func TestDeleteStickerSet(t *testing.T) { + _, err := api.DeleteStickerSet(stickerSetName) + + if err != nil { + t.Fatal(err) + } +} + +func TestGetForumTopicIconStickers(t *testing.T) { + res, err := api.GetForumTopicIconStickers() + + if err != nil { + t.Fatal(err) + } + + if len(res.Result) == 0 { + t.Fatal("error: Telegram returned no forum topic icon stickers") + } +} diff --git a/shared/echotron/types.go b/shared/echotron/types.go new file mode 100644 index 0000000..4ff4ced --- /dev/null +++ b/shared/echotron/types.go @@ -0,0 +1,1986 @@ +/* + * Echotron + * Copyright (C) 2018 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +import "encoding/json" + +// Update represents an incoming update. +// At most one of the optional parameters can be present in any given update. +type Update struct { + ChatJoinRequest *ChatJoinRequest `json:"chat_join_request,omitempty"` + ChatBoost *ChatBoostUpdated `json:"chat_boost,omitempty"` + RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"` + Message *Message `json:"message,omitempty"` + EditedMessage *Message `json:"edited_message,omitempty"` + ChannelPost *Message `json:"channel_post,omitempty"` + EditedChannelPost *Message `json:"edited_channel_post,omitempty"` + BusinessConnection *BusinessConnection `json:"business_connection,omitempty"` + BusinessMessage *Message `json:"business_message,omitempty"` + EditedBusinessMessage *Message `json:"edited_business_message,omitempty"` + DeletedBusinessMessages *BusinessMessagesDeleted `json:"deleted_business_messages,omitempty"` + MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"` + MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"` + InlineQuery *InlineQuery `json:"inline_query,omitempty"` + ChosenInlineResult *ChosenInlineResult `json:"chosen_inline_result,omitempty"` + CallbackQuery *CallbackQuery `json:"callback_query,omitempty"` + ShippingQuery *ShippingQuery `json:"shipping_query,omitempty"` + PreCheckoutQuery *PreCheckoutQuery `json:"pre_checkout_query,omitempty"` + Poll *Poll `json:"poll,omitempty"` + PollAnswer *PollAnswer `json:"poll_answer,omitempty"` + MyChatMember *ChatMemberUpdated `json:"my_chat_member,omitempty"` + ChatMember *ChatMemberUpdated `json:"chat_member,omitempty"` + PurchasedPaidMedia *PaidMediaPurchased `json:"purchased_paid_media,omitempty"` + ID int `json:"update_id"` +} + +// ChatID returns the ID of the chat the update is coming from. +func (u Update) ChatID() int64 { + switch { + case u.ChatJoinRequest != nil: + return u.ChatJoinRequest.Chat.ID + case u.ChatBoost != nil: + return u.ChatBoost.Chat.ID + case u.RemovedChatBoost != nil: + return u.RemovedChatBoost.Chat.ID + case u.Message != nil: + return u.Message.Chat.ID + case u.EditedMessage != nil: + return u.EditedMessage.Chat.ID + case u.ChannelPost != nil: + return u.ChannelPost.Chat.ID + case u.EditedChannelPost != nil: + return u.EditedChannelPost.Chat.ID + case u.BusinessConnection != nil: + return u.BusinessConnection.User.ID + case u.BusinessMessage != nil: + return u.BusinessMessage.Chat.ID + case u.EditedBusinessMessage != nil: + return u.EditedBusinessMessage.Chat.ID + case u.DeletedBusinessMessages != nil: + return u.DeletedBusinessMessages.Chat.ID + case u.MessageReaction != nil: + return u.MessageReaction.Chat.ID + case u.MessageReactionCount != nil: + return u.MessageReactionCount.Chat.ID + case u.InlineQuery != nil: + return u.InlineQuery.From.ID + case u.ChosenInlineResult != nil: + return u.ChosenInlineResult.From.ID + case u.CallbackQuery != nil: + return u.CallbackQuery.Message.Chat.ID + case u.ShippingQuery != nil: + return u.ShippingQuery.From.ID + case u.PreCheckoutQuery != nil: + return u.PreCheckoutQuery.From.ID + case u.PollAnswer != nil: + return u.PollAnswer.User.ID + case u.MyChatMember != nil: + return u.MyChatMember.Chat.ID + case u.ChatMember != nil: + return u.ChatMember.Chat.ID + default: + return 0 + } +} + +// WebhookInfo contains information about the current status of a webhook. +type WebhookInfo struct { + URL string `json:"url"` + IPAddress string `json:"ip_address,omitempty"` + LastErrorMessage string `json:"last_error_message,omitempty"` + AllowedUpdates []*UpdateType `json:"allowed_updates,omitempty"` + MaxConnections int `json:"max_connections,omitempty"` + LastErrorDate int64 `json:"last_error_date,omitempty"` + LastSynchronizationErrorDate int64 `json:"last_synchronization_error_date,omitempty"` + PendingUpdateCount int `json:"pending_update_count"` + HasCustomCertificate bool `json:"has_custom_certificate"` +} + +// APIResponse is implemented by all the APIResponse* types. +type APIResponse interface { + // Base returns the object of type APIResponseBase contained in each implemented type. + Base() APIResponseBase +} + +// APIResponseBase is a base type that represents the incoming response from Telegram servers. +// Used by APIResponse* to slim down the implementation. +type APIResponseBase struct { + Description string `json:"description,omitempty"` + ErrorCode int `json:"error_code,omitempty"` + Ok bool `json:"ok"` +} + +// Base returns the APIResponseBase itself. +func (a APIResponseBase) Base() APIResponseBase { + return a +} + +// APIResponseUpdate represents the incoming response from Telegram servers. +// Used by all methods that return an array of Update objects on success. +type APIResponseUpdate struct { + Result []*Update `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseUpdate) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseUser represents the incoming response from Telegram servers. +// Used by all methods that return a User object on success. +type APIResponseUser struct { + Result *User `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseUser) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseMessage represents the incoming response from Telegram servers. +// Used by all methods that return a Message object on success. +type APIResponseMessage struct { + Result *Message `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseMessage) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseMessageArray represents the incoming response from Telegram servers. +// Used by all methods that return an array of Message objects on success. +type APIResponseMessageArray struct { + Result []*Message `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseMessageArray) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseMessageID represents the incoming response from Telegram servers. +// Used by all methods that return a MessageID object on success. +type APIResponseMessageID struct { + Result *MessageID `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseMessageID) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseMessageIDs represents the incoming response from Telegram servers. +// Used by all methods that return a MessageID object on success. +type APIResponseMessageIDs struct { + Result []*MessageID `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseMessageIDs) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseCommands represents the incoming response from Telegram servers. +// Used by all methods that return an array of BotCommand objects on success. +type APIResponseCommands struct { + Result []*BotCommand `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseCommands) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseBool represents the incoming response from Telegram servers. +// Used by all methods that return True on success. +type APIResponseBool struct { + APIResponseBase + Result bool `json:"result,omitempty"` +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseBool) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseString represents the incoming response from Telegram servers. +// Used by all methods that return a string on success. +type APIResponseString struct { + Result string `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseString) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseChat represents the incoming response from Telegram servers. +// Used by all methods that return a ChatFullInfo object on success. +type APIResponseChat struct { + Result *ChatFullInfo `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseChat) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseInviteLink represents the incoming response from Telegram servers. +// Used by all methods that return a ChatInviteLink object on success. +type APIResponseInviteLink struct { + Result *ChatInviteLink `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseInviteLink) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseStickers represents the incoming response from Telegram servers. +// Used by all methods that return an array of Stickers on success. +type APIResponseStickers struct { + Result []*Sticker `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseStickers) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseStickerSet represents the incoming response from Telegram servers. +// Used by all methods that return a StickerSet object on success. +type APIResponseStickerSet struct { + Result *StickerSet `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseStickerSet) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseUserProfile represents the incoming response from Telegram servers. +// Used by all methods that return a UserProfilePhotos object on success. +type APIResponseUserProfile struct { + Result *UserProfilePhotos `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseUserProfile) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseUserProfileAudios represents the incoming response from Telegram servers. +// Used by all methods that return a UserProfileAudios object on success. +type APIResponseUserProfileAudios struct { + Result *UserProfileAudios `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseUserProfileAudios) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseFile represents the incoming response from Telegram servers. +// Used by all methods that return a File object on success. +type APIResponseFile struct { + Result *File `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseFile) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseAdministrators represents the incoming response from Telegram servers. +// Used by all methods that return an array of ChatMember objects on success. +type APIResponseAdministrators struct { + Result []*ChatMember `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseAdministrators) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseChatMember represents the incoming response from Telegram servers. +// Used by all methods that return a ChatMember object on success. +type APIResponseChatMember struct { + Result *ChatMember `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseChatMember) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseInteger represents the incoming response from Telegram servers. +// Used by all methods that return an integer on success. +type APIResponseInteger struct { + APIResponseBase + Result int `json:"result,omitempty"` +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseInteger) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponsePoll represents the incoming response from Telegram servers. +// Used by all methods that return a Poll object on success. +type APIResponsePoll struct { + Result *Poll `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponsePoll) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseGameHighScore represents the incoming response from Telegram servers. +// Used by all methods that return an array of GameHighScore objects on success. +type APIResponseGameHighScore struct { + Result []*GameHighScore `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseGameHighScore) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseWebhook represents the incoming response from Telegram servers. +// Used by all methods that return a WebhookInfo object on success. +type APIResponseWebhook struct { + Result *WebhookInfo `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseWebhook) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseSentWebAppMessage represents the incoming response from Telegram servers. +// Used by all methods that return a SentWebAppMessage object on success. +type APIResponseSentWebAppMessage struct { + Result *SentWebAppMessage `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseSentWebAppMessage) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseMenuButton represents the incoming response from Telegram servers. +// Used by all methods that return a MenuButton object on success. +type APIResponseMenuButton struct { + Result *MenuButton `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseMenuButton) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseChatAdministratorRights represents the incoming response from Telegram servers. +// Used by all methods that return a ChatAdministratorRights object on success. +type APIResponseChatAdministratorRights struct { + Result *ChatAdministratorRights `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseChatAdministratorRights) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseForumTopic represents the incoming response from Telegram servers. +// Used by all methods that return a ForumTopic object on success. +type APIResponseForumTopic struct { + Result *ForumTopic `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseForumTopic) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseBotDescription represents the incoming response from Telegram servers. +// Used by all methods that return a BotDescription object on success. +type APIResponseBotDescription struct { + Result *BotDescription `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseBotDescription) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseBotShortDescription represents the incoming response from Telegram servers. +// Used by all methods that return a BotShortDescription object on success. +type APIResponseBotShortDescription struct { + Result *BotShortDescription `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseBotShortDescription) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseBotName represents the incoming response from Telegram servers. +// Used by all methods that return a BotName object on success. +type APIResponseBotName struct { + Result *BotName `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseBotName) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseUserChatBoosts represents the incoming response from Telegram servers. +// Used by all methods that return a UserChatBoosts object on success. +type APIResponseUserChatBoosts struct { + Result *UserChatBoosts `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseUserChatBoosts) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseBusinessConnection represents the incoming response from Telegram servers. +// Used by all methods that return a BusinessConnection object on success. +type APIResponseBusinessConnection struct { + Result *BusinessConnection `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseBusinessConnection) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseStarTransactions represents the incoming response from Telegram servers. +// Used by all methods that return a StarTransactions object on success. +type APIResponseStarTransactions struct { + Result *StarTransactions `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseStarTransactions) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponsePreparedInlineMessage represents the incoming response from Telegram servers. +// Used by all methods that return a PreparedInlineMessage object on success. +type APIResponsePreparedInlineMessage struct { + Result *PreparedInlineMessage `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponsePreparedInlineMessage) Base() APIResponseBase { + return a.APIResponseBase +} + +// APIResponseGifts represents the incoming response from Telegram servers. +// Used by all methods that return a Gifts object on success. +type APIResponseGifts struct { + Result *Gifts `json:"result,omitempty"` + APIResponseBase +} + +// Base returns the contained object of type APIResponseBase. +func (a APIResponseGifts) Base() APIResponseBase { + return a.APIResponseBase +} + +// User represents a Telegram user or bot. +type User struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name,omitempty"` + Username string `json:"username,omitempty"` + LanguageCode string `json:"language_code,omitempty"` + ID int64 `json:"id"` + IsBot bool `json:"is_bot"` + IsPremium bool `json:"is_premium,omitempty"` + AddedToAttachmentMenu bool `json:"added_to_attachment_menu,omitempty"` + CanJoinGroups bool `json:"can_join_groups,omitempty"` + CanReadAllGroupMessages bool `json:"can_read_all_group_messages,omitempty"` + SupportsInlineQueries bool `json:"supports_inline_queries,omitempty"` + CanConnectToBusiness bool `json:"can_connect_to_business,omitempty"` + HasMainWebApp bool `json:"has_main_web_app,omitempty"` + AllowsUsersToCreateTopics bool `json:"allows_users_to_create_topics,omitempty"` +} + +// Chat represents a chat. +type Chat struct { + Type string `json:"type"` + Title string `json:"title,omitempty"` + Username string `json:"username,omitempty"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + ID int64 `json:"id"` + IsForum bool `json:"is_forum,omitempty"` +} + +// ChatFullInfo contains full information about a chat. +type ChatFullInfo struct { + Permissions *ChatPermissions `json:"permissions,omitempty"` + Location *ChatLocation `json:"location,omitempty"` + PinnedMessage *Message `json:"pinned_message,omitempty"` + Photo *ChatPhoto `json:"photo,omitempty"` + ActiveUsernames *[]string `json:"active_usernames,omitempty"` + AvailableReactions *[]ReactionType `json:"available_reactions,omitempty"` + BusinessIntro *BusinessIntro `json:"business_intro,omitempty"` + BusinessLocation *BusinessLocation `json:"business_location,omitempty"` + BusinessOpeningHours *BusinessOpeningHours `json:"business_opening_hours,omitempty"` + PersonalChat *Chat `json:"personal_chat,omitempty"` + Birthdate *Birthdate `json:"birthdate,omitempty"` + FirstProfileAudio *Audio `json:"first_profile_audio,omitempty"` + BackgroundCustomEmojiID string `json:"background_custom_emoji_id,omitempty"` + ProfileBackgroundCustomEmojiID string `json:"profile_background_custom_emoji_id,omitempty"` + Bio string `json:"bio,omitempty"` + Username string `json:"username,omitempty"` + Title string `json:"title,omitempty"` + StickerSetName string `json:"sticker_set_name,omitempty"` + Description string `json:"description,omitempty"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + InviteLink string `json:"invite_link,omitempty"` + EmojiStatusCustomEmojiID string `json:"emoji_status_custom_emoji_id,omitempty"` + Type string `json:"type"` + CustomEmojiStickerSetName string `json:"custom_emoji_sticker_set_name,omitempty"` + AccentColorID int `json:"accent_color_id,omitempty"` + MaxReactionCount int `json:"max_reaction_count,omitempty"` + ProfileAccentColorID int `json:"profile_accent_color_id,omitempty"` + EmojiStatusExpirationDate int `json:"emoji_status_expiration_date,omitempty"` + MessageAutoDeleteTime int `json:"message_auto_delete_time,omitempty"` + SlowModeDelay int `json:"slow_mode_delay,omitempty"` + UnrestrictBoostCount int `json:"unrestrict_boost_count,omitempty"` + LinkedChatID int64 `json:"linked_chat_id,omitempty"` + ID int64 `json:"id"` + IsForum bool `json:"is_forum,omitempty"` + CanSendPaidMedia bool `json:"can_send_paid_media,omitempty"` + HasAggressiveAntiSpamEnabled bool `json:"has_aggressive_anti_spam_enabled,omitempty"` + HasHiddenMembers bool `json:"has_hidden_members,omitempty"` + HasProtectedContent bool `json:"has_protected_content,omitempty"` + HasVisibleHistory bool `json:"has_visible_history,omitempty"` + HasPrivateForwards bool `json:"has_private_forwards,omitempty"` + CanSetStickerSet bool `json:"can_set_sticker_set,omitempty"` + JoinToSendMessages bool `json:"join_to_send_messages,omitempty"` + JoinByRequest bool `json:"join_by_request,omitempty"` + HasRestrictedVoiceAndVideoMessages bool `json:"has_restricted_voice_and_video_messages,omitempty"` + AcceptedGiftTypes AcceptedGiftTypes `json:"accepted_gift_types,omitempty"` +} + +type AcceptedGiftTypes struct { + UnlimitedGifts bool `json:"unlimited_gifs,omitempty"` + LimitedGifts bool `json:"limited_gifts,omitempty"` + UniqueGifts bool `json:"unique_gifs,omitempty"` + PremiumSubscription bool `json:"premium_subscription,omitempty"` +} + +// Message represents a message. +type Message struct { + MessageAutoDeleteTimerChanged *MessageAutoDeleteTimerChanged `json:"message_auto_delete_timer_changed,omitempty"` + Contact *Contact `json:"contact,omitempty"` + SenderChat *Chat `json:"sender_chat,omitempty"` + WebAppData *WebAppData `json:"web_app_data,omitempty"` + From *User `json:"from,omitempty"` + VideoChatParticipantsInvited *VideoChatParticipantsInvited `json:"video_chat_participants_invited,omitempty"` + Invoice *Invoice `json:"invoice,omitempty"` + SuccessfulPayment *SuccessfulPayment `json:"successful_payment,omitempty"` + RefundedPayment *RefundedPayment `json:"refunded_payment,omitempty"` + VideoChatEnded *VideoChatEnded `json:"video_chat_ended,omitempty"` + VideoChatStarted *VideoChatStarted `json:"video_chat_started,omitempty"` + ReplyToMessage *Message `json:"reply_to_message,omitempty"` + ViaBot *User `json:"via_bot,omitempty"` + Poll *Poll `json:"poll,omitempty"` + ProximityAlertTriggered *ProximityAlertTriggered `json:"proximity_alert_triggered,omitempty"` + ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` + Document *Document `json:"document,omitempty"` + PaidMedia *PaidMediaInfo `json:"paid_media,omitempty"` + PinnedMessage *Message `json:"pinned_message,omitempty"` + LeftChatMember *User `json:"left_chat_member,omitempty"` + Animation *Animation `json:"animation,omitempty"` + Audio *Audio `json:"audio,omitempty"` + Voice *Voice `json:"voice,omitempty"` + Location *Location `json:"location,omitempty"` + Sticker *Sticker `json:"sticker,omitempty"` + Video *Video `json:"video,omitempty"` + VideoNote *VideoNote `json:"video_note,omitempty"` + Venue *Venue `json:"venue,omitempty"` + Game *Game `json:"game,omitempty"` + Dice *Dice `json:"dice,omitempty"` + ForumTopicCreated *ForumTopicCreated `json:"forum_topic_created,omitempty"` + ForumTopicEdited *ForumTopicEdited `json:"forum_topic_edited,omitempty"` + VideoChatScheduled *VideoChatScheduled `json:"video_chat_scheduled,omitempty"` + ForumTopicClosed *ForumTopicClosed `json:"forum_topic_closed,omitempty"` + ForumTopicReopened *ForumTopicReopened `json:"forum_topic_reopened,omitempty"` + GeneralForumTopicHidden *GeneralForumTopicHidden `json:"general_forum_topic_hidden,omitempty"` + GeneralForumTopicUnhidden *GeneralForumTopicUnhidden `json:"general_forum_topic_unhidden,omitempty"` + ChatOwnerLeft *ChatOwnerLeft `json:"chat_owner_left,omitempty"` + ChatOwnerChanged *ChatOwnerChanged `json:"chat_owner_changed,omitempty"` + GiveawayCreated *GiveawayCreated `json:"giveaway_created,omitempty"` + Giveaway *Giveaway `json:"giveaway,omitempty"` + GiveawayWinners *GiveawayWinners `json:"giveaway_winners,omitempty"` + GiveawayCompleted *GiveawayCompleted `json:"giveaway_completed,omitempty"` + WriteAccessAllowed *WriteAccessAllowed `json:"write_access_allowed,omitempty"` + UsersShared *UsersShared `json:"users_shared,omitempty"` + ChatShared *ChatShared `json:"chat_shared,omitempty"` + Story *Story `json:"story,omitempty"` + ReplyToStory *Story `json:"reply_to_story,omitempty"` + ExternalReply *ExternalReplyInfo `json:"external_reply,omitempty"` + Quote *TextQuote `json:"quote,omitempty"` + LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"` + ForwardOrigin *MessageOrigin `json:"forward_origin,omitempty"` + BoostAdded *ChatBoostAdded `json:"boost_added,omitempty"` + ChatBackgroundSet *ChatBackground `json:"chat_background_set,omitempty"` + SenderBusinessBot *User `json:"sender_business_bot,omitempty"` + MediaGroupID string `json:"media_group_id,omitempty"` + ConnectedWebsite string `json:"connected_website,omitempty"` + NewChatTitle string `json:"new_chat_title,omitempty"` + AuthorSignature string `json:"author_signature,omitempty"` + Caption string `json:"caption,omitempty"` + Text string `json:"text,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + EffectID string `json:"effect_id,omitempty"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + NewChatPhoto []*PhotoSize `json:"new_chat_photo,omitempty"` + NewChatMembers []*User `json:"new_chat_members,omitempty"` + Photo []*PhotoSize `json:"photo,omitempty"` + Entities []*MessageEntity `json:"entities,omitempty"` + Chat Chat `json:"chat"` + ID int `json:"message_id"` + ThreadID int `json:"message_thread_id,omitempty"` + MigrateFromChatID int `json:"migrate_from_chat_id,omitempty"` + Date int `json:"date"` + MigrateToChatID int `json:"migrate_to_chat_id,omitempty"` + EditDate int `json:"edit_date,omitempty"` + SenderBoostCount int `json:"sender_boost_count,omitempty"` + DeleteChatPhoto bool `json:"delete_chat_photo,omitempty"` + IsTopicMessage bool `json:"is_topic_message,omitempty"` + IsAutomaticForward bool `json:"is_automatic_forward,omitempty"` + GroupChatCreated bool `json:"group_chat_created,omitempty"` + SupergroupChatCreated bool `json:"supergroup_chat_created,omitempty"` + ChannelChatCreated bool `json:"channel_chat_created,omitempty"` + HasProtectedContent bool `json:"has_protected_content,omitempty"` + HasMediaSpoiler bool `json:"has_media_spoiler,omitempty"` + IsFromOffline bool `json:"is_from_offline,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` +} + +// MessageID represents a unique message identifier. +type MessageID struct { + MessageID int `json:"message_id"` +} + +// MessageEntity represents one special entity in a text message. +// For example, hashtags, usernames, URLs, etc. +type MessageEntity struct { + User *User `json:"user,omitempty"` + Type MessageEntityType `json:"type"` + URL string `json:"url,omitempty"` + Language string `json:"language,omitempty"` + CustomEmojiID string `json:"custom_emoji_id,omitempty"` + Offset int `json:"offset"` + Length int `json:"length"` +} + +// PhotoSize represents one size of a photo or a file / sticker thumbnail. +type PhotoSize struct { + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + Width int `json:"width"` + Height int `json:"height"` + FileSize int `json:"file_size,omitempty"` +} + +// Animation represents an animation file (GIF or H.264/MPEG-4 AVC video without sound). +type Animation struct { + Thumbnail *PhotoSize `json:"thumbnail,omitempty"` + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + FileName string `json:"file_name,omitempty"` + MimeType string `json:"mime_type,omitempty"` + Width int `json:"width"` + Height int `json:"height"` + Duration int `json:"duration"` + FileSize int64 `json:"file_size,omitempty"` +} + +// Audio represents an audio file to be treated as music by the Telegram clients. +type Audio struct { + Thumbnail *PhotoSize `json:"thumbnail,omitempty"` + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + Performer string `json:"performer,omitempty"` + Title string `json:"title,omitempty"` + FileName string `json:"file_name,omitempty"` + MimeType string `json:"mime_type,omitempty"` + FileSize int64 `json:"file_size,omitempty"` + Duration int `json:"duration"` +} + +// Document represents a general file (as opposed to photos, voice messages and audio files). +type Document struct { + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + Thumbnail *PhotoSize `json:"thumbnail,omitempty"` + FileName string `json:"file_name,omitempty"` + MimeType string `json:"mime_type,omitempty"` + FileSize int64 `json:"file_size,omitempty"` +} + +// Video represents a video file. +type Video struct { + Thumbnail *PhotoSize `json:"thumbnail,omitempty"` + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + FileName string `json:"file_name,omitempty"` + MimeType string `json:"mime_type,omitempty"` + Qualities []VideoQuality `json:"qualities,omitempty"` + Width int `json:"width"` + Height int `json:"height"` + Duration int `json:"duration"` + FileSize int64 `json:"file_size,omitempty"` + Cover []PhotoSize `json:"cover,omitempty"` + StartTimestamp int `json:"start_timestamp,omitempty"` +} + +// VideoQuality describes an available quality of a video. +type VideoQuality struct { + Type string `json:"type"` + Width int `json:"width"` + Height int `json:"height"` +} + +// VideoNote represents a video message (available in Telegram apps as of v.4.0). +type VideoNote struct { + Thumbnail *PhotoSize `json:"thumbnail,omitempty"` + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + Length int `json:"length"` + Duration int `json:"duration"` + FileSize int `json:"file_size,omitempty"` +} + +// Voice represents a voice note. +type Voice struct { + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + MimeType string `json:"mime_type,omitempty"` + Duration int `json:"duration"` + FileSize int64 `json:"file_size,omitempty"` +} + +// PaidMediaInfo describes the paid media added to a message. +type PaidMediaInfo struct { + PaidMedia []PaidMedia `json:"paid_media"` + StarCount int `json:"star_count"` +} + +// PaidMedia describes paid media. +type PaidMedia struct { + Photo *[]PhotoSize `json:"photo,omitempty"` + Video *Video `json:"video,omitempty"` + Type string `json:"type"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Duration int `json:"duration,omitempty"` +} + +// Contact represents a phone contact. +type Contact struct { + PhoneNumber string `json:"phone_number"` + FirstName string `json:"first_name"` + LastName string `json:"last_name,omitempty"` + VCard string `json:"vcard,omitempty"` + UserID int `json:"user_id,omitempty"` +} + +// Dice represents an animated emoji that displays a random value. +type Dice struct { + Emoji string `json:"emoji"` + Value int `json:"value"` +} + +// PollOption contains information about one answer option in a poll. +type PollOption struct { + Text string `json:"text"` + TextEntities []*MessageEntity `json:"text_entities,omitempty"` + VoterCount int `json:"voter_count"` +} + +// InputPollOption contains information about one answer option in a poll to send. +type InputPollOption struct { + Text string `json:"text"` + TextParseMode ParseMode `json:"text_parse_mode,omitempty"` + TextEntities []*MessageEntity `json:"text_entities,omitempty"` +} + +// PollAnswer represents an answer of a user in a non-anonymous poll. +type PollAnswer struct { + PollID string `json:"poll_id"` + VoterChat *Chat `json:"chat,omitempty"` + User *User `json:"user,omitempty"` + OptionIDs []int `json:"option_ids"` +} + +// Poll contains information about a poll. +type Poll struct { + Type string `json:"type"` + Question string `json:"question"` + Explanation string `json:"explanation,omitempty"` + ID string `json:"id"` + ExplanationEntities []*MessageEntity `json:"explanation_entities,omitempty"` + QuestionEntities []*MessageEntity `json:"question_entities,omitempty"` + Options []*PollOption `json:"options"` + OpenPeriod int `json:"open_period,omitempty"` + TotalVoterCount int `json:"total_voter_count"` + CorrectOptionID int `json:"correct_option_id,omitempty"` + CloseDate int `json:"close_date,omitempty"` + AllowsMultipleAnswers bool `json:"allows_multiple_answers"` + IsClosed bool `json:"is_closed"` + IsAnonymous bool `json:"is_anonymous"` +} + +// Location represents a point on the map. +type Location struct { + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + HorizontalAccuracy float64 `json:"horizontal_accuracy,omitempty"` + LivePeriod int `json:"live_period,omitempty"` + Heading int `json:"heading,omitempty"` + ProximityAlertRadius int `json:"proximity_alert_radius,omitempty"` +} + +// Venue represents a venue. +type Venue struct { + Location *Location `json:"location"` + Title string `json:"title"` + Address string `json:"address"` + FoursquareID string `json:"foursquare_id,omitempty"` + FoursquareType string `json:"foursquare_type,omitempty"` + GooglePlaceID string `json:"google_place_id,omitempty"` + GooglePlaceType string `json:"google_place_type,omitempty"` +} + +// ProximityAlertTriggered represents the content of a service message, sent whenever a user in the chat triggers a proximity alert set by another user. +type ProximityAlertTriggered struct { + Traveler *User `json:"traveler"` + Watcher *User `json:"watcher"` + Distance int `json:"distance"` +} + +// MessageAutoDeleteTimerChanged represents a service message about a change in auto-delete timer settings. +type MessageAutoDeleteTimerChanged struct { + MessageAutoDeleteTime int `json:"message_auto_delete_time"` +} + +// VideoChatScheduled represents a service message about a voice chat scheduled in the chat. +type VideoChatScheduled struct { + StartDate int `json:"start_date"` +} + +// VideoChatStarted represents a service message about a voice chat started in the chat. +type VideoChatStarted struct{} + +// VideoChatEnded represents a service message about a voice chat ended in the chat. +type VideoChatEnded struct { + Duration int `json:"duration"` +} + +// VideoChatParticipantsInvited represents a service message about new members invited to a voice chat. +type VideoChatParticipantsInvited struct { + Users []*User `json:"users,omitempty"` +} + +// UserProfilePhotos represents a user's profile pictures. +type UserProfilePhotos struct { + Photos [][]PhotoSize `json:"photos"` + TotalCount int `json:"total_count"` +} + +// UserProfileAudios represents a list of audios added to a user's profile. +type UserProfileAudios struct { + Audios []Audio `json:"audios"` + TotalCount int `json:"total_count"` +} + +// File represents a file ready to be downloaded. +type File struct { + FileID string `json:"file_id"` + FileUniqueID string `json:"file_unique_id"` + FilePath string `json:"file_path,omitempty"` + FileSize int64 `json:"file_size,omitempty"` +} + +// LoginURL represents a parameter of the inline keyboard button used to automatically authorize a user. +type LoginURL struct { + URL string `json:"url"` + ForwardText string `json:"forward_text,omitempty"` + BotUsername string `json:"bot_username,omitempty"` + RequestWriteAccess bool `json:"request_write_access,omitempty"` +} + +// SwitchInlineQueryChosenChat represents an inline button that switches the current user to inline mode in a chosen chat, with an optional default inline query. +type SwitchInlineQueryChosenChat struct { + Query string `json:"query,omitempty"` + AllowUserChats bool `json:"allow_user_chats,omitempty"` + AllowBotChats bool `json:"allow_bot_chats,omitempty"` + AllowGroupChats bool `json:"allow_group_chats,omitempty"` + AllowChannelChats bool `json:"allow_channel_chats,omitempty"` +} + +// CallbackQuery represents an incoming callback query from a callback button in an inline keyboard. +// If the button that originated the query was attached to a message sent by the bot, +// the field message will be present. If the button was attached to a message sent via the bot (in inline mode), +// the field inline_message_id will be present. Exactly one of the fields data or game_short_name will be present. +type CallbackQuery struct { + ID string `json:"id"` + From *User `json:"from"` + Message *Message `json:"message,omitempty"` + InlineMessageID string `json:"inline_message_id,omitempty"` + ChatInstance string `json:"chat_instance,omitempty"` + Data string `json:"data,omitempty"` + GameShortName string `json:"game_short_name,omitempty"` +} + +// ChatPhoto represents a chat photo. +type ChatPhoto struct { + SmallFileID string `json:"small_file_id"` + SmallFileUniqueID string `json:"small_file_unique_id"` + BigFileID string `json:"big_file_id"` + BigFileUniqueID string `json:"big_file_unique_id"` +} + +// ChatInviteLink represents an invite link for a chat. +type ChatInviteLink struct { + Creator *User `json:"creator"` + InviteLink string `json:"invite_link"` + Name string `json:"name,omitempty"` + PendingJoinRequestCount int `json:"pending_join_request_count,omitempty"` + ExpireDate int `json:"expire_date,omitempty"` + MemberLimit int `json:"member_limit,omitempty"` + IsPrimary bool `json:"is_primary"` + IsRevoked bool `json:"is_revoked"` + CreatesJoinRequest bool `json:"creates_join_request"` +} + +// ChatMember contains information about one member of a chat. +type ChatMember struct { + User *User `json:"user"` + Status string `json:"status"` + CustomTitle string `json:"custom_title,omitempty"` + IsAnonymous bool `json:"is_anonymous,omitempty"` + CanBeEdited bool `json:"can_be_edited,omitempty"` + CanManageChat bool `json:"can_manage_chat,omitempty"` + CanPostMessages bool `json:"can_post_messages,omitempty"` + CanEditMessages bool `json:"can_edit_messages,omitempty"` + CanDeleteMessages bool `json:"can_delete_messages,omitempty"` + CanManageVideoChats bool `json:"can_manage_video_chats,omitempty"` + CanRestrictMembers bool `json:"can_restrict_members,omitempty"` + CanPromoteMembers bool `json:"can_promote_members,omitempty"` + CanChangeInfo bool `json:"can_change_info,omitempty"` + CanInviteUsers bool `json:"can_invite_users,omitempty"` + CanPinMessages bool `json:"can_pin_messages,omitempty"` + IsMember bool `json:"is_member,omitempty"` + CanSendMessages bool `json:"can_send_messages,omitempty"` + CanSendAudios bool `json:"can_send_audios,omitempty"` + CanSendDocuments bool `json:"can_send_documents,omitempty"` + CanSendPhotos bool `json:"can_send_photos,omitempty"` + CanSendVideos bool `json:"can_send_videos,omitempty"` + CanSendVideoNotes bool `json:"can_send_video_notes,omitempty"` + CanSendVoiceNotes bool `json:"can_send_voice_notes,omitempty"` + CanSendPolls bool `json:"can_send_polls,omitempty"` + CanSendOtherMessages bool `json:"can_send_other_messages,omitempty"` + CanAddWebPagePreviews bool `json:"can_add_web_page_previews,omitempty"` + CanManageTopics bool `json:"can_manage_topics,omitempty"` + CanPostStories bool `json:"can_post_stories,omitempty"` + CanEditStories bool `json:"can_edit_stories,omitempty"` + CanDeleteStories bool `json:"can_delete_stories,omitempty"` + UntilDate int `json:"until_date,omitempty"` +} + +// ChatMemberUpdated represents changes in the status of a chat member. +type ChatMemberUpdated struct { + InviteLink *ChatInviteLink `json:"invite_link,omitempty"` + Chat Chat `json:"chat"` + From User `json:"from"` + OldChatMember ChatMember `json:"old_chat_member"` + NewChatMember ChatMember `json:"new_chat_member"` + Date int `json:"date"` + ViaChatFolderInviteLink bool `json:"via_chat_folder_invite_link,omitempty"` + ViaJoinRequest bool `json:"via_join_request,omitempty"` +} + +// ChatPermissions describes actions that a non-administrator user is allowed to take in a chat. +type ChatPermissions struct { + CanSendMessages bool `json:"can_send_messages,omitempty"` + CanSendAudios bool `json:"can_send_audios,omitempty"` + CanSendDocuments bool `json:"can_send_documents,omitempty"` + CanSendPhotos bool `json:"can_send_photos,omitempty"` + CanSendVideos bool `json:"can_send_videos,omitempty"` + CanSendVideoNotes bool `json:"can_send_video_notes,omitempty"` + CanSendVoiceNotes bool `json:"can_send_voice_notes,omitempty"` + CanSendPolls bool `json:"can_send_polls,omitempty"` + CanSendOtherMessages bool `json:"can_send_other_messages,omitempty"` + CanAddWebPagePreviews bool `json:"can_add_web_page_previews,omitempty"` + CanChangeInfo bool `json:"can_change_info,omitempty"` + CanInviteUsers bool `json:"can_invite_users,omitempty"` + CanPinMessages bool `json:"can_pin_messages,omitempty"` + CanManageTopics bool `json:"can_manage_topics,omitempty"` +} + +// Birthdate +type Birthdate struct { + Day int `json:"day"` + Month int `json:"month"` + Year int `json:"year"` +} + +// BusinessIntro +type BusinessIntro struct { + Sticker *Sticker `json:"sticker,omitempty"` + Title string `json:"title,omitempty"` + Message string `json:"message,omitempty"` +} + +// BusinessLocation +type BusinessLocation struct { + Location *Location `json:"location,omitempty"` + Address string `json:"address"` +} + +// BusinessOpeningHoursInterval +type BusinessOpeningHoursInterval struct { + OpeningMinute int `json:"opening_minute"` + ClosingMinute int `json:"closing_minute"` +} + +// BusinessOpeningHours +type BusinessOpeningHours struct { + TimeZoneName string `json:"time_zone_name"` + OpeningHours []BusinessOpeningHoursInterval `json:"opening_hours"` +} + +// ChatLocation represents a location to which a chat is connected. +type ChatLocation struct { + Location *Location `json:"location"` + Address string `json:"address"` +} + +// BotCommand represents a bot command. +type BotCommand struct { + Command string `json:"command"` + Description string `json:"description"` +} + +// ResponseParameters contains information about why a request was unsuccessful. +type ResponseParameters struct { + MigrateToChatID int `json:"migrate_to_chat_id,omitempty"` + RetryAfter int `json:"retry_after,omitempty"` +} + +// InputMediaType is a custom type for the various InputMedia*'s Type field. +type InputMediaType string + +// These are all the possible types for the various InputMedia*'s Type field. +const ( + MediaTypePhoto InputMediaType = "photo" + MediaTypeVideo = "video" + MediaTypeAnimation = "animation" + MediaTypeAudio = "audio" + MediaTypeDocument = "document" +) + +// InputMedia is an interface for the various media types. +type InputMedia interface { + media() InputFile + thumbnail() InputFile +} + +// GroupableInputMedia is an interface for the various groupable media types. +type GroupableInputMedia interface { + InputMedia + groupable() +} + +// mediaEnvelope is a generic struct for all the various structs under the InputMedia interface. +type mediaEnvelope struct { + InputMedia + media string + thumbnail string +} + +// MarshalJSON is a custom marshaler for the mediaEnvelope struct. +func (i mediaEnvelope) MarshalJSON() (cnt []byte, err error) { + var tmp any + + switch o := i.InputMedia.(type) { + case InputMediaPhoto: + tmp = struct { + Media string `json:"media"` + InputMediaPhoto + }{ + InputMediaPhoto: o, + Media: i.media, + } + + case InputMediaVideo: + tmp = struct { + Media string `json:"media"` + Thumbnail string `json:"thumbnail,omitempty"` + InputMediaVideo + }{ + InputMediaVideo: o, + Media: i.media, + Thumbnail: i.thumbnail, + } + + case InputMediaAnimation: + tmp = struct { + Media string `json:"media"` + Thumbnail string `json:"thumbnail,omitempty"` + InputMediaAnimation + }{ + InputMediaAnimation: o, + Media: i.media, + Thumbnail: i.thumbnail, + } + + case InputMediaAudio: + tmp = struct { + Media string `json:"media"` + Thumbnail string `json:"thumbnail,omitempty"` + InputMediaAudio + }{ + InputMediaAudio: o, + Media: i.media, + Thumbnail: i.thumbnail, + } + + case InputMediaDocument: + tmp = struct { + Media string `json:"media"` + Thumbnail string `json:"thumbnail,omitempty"` + InputMediaDocument + }{ + InputMediaDocument: o, + Media: i.media, + Thumbnail: i.thumbnail, + } + + case InputPaidMediaPhoto: + tmp = struct { + Media string `json:"media"` + InputPaidMediaPhoto + }{ + InputPaidMediaPhoto: o, + Media: i.media, + } + + case InputPaidMediaVideo: + tmp = struct { + Media string `json:"media"` + Thumbnail string `json:"thumbnail,omitempty"` + InputPaidMediaVideo + }{ + InputPaidMediaVideo: o, + Media: i.media, + Thumbnail: i.thumbnail, + } + } + + return json.Marshal(tmp) +} + +// InputMediaPhoto represents a photo to be sent. +// Type MUST BE "photo". +type InputMediaPhoto struct { + Type InputMediaType `json:"type"` + Media InputFile `json:"-"` + Caption string `json:"caption,omitempty"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + HasSpoiler bool `json:"has_spoiler,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` +} + +// media is a method which allows to obtain the Media (type InputFile) field from the InputMedia* struct. +func (i InputMediaPhoto) media() InputFile { return i.Media } + +// thumbnail is a method which allows to obtain the Thumbnail (type InputFile) field from the InputMedia* struct. +func (i InputMediaPhoto) thumbnail() InputFile { return InputFile{} } + +// groupable is a dummy method which exists to implement the interface GroupableInputMedia. +func (i InputMediaPhoto) groupable() {} + +// InputMediaVideo represents a video to be sent. +// Type MUST BE "video". +type InputMediaVideo struct { + Type InputMediaType `json:"type"` + Media InputFile `json:"-"` + Thumbnail InputFile `json:"-"` + Caption string `json:"caption,omitempty"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Duration int `json:"duration,omitempty"` + SupportsStreaming bool `json:"supports_streaming,omitempty"` + HasSpoiler bool `json:"has_spoiler,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` + Cover string `json:"cover,omitempty"` + StartTimestamp int `json:"start_timestamp,omitempty"` +} + +// media is a method which allows to obtain the Media (type InputFile) field from the InputMedia* struct. +func (i InputMediaVideo) media() InputFile { return i.Media } + +// thumbnail is a method which allows to obtain the Thumbnail (type InputFile) field from the InputMedia* struct. +func (i InputMediaVideo) thumbnail() InputFile { return i.Thumbnail } + +// groupable is a dummy method which exists to implement the interface GroupableInputMedia. +func (i InputMediaVideo) groupable() {} + +// InputMediaAnimation represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent. +// Type MUST BE "animation". +type InputMediaAnimation struct { + Type InputMediaType `json:"type"` + Media InputFile `json:"-"` + Thumbnail InputFile `json:"-"` + Caption string `json:"caption,omitempty"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Duration int `json:"duration,omitempty"` + HasSpoiler bool `json:"has_spoiler,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"` +} + +// media is a method which allows to obtain the Media (type InputFile) field from the InputMedia* struct. +func (i InputMediaAnimation) media() InputFile { return i.Media } + +// thumbnail is a method which allows to obtain the Thumbnail (type InputFile) field from the InputMedia* struct. +func (i InputMediaAnimation) thumbnail() InputFile { return i.Thumbnail } + +// InputMediaAudio represents an audio file to be treated as music to be sent. +// Type MUST BE "audio". +type InputMediaAudio struct { + Type InputMediaType `json:"type"` + Performer string `json:"performer,omitempty"` + Title string `json:"title,omitempty"` + Caption string `json:"caption,omitempty"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + Media InputFile `json:"-"` + Thumbnail InputFile `json:"-"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + Duration int `json:"duration,omitempty"` +} + +// media is a method which allows to obtain the Media (type InputFile) field from the InputMedia* struct. +func (i InputMediaAudio) media() InputFile { return i.Media } + +// thumbnail is a method which allows to obtain the Thumbnail (type InputFile) field from the InputMedia* struct. +func (i InputMediaAudio) thumbnail() InputFile { return i.Thumbnail } + +// groupable is a dummy method which exists to implement the interface GroupableInputMedia. +func (i InputMediaAudio) groupable() {} + +// InputMediaDocument represents a general file to be sent. +// Type MUST BE "document". +type InputMediaDocument struct { + Type InputMediaType `json:"type"` + Media InputFile `json:"-"` + Thumbnail InputFile `json:"-"` + Caption string `json:"caption,omitempty"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + DisableContentTypeDetection bool `json:"disable_content_type_detection,omitempty"` +} + +// media is a method which allows to obtain the Media (type InputFile) field from the InputMedia* struct. +func (i InputMediaDocument) media() InputFile { return i.Media } + +// thumbnail is a method which allows to obtain the Thumbnail (type InputFile) field from the InputMedia* struct. +func (i InputMediaDocument) thumbnail() InputFile { return i.Thumbnail } + +// groupable is a dummy method which exists to implement the interface GroupableInputMedia. +func (i InputMediaDocument) groupable() {} + +// InputPaidMediaType represents the various InputPaidMedia types. +type InputPaidMediaType string + +// These are the various InputPaidMediaType values. +const ( + InputPaidMediaTypePhoto InputPaidMediaType = "photo" + InputPaidMediaTypeVideo = "video" +) + +// InputPaidMediaPhoto represents a paid photo to send. +type InputPaidMediaPhoto struct { + Type InputPaidMediaType `json:"type"` + Media InputFile `json:"-"` +} + +// media is a method which allows to obtain the Media (type InputFile) field from the InputPaidMedia* struct. +func (i InputPaidMediaPhoto) media() InputFile { return i.Media } + +// thumbnail is a method which allows to obtain the Thumbnail (type InputFile) field from the InputPaidMedia* struct. +func (i InputPaidMediaPhoto) thumbnail() InputFile { return InputFile{} } + +// groupable is a dummy method which exists to implement the interface GroupableInputMedia. +func (i InputPaidMediaPhoto) groupable() {} + +// InputPaidMediaVideo represents a paid video to send. +type InputPaidMediaVideo struct { + Type InputPaidMediaType `json:"type"` + Media InputFile `json:"-"` + Thumbnail InputFile `json:"-"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Duration int `json:"duration,omitempty"` + SupportsStreaming bool `json:"supports_streaming,omitempty"` + Cover string `json:"cover,omitempty"` + StartTimestamp int `json:"start_timestamp,omitempty"` +} + +// media is a method which allows to obtain the Media (type InputFile) field from the InputPaidMedia* struct. +func (i InputPaidMediaVideo) media() InputFile { return i.Media } + +// thumbnail is a method which allows to obtain the Thumbnail (type InputFile) field from the InputPaidMedia* struct. +func (i InputPaidMediaVideo) thumbnail() InputFile { return i.Thumbnail } + +// groupable is a dummy method which exists to implement the interface GroupableInputMedia. +func (i InputPaidMediaVideo) groupable() {} + +// InputProfilePhoto represents an interface that implements all the various input profile photo types. +type InputProfilePhoto interface { + file() InputFile + inputProfilePhoto() +} + +// profilePhotoEnvelope is a generic struct for all the various structs under the InputProfilePhoto interface. +type profilePhotoEnvelope struct { + InputProfilePhoto + ProfilePhoto string `json:"photo,omitempty"` + Animation string `json:"animation,omitempty"` +} + +// MarshalJSON is a custom marshaler for the profilePhotoEnvelope struct. +func (i profilePhotoEnvelope) MarshalJSON() (cnt []byte, err error) { + var tmp any + + switch o := i.InputProfilePhoto.(type) { + case InputProfilePhotoStatic: + tmp = struct { + Photo string `json:"photo"` + InputProfilePhotoStatic + }{ + InputProfilePhotoStatic: o, + Photo: i.ProfilePhoto, + } + + case InputProfilePhotoAnimated: + tmp = struct { + Animation string `json:"animation"` + InputProfilePhotoAnimated + }{ + InputProfilePhotoAnimated: o, + Animation: i.Animation, + } + } + + return json.Marshal(tmp) +} + +// InputProfilePhotoStatic describes a static profile photo to set for the bot. +// Type must be "static". +type InputProfilePhotoStatic struct { + Type string `json:"type"` + Photo InputFile `json:"-"` +} + +func (i InputProfilePhotoStatic) file() InputFile { return i.Photo } + +func (i InputProfilePhotoStatic) inputProfilePhoto() {} + +// InputProfilePhotoAnimated describes an animated profile photo to set for the bot. +// Type must be "animation". +type InputProfilePhotoAnimated struct { + Type string `json:"type"` + Animation InputFile `json:"-"` + MainFrameTimestamp float64 `json:"main_frame_timestamp,omitempty"` +} + +func (i InputProfilePhotoAnimated) file() InputFile { return i.Animation } + +func (i InputProfilePhotoAnimated) inputProfilePhoto() {} + +// BotCommandScopeType is a custom type for the various bot command scope types. +type BotCommandScopeType string + +// These are all the various bot command scope types. +const ( + BCSTDefault BotCommandScopeType = "default" + BCSTAllPrivateChats = "all_private_chats" + BCSTAllGroupChats = "all_group_chats" + BCSTAllChatAdministrators = "all_chat_administrators" + BCSTChat = "chat" + BCSTChatAdministrators = "chat_administrators" + BCSTChatMember = "chat_member" +) + +// BotCommandScope is an optional parameter used in the SetMyCommands, DeleteMyCommands and GetMyCommands methods. +type BotCommandScope struct { + Type BotCommandScopeType `json:"type"` + ChatID int64 `json:"chat_id"` + UserID int64 `json:"user_id"` +} + +// BotDescription represents the bot's description. +type BotDescription struct { + Description string `json:"description"` +} + +// BotShortDescription represents the bot's short description. +type BotShortDescription struct { + ShortDescription string `json:"short_description"` +} + +// BotName represents the bot's name. +type BotName struct { + Name string `json:"name"` +} + +// ChatJoinRequest represents a join request sent to a chat. +type ChatJoinRequest struct { + InviteLink *ChatInviteLink `json:"invite_link,omitempty"` + Bio string `json:"bio,omitempty"` + Chat Chat `json:"chat"` + From User `json:"user"` + Date int `json:"date"` + UserChatID int64 `json:"user_chat_id"` +} + +// ChatBoostAdded represents a service message about a user boosting a chat. +type ChatBoostAdded struct { + BoostCount int `json:"boost_count"` +} + +// BackgroundFill describes the way a background is filled based on the selected colors. +type BackgroundFill interface { + ImplementsBackgroundFill() +} + +// BackgroundFillSolid is a background filled using the selected color. +// Type MUST be "solid". +type BackgroundFillSolid struct { + Type string `json:"type"` + Color int `json:"color"` +} + +func (b BackgroundFillSolid) ImplementsBackgroundFill() {} + +// BackgroundFillGradient is a background with a gradient fill. +// Type MUST be "gradient". +type BackgroundFillGradient struct { + Type string `json:"type"` + TopColor int `json:"top_color"` + BottomColor int `json:"bottom_color"` + RotationAngle int `json:"rotation_angle"` +} + +func (b BackgroundFillGradient) ImplementsBackgroundFill() {} + +// BackgroundFillFreeformGradient is a background with a freeform gradient that rotates after every message in the chat. +// Type MUST be "freeform_gradient". +type BackgroundFillFreeformGradient struct { + Type string `json:"type"` + Colors []int `json:"colors"` +} + +func (b BackgroundFillFreeformGradient) ImplementsBackgroundFill() {} + +// BackgroundType describes the type of a background. +type BackgroundType interface { + ImplementsBackgroundType() +} + +// BackgroundTypeFill is a background which is automatically filled based on the selected colors. +// Type MUST be "fill". +type BackgroundTypeFill struct { + Fill BackgroundFill `json:"fill"` + Type string `json:"type"` + DarkThemeDimming int `json:"dark_theme_dimming"` +} + +func (b BackgroundTypeFill) ImplementsBackgroundType() {} + +// BackgroundTypeWallpaper is a background which is a wallpaper in the JPEG format. +// Type MUST be "wallpaper". +type BackgroundTypeWallpaper struct { + Type string `json:"type"` + Document Document `json:"document"` + DarkThemeDimming int `json:"dark_theme_dimming"` + IsBlurred bool `json:"is_blurred,omitempty"` + IsMoving bool `json:"is_moving,omitempty"` +} + +func (b BackgroundTypeWallpaper) ImplementsBackgroundType() {} + +// BackgroundTypePattern is a PNG or TGV (gzipped subset of SVG with MIME type “application/x-tgwallpattern”) pattern +// to be combined with the background fill chosen by the user. +// Type MUST be "pattern". +type BackgroundTypePattern struct { + Fill BackgroundFill `json:"fill"` + Type string `json:"type"` + Document Document `json:"document"` + Intensity int `json:"intensity"` + IsInverted bool `json:"is_inverted,omitempty"` + IsMoving bool `json:"is_moving,omitempty"` +} + +func (b BackgroundTypePattern) ImplementsBackgroundType() {} + +// BackgroundTypeChatTheme is taken directly from a built-in chat theme. +// Type MUST be "chat_theme". +type BackgroundTypeChatTheme struct { + Type string `json:"type"` + ThemeName string `json:"theme_name"` +} + +func (b BackgroundTypeChatTheme) ImplementsBackgroundType() {} + +// ForumTopicCreated represents a service message about a new forum topic created in the chat. +type ForumTopicCreated struct { + Name string `json:"name"` + IconCustomEmojiID string `json:"icon_custom_emoji_id"` + IconColor int `json:"icon_color"` +} + +// ChatBackground represents a chat background. +type ChatBackground struct { + Type BackgroundType `json:"type"` +} + +// ForumTopicClosed represents a service message about a forum topic closed in the chat. +type ForumTopicClosed struct{} + +// ForumTopicEdited represents a service message about an edited forum topic. +type ForumTopicEdited struct { + Name string `json:"name"` + IconCustomEmojiID string `json:"icon_custom_emoji_id"` +} + +// ForumTopicReopened represents a service message about a forum topic reopened in the chat. +type ForumTopicReopened struct{} + +// GeneralForumTopicHidden represents a service message about General forum topic hidden in the chat. +type GeneralForumTopicHidden struct{} + +// GeneralForumTopicUnhidden represents a service message about General forum topic unhidden in the chat. +type GeneralForumTopicUnhidden struct{} + +// ChatOwnerLeft represents a service message about the owner of the direct messages chat leaving the chat. +type ChatOwnerLeft struct{} + +// ChatOwnerChanged represents a service message about a change in the owner of the direct messages chat. +type ChatOwnerChanged struct { + OldOwner User `json:"old_owner"` + NewOwner User `json:"new_owner"` +} + +// WriteAccessAllowed represents a service message about a user allowing a bot added to the attachment menu to write messages. +type WriteAccessAllowed struct { + WebAppName string `json:"web_app_name,omitempty"` + FromRequest bool `json:"from_request,omitempty"` + FromAttachmentMenu bool `json:"from_attachment_menu,omitempty"` +} + +// IconColor represents a forum topic icon in RGB format. +type IconColor int + +// These are all the various icon colors. +const ( + LightBlue IconColor = 0x6FB9F0 + Yellow = 0xFFD67E + Purple = 0xCB86DB + Green = 0x8EEE98 + Pink = 0xFF93B2 + Red = 0xFB6F5F +) + +// ForumTopic represents a forum topic. +type ForumTopic struct { + Name string `json:"name"` + IconCustomEmojiID string `json:"icon_custom_emoji_id"` + IconColor IconColor `json:"icon_color"` + MessageThreadID int64 `json:"message_thread_id"` +} + +// UserShared contains information about the user whose identifier was shared with the bot using a KeyboardButtonRequestUser button. +type UserShared struct { + RequestID int `json:"request_id"` + UserID int64 `json:"user_id"` +} + +// ChatShared contains information about the chat whose identifier was shared with the bot using a KeyboardButtonRequestChat button. +type ChatShared struct { + Photo *[]PhotoSize `json:"photo,omitempty"` + Title string `json:"title,omitempty"` + Username string `json:"username,omitempty"` + RequestID int `json:"request_id"` + ChatID int64 `json:"chat_id"` +} + +// Story represents a story. +type Story struct { + Chat Chat `json:"chat"` + ID int64 `json:"id"` +} + +type ReactionType struct { + Type string `json:"type"` + Emoji string `json:"emoji"` + CustomEmoji string `json:"custom_emoji"` +} + +// ReactionCount represents a reaction added to a message along with the number of times it was added. +type ReactionCount struct { + Type ReactionType `json:"type"` + TotalCount int `json:"total_count"` +} + +// MessageReactionUpdated represents a change of a reaction on a message performed by a user. +type MessageReactionUpdated struct { + Chat Chat `json:"chat"` + ActorChat Chat `json:"actor_chat,omitempty"` + OldReaction []ReactionType `json:"old_reaction"` + NewReaction []ReactionType `json:"new_reaction"` + User User `json:"user,omitempty"` + MessageID int `json:"message_id"` + Date int `json:"date"` +} + +// MessageReactionCountUpdated represents reaction changes on a message with anonymous reactions. +type MessageReactionCountUpdated struct { + Reactions []ReactionCount `json:"reactions"` + Chat Chat `json:"chat"` + MessageID int `json:"message_id"` + Date int `json:"date"` +} + +// TextQuote contains information about the quoted part of a message that is replied to by the given message. +type TextQuote struct { + Entities *[]MessageEntity `json:"entities,omitempty"` + Text string `json:"text"` + Position int `json:"position"` + IsManual bool `json:"is_manual,omitempty"` +} + +// ExternalReplyInfo contains information about a message that is being replied to, which may come from another chat or forum topic. +type ExternalReplyInfo struct { + Venue Venue `json:"venue,omitempty"` + Chat Chat `json:"chat,omitempty"` + Document Document `json:"document,omitempty"` + PaidMedia PaidMediaInfo `json:"paid_media,omitempty"` + Origin MessageOrigin `json:"origin"` + Contact Contact `json:"contact,omitempty"` + Invoice Invoice `json:"invoice,omitempty"` + Dice Dice `json:"dice,omitempty"` + LinkPreviewOptions LinkPreviewOptions `json:"link_preview_options,omitempty"` + Photo []PhotoSize `json:"photo,omitempty"` + Audio Audio `json:"audio,omitempty"` + Story Story `json:"story,omitempty"` + Voice Voice `json:"voice,omitempty"` + VideoNote VideoNote `json:"video_note,omitempty"` + Game Game `json:"game,omitempty"` + Video Video `json:"video,omitempty"` + Animation Animation `json:"animation,omitempty"` + Sticker Sticker `json:"sticker,omitempty"` + Giveaway Giveaway `json:"giveaway,omitempty"` + Poll Poll `json:"poll,omitempty"` + GiveawayWinners GiveawayWinners `json:"giveaway_winners,omitempty"` + Location Location `json:"location,omitempty"` + MessageID int `json:"message_id,omitempty"` + HasMediaSpoiler bool `json:"has_media_spoiler,omitempty"` +} + +// MessageOrigin describes the origin of a message. +type MessageOrigin struct { + SenderChat *Chat `json:"sender_chat,omitempty"` + SenderUser *User `json:"sender_user,omitempty"` + Type string `json:"type"` + SenderUserName string `json:"sender_user_name,omitempty"` + AuthorSignature string `json:"author_signature,omitempty"` + Date int `json:"date"` +} + +// LinkPreviewOptions describes the options used for link preview generation. +type LinkPreviewOptions struct { + URL string `json:"url,omitempty"` + IsDisabled bool `json:"is_disabled,omitempty"` + PreferSmallMedia bool `json:"prefer_small_media,omitempty"` + PreferLargeMedia bool `json:"prefer_large_media,omitempty"` + ShowAboveText bool `json:"show_above_text,omitempty"` +} + +// ReplyParameters describes reply parameters for the message that is being sent. +type ReplyParameters struct { + Quote string `json:"quote,omitempty"` + QuoteParseMode string `json:"quote_parse_mode,omitempty"` + QuoteEntities []MessageEntity `json:"quote_entities,omitempty"` + MessageID int `json:"message_id"` + ChatID int64 `json:"chat_id,omitempty"` + QuotePosition int `json:"quote_position,omitempty"` + AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"` +} + +// SharedUser contains information about a user that was shared with the bot using a KeyboardButtonRequestUser button. +type SharedUser struct { + Photo *[]PhotoSize `json:"photo,omitempty"` + FirstName string `json:"firstname,omitempty"` + LastName string `json:"lastname,omitempty"` + Username string `json:"username,omitempty"` + UserID int64 `json:"user_id"` +} + +// UsersShared contains information about the users whose identifiers were shared with the bot using a KeyboardButtonRequestUsers button. +type UsersShared struct { + Users []SharedUser `json:"users"` + RequestID int `json:"request_id"` +} + +// ChatBoost contains information about a chat boost. +type ChatBoost struct { + BoostID string `json:"boost_id"` + Source ChatBoostSource `json:"source"` + AddDate int `json:"add_date"` + ExpirationDate int `json:"expiration_date"` +} + +// ChatBoostSourceType is a custom type for the various chat boost sources. +type ChatBoostSourceType string + +// These are all the possible chat boost types. +const ( + ChatBoostSourcePremium ChatBoostSourceType = "premium" + ChatBoostSourceGiftCode = "gift_code" + ChatBoostSourceGiveaway = "giveaway" +) + +// ChatBoostSource describes the source of a chat boost. +type ChatBoostSource struct { + User *User `json:"user,omitempty"` + Source ChatBoostSourceType `json:"source"` + GiveawayMessageID int `json:"giveaway_message_id,omitempty"` + PrizeStarCount int `json:"prize_star_count,omitempty"` + IsUnclaimed bool `json:"is_unclaimed,omitempty"` +} + +// ChatBoostUpdated represents a boost added to a chat or changed. +type ChatBoostUpdated struct { + Chat Chat `json:"chat"` + Boost ChatBoost `json:"boost"` +} + +// ChatBoostRemoved represents a boost removed from a chat. +type ChatBoostRemoved struct { + BoostID string `json:"boost_id"` + Chat Chat `json:"chat"` + Source ChatBoostSource `json:"source"` + RemoveDate int `json:"remove_date"` +} + +// UserChatBoosts represents a list of boosts added to a chat by a user. +type UserChatBoosts struct { + Boosts []ChatBoost `json:"boosts"` +} + +// BusinessConnection describes the connection of the bot with a business account. +type BusinessConnection struct { + ID string `json:"id"` + User User `json:"user"` + UserChatID int64 `json:"user_chat_id"` + Date int64 `json:"date"` + CanReply bool `json:"can_reply"` + IsEnabled bool `json:"is_enabled"` +} + +// BusinessMessagesDeleted is received when messages are deleted from a connected business account. +type BusinessMessagesDeleted struct { + BusinessConnectionID string `json:"business_connection_id"` + MessageIDs []int `json:"message_ids"` + Chat Chat `json:"chat"` +} + +// Giveaway represents a message about a scheduled giveaway. +type Giveaway struct { + CountryCodes *[]string `json:"country_codes,omitempty"` + PrizeDescription string `json:"prize_description,omitempty"` + Chats []Chat `json:"chats"` + PrizeStarCount int `json:"prize_star_count,omitempty"` + WinnersSelectionDate int `json:"winners_selection_date"` + WinnerCount int `json:"winner_count"` + PremiumSubscriptionMonthCount int `json:"premium_subscription_month_count,omitempty"` + OnlyNewMembers bool `json:"only_new_members,omitempty"` + HasPublicWinners bool `json:"has_public_winners,omitempty"` +} + +// GiveawayCreated represents a service message about the creation of a scheduled giveaway. +type GiveawayCreated struct { + PrizeStarCount int `json:"prize_star_count,omitempty"` +} + +// GiveawayWinners represents a message about the completion of a giveaway with public winners. +type GiveawayWinners struct { + PrizeDescription string `json:"prize_description,omitempty"` + Chats []Chat `json:"chats"` + Winners []User `json:"winners"` + PrizeStarCount int `json:"prize_star_count,omitempty"` + GiveawayMessageID int `json:"giveaway_message_id"` + WinnersSelectionDate int `json:"winners_selection_date"` + WinnerCount int `json:"winner_count"` + AdditionalChatCount int `json:"additional_chat_count,omitempty"` + PremiumSubscriptionMonthCount int `json:"premium_subscription_month_count,omitempty"` + UnclaimedPrizeCount int `json:"unclaimed_prize_count,omitempty"` + OnlyNewMembers bool `json:"only_new_members,omitempty"` + WasRefunded bool `json:"was_refunded,omitempty"` +} + +// GiveawayCompleted represents a service message about the completion of a giveaway without public winners. +type GiveawayCompleted struct { + GiveawayMessage *Message `json:"giveaway_message,omitempty"` + IsStarGiveaway bool `json:"is_star_giveaway,omitempty"` + WinnerCount int `json:"winner_count"` + UnclaimedPrizeCount int `json:"unclaimed_prize_count,omitempty"` +} + +// Gift represents a gift that can be sent by the bot. +type Gift struct { + ID string `json:"id"` + Sticker Sticker `json:"sticker"` + StarCount int `json:"star_count"` + UpgradeStarCount int `json:"upgrade_star_count,omitempty"` + TotalCount int `json:"total_count,omitempty"` + RemainingCount int `json:"remaining_count,omitempty"` +} + +// Gifts represents a list of gifts. +type Gifts struct { + Gifts []Gift `json:"gifts"` +} + +// UniqueGiftBackdropColors describes colors of the backdrop of a unique gift. +type UniqueGiftBackdropColors struct { + CenterColor int `json:"center_color"` + EdgeColor int `json:"edge_color"` + SymbolColor int `json:"symbol_color"` + TextColor int `json:"text_color"` +} + +// UniqueGiftBackdrop describes the backdrop of a unique gift. +type UniqueGiftBackdrop struct { + Name string `json:"name"` + Colors UniqueGiftBackdropColors `json:"colors"` + Rarity int `json:"rarity"` +} + +// UniqueGiftSymbol describes a symbol of a unique gift. +type UniqueGiftSymbol struct { + Name string `json:"name"` + Sticker Sticker `json:"sticker"` + Rarity int `json:"rarity"` +} + +// UniqueGiftModel describes a model of a unique gift. +type UniqueGiftModel struct { + Name string `json:"name"` + Sticker Sticker `json:"sticker"` + Number int `json:"number"` + Rarity int `json:"rarity"` + Model Sticker `json:"model"` + Symbol Sticker `json:"symbol"` + Backdrop UniqueGiftBackdrop `json:"backdrop"` +} + +// UniqueGift describes an upgraded gift with unique characteristics. +type UniqueGift struct { + Model UniqueGiftModel `json:"model"` + Symbol UniqueGiftSymbol `json:"symbol"` + Backdrop UniqueGiftBackdrop `json:"backdrop"` + PublisherChat *Chat `json:"publisher_chat,omitempty"` + OwnerChat *Chat `json:"owner_chat,omitempty"` + SellerBot *User `json:"seller_bot,omitempty"` + BaseName string `json:"base_name"` + Name string `json:"name"` + OwnerName string `json:"owner_name,omitempty"` + Text string `json:"text,omitempty"` + Entities []MessageEntity `json:"entities,omitempty"` + Number int `json:"number"` + LastResaleStarCount int `json:"last_resale_star_count,omitempty"` + SellStarCount int `json:"sell_star_count,omitempty"` + TransferStarCount int `json:"transfer_star_count,omitempty"` + NextTransferDate int `json:"next_transfer_date,omitempty"` + CanBeTransferred bool `json:"can_be_transferred,omitempty"` + WasTransferred bool `json:"was_transferred,omitempty"` + CanBeUpgraded bool `json:"can_be_upgraded,omitempty"` + HasBeenUpgraded bool `json:"has_been_upgraded,omitempty"` + IsResellable bool `json:"is_resellable,omitempty"` + IsDisplayed bool `json:"is_displayed,omitempty"` + IsPublic bool `json:"is_public,omitempty"` + IsLimited bool `json:"is_limited,omitempty"` + IsSoldOut bool `json:"is_sold_out,omitempty"` + IsPermanent bool `json:"is_permanent,omitempty"` + IsBanned bool `json:"is_banned,omitempty"` + IsBurned bool `json:"is_burned,omitempty"` +} diff --git a/shared/echotron/types_test.go b/shared/echotron/types_test.go new file mode 100644 index 0000000..cefaedc --- /dev/null +++ b/shared/echotron/types_test.go @@ -0,0 +1,248 @@ +package echotron + +import "testing" + +func TestAPIResponseBase(_ *testing.T) { + a := APIResponseBase{} + a.Base() +} + +func TestAPIResponseUpdate(_ *testing.T) { + a := APIResponseUpdate{} + a.Base() +} + +func TestAPIResponseUser(_ *testing.T) { + a := APIResponseUser{} + a.Base() +} + +func TestAPIResponseMessage(_ *testing.T) { + a := APIResponseMessage{} + a.Base() +} + +func TestAPIResponseMessageArray(_ *testing.T) { + a := APIResponseMessageArray{} + a.Base() +} + +func TestAPIResponseMessageID(_ *testing.T) { + a := APIResponseMessageID{} + a.Base() +} + +func TestAPIResponseCommands(_ *testing.T) { + a := APIResponseCommands{} + a.Base() +} + +func TestAPIResponseBool(_ *testing.T) { + a := APIResponseBool{} + a.Base() +} + +func TestAPIResponseString(_ *testing.T) { + a := APIResponseString{} + a.Base() +} + +func TestAPIResponseChat(_ *testing.T) { + a := APIResponseChat{} + a.Base() +} + +func TestAPIResponseInviteLink(_ *testing.T) { + a := APIResponseInviteLink{} + a.Base() +} + +func TestAPIResponseStickerSet(_ *testing.T) { + a := APIResponseStickerSet{} + a.Base() +} + +func TestAPIResponseUserProfile(_ *testing.T) { + a := APIResponseUserProfile{} + a.Base() +} + +func TestAPIResponseUserProfileAudios(_ *testing.T) { + a := APIResponseUserProfileAudios{} + a.Base() +} + +func TestAPIResponseFile(_ *testing.T) { + a := APIResponseFile{} + a.Base() +} + +func TestAPIResponseAdministrators(_ *testing.T) { + a := APIResponseAdministrators{} + a.Base() +} + +func TestAPIResponseChatMember(_ *testing.T) { + a := APIResponseChatMember{} + a.Base() +} + +func TestAPIResponseInteger(_ *testing.T) { + a := APIResponseInteger{} + a.Base() +} + +func TestAPIResponsePoll(_ *testing.T) { + a := APIResponsePoll{} + a.Base() +} + +func TestAPIResponseGameHighScore(_ *testing.T) { + a := APIResponseGameHighScore{} + a.Base() +} + +func TestAPIResponseWebhook(_ *testing.T) { + a := APIResponseWebhook{} + a.Base() +} + +func TestAPIResponseSentWebAppMessage(_ *testing.T) { + a := APIResponseSentWebAppMessage{} + a.Base() +} + +func TestAPIResponseMenuButton(_ *testing.T) { + a := APIResponseMenuButton{} + a.Base() +} + +func TestAPIResponseChatAdministratorRights(_ *testing.T) { + a := APIResponseChatAdministratorRights{} + a.Base() +} + +func TestAPIResponseBotDescription(_ *testing.T) { + a := APIResponseBotDescription{} + a.Base() +} + +func TestAPIResponseBotShortDescription(_ *testing.T) { + a := APIResponseBotShortDescription{} + a.Base() +} + +func TestAPIResponseBusinessConnection(_ *testing.T) { + a := APIResponseBusinessConnection{} + a.Base() +} + +func TestAPIResponseStarTransactions(_ *testing.T) { + a := APIResponseStarTransactions{} + a.Base() +} + +func TestAPIResponsePreparedInlineMessage(_ *testing.T) { + a := APIResponsePreparedInlineMessage{} + a.Base() +} + +func TestAPIResponseGifts(_ *testing.T) { + a := APIResponseGifts{} + a.Base() +} + +func TestInputMediaPhoto(_ *testing.T) { + i := InputMediaPhoto{} + i.media() + i.thumbnail() + i.groupable() +} + +func TestInputMediaVideo(_ *testing.T) { + i := InputMediaVideo{} + i.media() + i.thumbnail() + i.groupable() +} + +func TestInputMediaAnimation(_ *testing.T) { + i := InputMediaAnimation{} + i.media() + i.thumbnail() +} + +func TestInputMediaAudio(_ *testing.T) { + i := InputMediaAudio{} + i.media() + i.thumbnail() + i.groupable() +} + +func TestInputMediaDocument(_ *testing.T) { + i := InputMediaDocument{} + i.media() + i.thumbnail() + i.groupable() +} + +func TestInputPaidMediaPhoto(_ *testing.T) { + i := InputPaidMediaPhoto{} + i.media() + i.groupable() + i.thumbnail() +} + +func TestInputPaidMediaVideo(_ *testing.T) { + i := InputPaidMediaVideo{} + i.media() + i.groupable() + i.thumbnail() +} + +func TestInputProfilePhotoStatic(_ *testing.T) { + i := InputProfilePhotoStatic{} + i.file() + i.inputProfilePhoto() +} + +func TestInputProfilePhotoAnimated(_ *testing.T) { + i := InputProfilePhotoAnimated{} + i.file() + i.inputProfilePhoto() +} + +func TestBackgroundFillSolid(_ *testing.T) { + b := BackgroundFillSolid{} + b.ImplementsBackgroundFill() +} + +func TestBackgroundFillGradient(_ *testing.T) { + b := BackgroundFillGradient{} + b.ImplementsBackgroundFill() +} + +func TestBackgroundFillFreeformGradient(_ *testing.T) { + b := BackgroundFillFreeformGradient{} + b.ImplementsBackgroundFill() +} + +func TestBackgroundTypeFill(_ *testing.T) { + b := BackgroundTypeFill{} + b.ImplementsBackgroundType() +} + +func TestBackgroundTypeWallpaper(_ *testing.T) { + b := BackgroundTypeWallpaper{} + b.ImplementsBackgroundType() +} + +func TestBackgroundTypePattern(_ *testing.T) { + b := BackgroundTypePattern{} + b.ImplementsBackgroundType() +} + +func TestBackgroundTypeChatTheme(_ *testing.T) { + b := BackgroundTypeChatTheme{} + b.ImplementsBackgroundType() +} diff --git a/shared/echotron/webapp.go b/shared/echotron/webapp.go new file mode 100644 index 0000000..8b978d3 --- /dev/null +++ b/shared/echotron/webapp.go @@ -0,0 +1,57 @@ +/* + * Echotron + * Copyright (C) 2022 The Echotron Contributors + * + * Echotron is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Echotron is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package echotron + +import ( + "encoding/json" + "net/url" +) + +// WebAppInfo contains information about a Web App. +type WebAppInfo struct { + URL string `json:"url"` +} + +// SentWebAppMessage contains information about an inline message sent +// by a Web App on behalf of a user. +type SentWebAppMessage struct { + InlineMessageID string `json:"inline_message_id,omitempty"` +} + +// WebAppData contains data sent from a Web App to the bot. +type WebAppData struct { + Data string `json:"data"` + ButtonText string `json:"button_text"` +} + +// AnswerWebAppQuery is used to set the result of an interaction with a Web App +// and send a corresponding message on behalf of the user to the chat from which +// the query originated. +func (a API) AnswerWebAppQuery(webAppQueryID string, result InlineQueryResult) (res APIResponseSentWebAppMessage, err error) { + var vals = make(url.Values) + + resultJson, err := json.Marshal(result) + if err != nil { + return res, err + } + + vals.Set("web_app_query_id", webAppQueryID) + vals.Set("result", string(resultJson)) + return res, client.get(a.base, "answerWebAppQuery", vals, &res) +} diff --git a/shared/jwt_base.py b/shared/jwt_base.py new file mode 100644 index 0000000..1601ca4 --- /dev/null +++ b/shared/jwt_base.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + + +class JWTConfig(BaseModel): + SECRET_KEY: str + ALGORITHM: str = 'HS256' + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days + + +class JWTBase: + def __init__(self, config: JWTConfig) -> None: + self.config = config diff --git a/shared/logger/__init__.py b/shared/logger/__init__.py new file mode 100644 index 0000000..c15588b --- /dev/null +++ b/shared/logger/__init__.py @@ -0,0 +1,3 @@ +__all__ = ['LoggerConfig', 'init'] + +from .logger import LoggerConfig, init diff --git a/shared/logger/console_formatter.py b/shared/logger/console_formatter.py new file mode 100644 index 0000000..16b8982 --- /dev/null +++ b/shared/logger/console_formatter.py @@ -0,0 +1,84 @@ +import logging +from datetime import datetime + + +class ColoredConsoleFormatter(logging.Formatter): + TIME_COLOR = '\033[38;2;89;89;89m' + RESET = '\033[0m' + BOLD = '\033[1m' + DARK_CYAN = '\033[36m' + DARK_YELLOW = '\033[33m' + RED = '\033[91m' + + LEVEL_COLORS = { + logging.DEBUG: '\033[36m', + logging.INFO: '\033[32m', + logging.WARNING: '\033[33m', + logging.ERROR: RED, + logging.CRITICAL: '\033[35m', + } + + LEVEL_NAMES = { + logging.DEBUG: 'DBG', + logging.INFO: 'INF', + logging.WARNING: 'WRN', + logging.ERROR: 'ERR', + logging.CRITICAL: 'CRT', + } + + def format(self, record: logging.LogRecord) -> str: + timestamp = f'{self.TIME_COLOR}{datetime.fromtimestamp(record.created).strftime("%H:%M:%S")}{self.RESET}' + + level_name = self.LEVEL_NAMES.get(record.levelno, record.levelname[:3]) + level_color = self.LEVEL_COLORS.get(record.levelno, '') + level = f'{level_color}{level_name}{self.RESET}' + + message_color = self.RED if record.levelno >= logging.ERROR else '' + message = f'{message_color}{self.BOLD}{record.getMessage()}{self.RESET}' + + # Add extra fields + extra_parts = [] + for key, value in record.__dict__.items(): + if key not in [ + 'name', + 'msg', + 'args', + 'created', + 'filename', + 'funcName', + 'levelname', + 'levelno', + 'lineno', + 'module', + 'msecs', + 'message', + 'pathname', + 'process', + 'processName', + 'relativeCreated', + 'thread', + 'threadName', + 'exc_info', + 'exc_text', + 'stack_info', + 'app_name', + 'app_version', + 'taskName', + 'color_message', + ]: + value_color = self.RED if key == 'error' else self.DARK_YELLOW + extra_parts.append(f'{self.DARK_CYAN}{key}{self.RESET}={value_color}{value}{self.RESET}') + + if extra_parts: + message += f' {" ".join(extra_parts)}' + + if record.levelno >= logging.WARNING: + location = f'{self.TIME_COLOR}{record.module}:{record.lineno}{self.RESET}' + result = f'{timestamp} {level} {location} {message}' + else: + result = f'{timestamp} {level} {message}' + + if record.exc_info: + result += '\n' + self.formatException(record.exc_info) + + return result diff --git a/shared/logger/json_formatter.py b/shared/logger/json_formatter.py new file mode 100644 index 0000000..c36fd25 --- /dev/null +++ b/shared/logger/json_formatter.py @@ -0,0 +1,55 @@ +import json +import logging +from datetime import UTC, datetime + + +class JSONFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + log_data = { + 'timestamp': datetime.fromtimestamp(record.created, UTC).isoformat(), + 'level': record.levelname, + 'message': record.getMessage(), + 'module': record.module, + 'package': record.name, + 'app_name': getattr(record, 'app_name', 'unknown'), + 'app_version': getattr(record, 'app_version', 'unknown'), + } + + if record.levelno >= logging.ERROR: + log_data['location'] = f'{record.pathname}:{record.lineno}' + + if record.exc_info: + exc_type = record.exc_info[0] + log_data['error_type'] = exc_type.__name__ if exc_type else 'Unknown' + log_data['error_message'] = str(record.exc_info[1]) + log_data['traceback'] = self.formatException(record.exc_info) + + for key, value in record.__dict__.items(): + if key not in { + 'name', + 'msg', + 'args', + 'created', + 'filename', + 'funcName', + 'levelname', + 'levelno', + 'lineno', + 'module', + 'msecs', + 'message', + 'pathname', + 'process', + 'processName', + 'relativeCreated', + 'thread', + 'threadName', + 'exc_info', + 'exc_text', + 'stack_info', + 'app_name', + 'app_version', + }: + log_data[key] = value + + return json.dumps(log_data, ensure_ascii=False) diff --git a/shared/logger/logger.py b/shared/logger/logger.py new file mode 100644 index 0000000..0f74279 --- /dev/null +++ b/shared/logger/logger.py @@ -0,0 +1,65 @@ +import logging +import os +import sys + +import pydantic + +from .console_formatter import ColoredConsoleFormatter +from .json_formatter import JSONFormatter + + +class LoggerConfig(pydantic.BaseModel): + APP_NAME: str + APP_VERSION: str + LEVEL: int = logging.INFO + PRETTY_CONSOLE: bool = False + + @pydantic.field_validator('APP_VERSION') + @classmethod + def normalize_app_version(cls, v: str) -> str: + parts = v.removeprefix('v').split('.') + if len(parts) < 2 or any(not part.isdigit() for part in parts): + raise ValueError('APP_VERSION must look like 1.2.0') + commit_count = _get_git_commit_count() + if not commit_count: + return f'v{".".join(parts)}' + parts[-1] = commit_count + return f'v{".".join(parts)}' + + +def _get_git_commit_count() -> str | None: + commit_count = os.getenv('GIT_COMMIT_COUNT') + if commit_count is None: + return None + normalized = commit_count.strip() + return normalized or None + + +def init(config: LoggerConfig) -> None: + root_logger = logging.getLogger() + root_logger.setLevel(config.LEVEL) + root_logger.handlers.clear() + + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(config.LEVEL) + + formatter = ColoredConsoleFormatter() if config.PRETTY_CONSOLE else JSONFormatter() + handler.setFormatter(formatter) + root_logger.addHandler(handler) + + for logger_name in ['uvicorn', 'uvicorn.access', 'uvicorn.error']: + uvicorn_logger = logging.getLogger(logger_name) + uvicorn_logger.handlers = [handler] + uvicorn_logger.propagate = False + + old_factory = logging.getLogRecordFactory() + + def record_factory(*args: object, **kwargs: object) -> logging.LogRecord: + record = old_factory(*args, **kwargs) + record.app_name = config.APP_NAME + record.app_version = config.APP_VERSION + return record + + logging.setLogRecordFactory(record_factory) + + logging.info('Logger initialized', extra={'app': config.APP_NAME, 'version': config.APP_VERSION}) diff --git a/shared/telegram_base.py b/shared/telegram_base.py new file mode 100644 index 0000000..2f8d7c6 --- /dev/null +++ b/shared/telegram_base.py @@ -0,0 +1,14 @@ +import logging + +import pydantic +from aiogram import Bot + + +class TelegramConfig(pydantic.BaseModel): + TOKEN: str + + +class TelegramBase: + def __init__(self, config: TelegramConfig) -> None: + self.bot: Bot = Bot(token=config.TOKEN) + logging.info('Telegram bot initialized') diff --git a/shared/worker_base.py b/shared/worker_base.py new file mode 100644 index 0000000..eb4561c --- /dev/null +++ b/shared/worker_base.py @@ -0,0 +1,65 @@ +import asyncio +import logging + +import pydantic + +log = logging.getLogger(__name__) + + +class WorkerConfig(pydantic.BaseModel): + INTERVAL_SECONDS: int = 60 + + +class WorkerBase: + def __init__(self, config: WorkerConfig) -> None: + self.config = config + self._task: asyncio.Task[None] | None = None + self._stop_event = asyncio.Event() + + log.info('Worker initialized (interval=%ds)', config.INTERVAL_SECONDS) + + async def _cycle_func(self) -> None: + raise NotImplementedError + + async def start(self) -> None: + if self._task is not None: + log.warning('Worker already running') + return + + self._stop_event.clear() + self._task = asyncio.create_task(self._run()) + log.info('Worker started') + + async def stop(self) -> None: + if self._task is None: + log.warning('Worker not running') + return + + self._stop_event.set() + + try: + await asyncio.wait_for(self._task, timeout=5.0) + except TimeoutError: + log.warning('Worker did not stop in time, cancelling') + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + except asyncio.CancelledError: + log.info('Worker task cancelled') + + self._task = None + log.info('Worker stopped') + + async def _run(self) -> None: + while not self._stop_event.is_set(): + try: + await self._cycle_func() + except Exception: + log.exception('Error in worker cycle') + + try: + await asyncio.wait_for(self._stop_event.wait(), timeout=self.config.INTERVAL_SECONDS) + except TimeoutError: + pass # Время вышло, продолжаем diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/adapter/jwt.py b/src/adapter/jwt.py new file mode 100644 index 0000000..7f86391 --- /dev/null +++ b/src/adapter/jwt.py @@ -0,0 +1,59 @@ +import datetime +import uuid + +import jwt +import pydantic + +from shared.jwt_base import JWTBase +from src.usecase import JWTEncoder + + +class JWTPayload(pydantic.BaseModel): + user_id: uuid.UUID + telegram_id: int + username: str | None + + +class JWT(JWTBase, JWTEncoder): + def encode_access_token( + self, + user_id: uuid.UUID, + telegram_id: int, + username: str | None = None, + ) -> str: + expire = datetime.datetime.now(datetime.UTC) + datetime.timedelta( + minutes=self.config.ACCESS_TOKEN_EXPIRE_MINUTES + ) + + payload = { + 'sub': str(user_id), + 'telegram_id': telegram_id, + 'username': username, + 'exp': expire, + 'type': 'access', + } + + encoded: str = jwt.encode(payload, self.config.SECRET_KEY, algorithm=self.config.ALGORITHM) + return encoded + + def decode_access_token(self, token: str) -> JWTPayload: + try: + payload = jwt.decode(token, self.config.SECRET_KEY, algorithms=[self.config.ALGORITHM]) + + if payload.get('type') != 'access': + raise ValueError('Invalid token type') + + user_id = payload.get('sub') + if not user_id: + raise ValueError('Token missing subject') + + return JWTPayload( + user_id=uuid.UUID(user_id), + telegram_id=payload['telegram_id'], + username=payload.get('username'), + ) + + except jwt.ExpiredSignatureError as e: + raise ValueError('Token has expired') from e + except jwt.InvalidTokenError as e: + raise ValueError('Invalid token') from e diff --git a/src/adapter/parser.py b/src/adapter/parser.py new file mode 100644 index 0000000..ed84005 --- /dev/null +++ b/src/adapter/parser.py @@ -0,0 +1,83 @@ +import logging +from typing import Any + +import httpx +from pydantic import BaseModel + +from src.usecase import Parser + +log = logging.getLogger(__name__) + + +class FetchChannelResponse(BaseModel): + telegram_id: int + username: str | None + title: str | None + access_hash: int | None + pts: int | None + + +class ParserClient(Parser): + def __init__(self, base_url: str, timeout: float = 5.0): + self.base_url = base_url.rstrip('/') + self.timeout = timeout + + async def fetch_telegram_channel(self, username: str) -> FetchChannelResponse | None: + username = username.lstrip('@') + + url = f'{self.base_url}/fetch-telegram-channel' + params = {'username': username} + + async with httpx.AsyncClient(timeout=self.timeout) as client: + try: + response = await client.get(url, params=params) + + if response.status_code == 404: + log.info('Channel @%s not found in Telegram', username) + return None + + response.raise_for_status() + data: dict[str, Any] = response.json() + return FetchChannelResponse.model_validate(data) + + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + return None + log.error('Parser HTTP error for @%s: %s', username, e) + raise + except httpx.TimeoutException: + log.error('Parser timeout for @%s', username) + raise + except Exception as e: + log.error('Parser unexpected error for @%s: %s', username, e) + raise + + async def resolve_telegram_channel_by_invite(self, invite_link: str) -> FetchChannelResponse | None: + invite_link = invite_link.strip() + + url = f'{self.base_url}/resolve-channel-by-invite' + payload = {'invite_link': invite_link} + + async with httpx.AsyncClient(timeout=self.timeout) as client: + try: + response = await client.post(url, json=payload) + + if response.status_code == 404: + log.info('Channel not found by invite link') + return None + + response.raise_for_status() + data: dict[str, Any] = response.json() + return FetchChannelResponse.model_validate(data) + + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + return None + log.error('Parser HTTP error for invite link: %s', e) + raise + except httpx.TimeoutException: + log.error('Parser timeout for invite link') + raise + except Exception as e: + log.error('Parser unexpected error for invite link: %s', e) + raise diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py new file mode 100644 index 0000000..2543848 --- /dev/null +++ b/src/adapter/postgres.py @@ -0,0 +1,1040 @@ +import datetime +import logging +import typing +import uuid + +from tortoise import timezone +from tortoise.transactions import in_transaction + +from shared.datebase_base import DatabaseBase +from src import domain + +log = logging.getLogger(__name__) + + +class Postgres(DatabaseBase): + @staticmethod + def transaction() -> typing.AsyncContextManager[None]: + return in_transaction() + + @staticmethod + async def create_user(user: domain.User) -> None: + await user.save() + + @staticmethod + async def get_telegram_user( + telegram_user_id: uuid.UUID | None = None, telegram_id: int | None = None + ) -> domain.TelegramUser | None: + if telegram_user_id: + return await domain.TelegramUser.get_or_none(id=telegram_user_id) + if telegram_id: + return await domain.TelegramUser.get_or_none(telegram_id=telegram_id) + + raise ValueError('Either telegram_user_id or telegram_id must be provided') + + @staticmethod + async def create_telegram_user(telegram_user: domain.TelegramUser) -> None: + await telegram_user.save() + + @staticmethod + async def update_telegram_user(telegram_user: domain.TelegramUser) -> None: + await telegram_user.save() + + @staticmethod + async def create_workspace(workspace: domain.Workspace) -> None: + await workspace.save() + + @staticmethod + async def update_workspace(workspace: domain.Workspace) -> None: + await workspace.save() + + @staticmethod + async def delete_workspace(workspace_id: uuid.UUID) -> None: + await domain.Workspace.filter(id=workspace_id).delete() + + @staticmethod + async def add_user_to_workspace(workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.WorkspaceUser: + return await domain.WorkspaceUser.create( + workspace_id=workspace_id, + user_id=user_id, + status=domain.WorkspaceUserStatus.ACTIVE, + ) + + @staticmethod + async def update_workspace_user(workspace_user: domain.WorkspaceUser) -> None: + await workspace_user.save() + + @staticmethod + async def get_user_workspaces(user_id: uuid.UUID) -> list[domain.WorkspaceUser]: + return ( + await domain.WorkspaceUser.filter(user_id=user_id) + .prefetch_related('workspace') + .order_by('created_at') + .all() + ) + + @staticmethod + async def get_workspace(workspace_id: uuid.UUID) -> domain.Workspace | None: + return await domain.Workspace.get_or_none(id=workspace_id) + + @staticmethod + async def get_workspace_for_user(workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.Workspace | None: + membership = ( + await domain.WorkspaceUser.filter(workspace_id=workspace_id, user_id=user_id) + .prefetch_related('workspace') + .first() + ) + return membership.workspace if membership else None + + @staticmethod + async def get_default_workspace_for_user(user_id: uuid.UUID) -> domain.Workspace | None: + membership = ( + await domain.WorkspaceUser.filter(user_id=user_id) + .prefetch_related('workspace') + .order_by('created_at') + .first() + ) + return membership.workspace if membership else None + + @staticmethod + async def get_workspace_membership(workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.WorkspaceUser | None: + return ( + await domain.WorkspaceUser.filter(workspace_id=workspace_id, user_id=user_id) + .prefetch_related('workspace', 'user', 'user__telegram_user', 'permissions', 'permission_scopes') + .first() + ) + + @staticmethod + async def get_workspace_members(workspace_id: uuid.UUID) -> list[domain.WorkspaceUser]: + return ( + await domain.WorkspaceUser.filter(workspace_id=workspace_id) + .prefetch_related('workspace', 'user__telegram_user', 'permissions', 'permission_scopes') + .order_by('created_at') + .all() + ) + + @staticmethod + async def get_workspace_member(workspace_user_id: uuid.UUID) -> domain.WorkspaceUser | None: + return ( + await domain.WorkspaceUser.filter(id=workspace_user_id) + .prefetch_related('workspace', 'user__telegram_user', 'permissions', 'permission_scopes') + .first() + ) + + @staticmethod + async def create_workspace_invite(invite: domain.WorkspaceInvite) -> None: + await invite.save() + + @staticmethod + async def update_workspace_invite(invite: domain.WorkspaceInvite) -> None: + await invite.save() + + @staticmethod + async def get_workspace_invite(invite_id: uuid.UUID) -> domain.WorkspaceInvite | None: + return ( + await domain.WorkspaceInvite.filter(id=invite_id) + .prefetch_related('workspace', 'user__telegram_user', 'invited_by__telegram_user') + .first() + ) + + @staticmethod + async def get_workspace_invite_by_user( + workspace_id: uuid.UUID, user_id: uuid.UUID + ) -> domain.WorkspaceInvite | None: + return await domain.WorkspaceInvite.get_or_none(workspace_id=workspace_id, user_id=user_id) + + @staticmethod + async def get_workspace_invites(workspace_id: uuid.UUID) -> list[domain.WorkspaceInvite]: + return ( + await domain.WorkspaceInvite.filter(workspace_id=workspace_id) + .prefetch_related('user__telegram_user', 'invited_by__telegram_user') + .order_by('created_at') + .all() + ) + + @staticmethod + async def set_workspace_user_permissions( + workspace_user_id: uuid.UUID, + global_permissions: set[domain.PermissionKey], + scoped_permissions: list[tuple[domain.PermissionKey, domain.PermissionScopeType, uuid.UUID]], + ) -> None: + await domain.WorkspaceUserPermission.filter(workspace_user_id=workspace_user_id).delete() + await domain.WorkspaceUserPermissionScope.filter(workspace_user_id=workspace_user_id).delete() + + if global_permissions: + await domain.WorkspaceUserPermission.bulk_create( + [ + domain.WorkspaceUserPermission(workspace_user_id=workspace_user_id, permission=permission) + for permission in global_permissions + ] + ) + + if scoped_permissions: + scope_objects = [] + for permission, scope_type, scope_id in scoped_permissions: + kwargs = { + 'workspace_user_id': workspace_user_id, + 'permission': permission, + } + if scope_type == domain.PermissionScopeType.PROJECT: + kwargs['project_id'] = scope_id + elif scope_type == domain.PermissionScopeType.CREATIVE: + kwargs['creative_id'] = scope_id + elif scope_type == domain.PermissionScopeType.PLACEMENT: + kwargs['placement_id'] = scope_id + elif scope_type == domain.PermissionScopeType.CHANNEL: + kwargs['channel_id'] = scope_id + + scope_objects.append(domain.WorkspaceUserPermissionScope(**kwargs)) + + await domain.WorkspaceUserPermissionScope.bulk_create(scope_objects) + + @staticmethod + async def create_login_token(login_token: domain.LoginToken) -> None: + await login_token.save() + + @staticmethod + async def create_creative(creative: domain.Creative) -> None: + await creative.save() + + @staticmethod + async def get_user(user_id: uuid.UUID | None = None, telegram_id: int | None = None) -> domain.User | None: + if user_id: + return await domain.User.filter(id=user_id).prefetch_related('telegram_user').first() + elif telegram_id: + return ( + await domain.User.filter(telegram_user__telegram_id=telegram_id) + .prefetch_related('telegram_user') + .first() + ) + + raise ValueError('Either user_id or telegram_id must be provided') + + @staticmethod + async def get_user_by_username(username: str) -> domain.User | None: + return ( + await domain.User.filter(telegram_user__username__iexact=username).prefetch_related('telegram_user').first() + ) + + @staticmethod + async def get_login_token(token: str) -> domain.LoginToken | None: + return await domain.LoginToken.get_or_none(token=token) + + @staticmethod + async def mark_token_as_used(token: str) -> None: + updated = await domain.LoginToken.filter(token=token, used_at__isnull=True).update(used_at=timezone.now()) + + if updated == 0: + raise domain.LoginTokenAlreadyUsed() # либо нет токена, либо он уже использован + + @staticmethod + async def update_login_token_message_id(token: str, message_id: int) -> None: + updated = await domain.LoginToken.filter(token=token).update(message_id=message_id) + if updated == 0: + raise domain.LoginTokenNotFound() + + @staticmethod + async def get_channel( + channel_id: uuid.UUID | None = None, telegram_id: int | None = None, username: str | None = None + ) -> domain.Channel | None: + if channel_id: + return await domain.Channel.get_or_none(id=channel_id) + if telegram_id: + return await domain.Channel.get_or_none(telegram_id=telegram_id) + if username: + return await domain.Channel.filter(username__iexact=username).first() + return None + + @staticmethod + async def create_channel(channel: domain.Channel) -> None: + await channel.save() + + @staticmethod + async def update_channel(channel: domain.Channel) -> None: + await channel.save() + + @staticmethod + async def search_channels(username_query: str | None = None) -> list[domain.Channel]: + query = domain.Channel.all() + + if username_query: + # Частичный поиск (case-insensitive) + query = query.filter(username__icontains=username_query) + + return await query.all() + + @staticmethod + async def get_project( + workspace_id: uuid.UUID, + project_id: uuid.UUID | None = None, + channel_id: uuid.UUID | None = None, + include_deleted: bool = False, + ) -> domain.Project | None: + query = domain.Project.filter(workspace_id=workspace_id).prefetch_related('channel') + if not include_deleted: + query = query.filter(deleted_at__isnull=True) + if project_id: + query = query.filter(id=project_id) + if channel_id: + query = query.filter(channel_id=channel_id) + return await query.first() + + @staticmethod + async def get_project_for_user_by_telegram(user_id: uuid.UUID, channel_telegram_id: int) -> domain.Project | None: + return ( + await domain.Project.filter( + channel__telegram_id=channel_telegram_id, + workspace__workspace_users__user_id=user_id, + deleted_at__isnull=True, + ) + .prefetch_related('channel') + .first() + ) + + @staticmethod + async def get_project_by_channel_telegram(channel_telegram_id: int) -> domain.Project | None: + return ( + await domain.Project.filter(channel__telegram_id=channel_telegram_id, deleted_at__isnull=True) + .prefetch_related('channel') + .first() + ) + + @staticmethod + async def create_project(project: domain.Project) -> None: + await project.save() + + @staticmethod + async def update_project(project: domain.Project) -> None: + await project.save() + + @staticmethod + async def get_workspace_projects( + workspace_id: uuid.UUID, + allowed_project_ids: set[uuid.UUID] | None = None, + include_archived: bool = False, + ) -> list[domain.Project]: + query = domain.Project.filter(workspace_id=workspace_id, deleted_at__isnull=True).prefetch_related('channel') + + if allowed_project_ids is not None: + if not allowed_project_ids: + return [] + query = query.filter(id__in=list(allowed_project_ids)) + + if not include_archived: + query = query.filter(status=domain.ProjectStatus.ACTIVE) + + return await query.order_by('created_at').all() + + @staticmethod + async def archive_project(workspace_id: uuid.UUID, project_id: uuid.UUID) -> None: + project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id) + if not project: + raise domain.ProjectNotFound() + project.status = domain.ProjectStatus.ARCHIVED + await project.save() + + @staticmethod + async def unarchive_project(workspace_id: uuid.UUID, project_id: uuid.UUID) -> None: + project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id) + if not project: + raise domain.ProjectNotFound() + project.status = domain.ProjectStatus.ACTIVE + await project.save() + + @staticmethod + async def delete_project(workspace_id: uuid.UUID, project_id: uuid.UUID) -> None: + project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id) + if not project: + raise domain.ProjectNotFound() + + project.deleted_at = timezone.now() + await project.save() + + @staticmethod + async def check_channel_exists_in_workspace(channel_id: uuid.UUID, workspace_id: uuid.UUID) -> bool: + project = await domain.Project.get_or_none( + channel_id=channel_id, workspace_id=workspace_id, deleted_at__isnull=True + ) + return project is not None + + @staticmethod + async def get_creative(workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None: + return await domain.Creative.get_or_none(id=creative_id, project__workspace_id=workspace_id).prefetch_related( + 'project', 'project__channel', 'media_items' + ) + + @staticmethod + async def update_creative(creative: domain.Creative) -> None: + await creative.save() + + @staticmethod + async def get_workspace_creatives( + workspace_id: uuid.UUID, + project_id: uuid.UUID | None = None, + include_archived: bool = False, + allowed_project_ids: set[uuid.UUID] | None = None, + created_by_user_id: uuid.UUID | None = None, + tag: domain.CreativeTag | None = None, + ) -> list[domain.Creative]: + query = domain.Creative.filter(project__workspace_id=workspace_id) + + if project_id: + query = query.filter(project_id=project_id) + elif allowed_project_ids is not None: + if not allowed_project_ids: + return [] + query = query.filter(project_id__in=list(allowed_project_ids)) + + if created_by_user_id is not None: + query = query.filter(created_by_user_id=created_by_user_id) + + if tag is not None: + query = query.filter(tag=tag) + + if not include_archived: + query = query.filter(status=domain.CreativeStatus.ACTIVE) + + return await query.prefetch_related('project', 'project__channel', 'media_items').order_by('-created_at').all() + + @staticmethod + async def delete_creative(creative_id: uuid.UUID) -> None: + await domain.Creative.filter(id=creative_id).delete() + + @staticmethod + async def create_placement(placement: domain.Placement) -> None: + await placement.save() + + @staticmethod + async def get_placement(workspace_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None: + return await domain.Placement.get_or_none(id=placement_id, project__workspace_id=workspace_id).prefetch_related( + 'project', 'project__channel', 'channel', 'creative' + ) + + @staticmethod + async def count_placements_by_project_and_channel( + project_id: uuid.UUID, + channel_id: uuid.UUID, + ) -> int: + """Count placements for a project in a specific channel.""" + return await domain.Placement.filter( + project_id=project_id, + channel_id=channel_id, + ).count() + + @staticmethod + async def update_placement(placement: domain.Placement) -> None: + await placement.save() + + @staticmethod + async def delete_placement(placement_id: uuid.UUID) -> None: + await domain.Placement.filter(id=placement_id).delete() + + @staticmethod + async def get_project_placements( + workspace_id: uuid.UUID, project_id: uuid.UUID, include_archived: bool = False + ) -> list[domain.Placement]: + query = domain.Placement.filter(project__workspace_id=workspace_id, project_id=project_id) + + if not include_archived: + query = query.filter( + status__in=[ + domain.PlacementStatus.NO_STATUS, + domain.PlacementStatus.WRITE, + domain.PlacementStatus.WAITING_RESPONSE, + domain.PlacementStatus.TERMS_APPROVAL, + domain.PlacementStatus.TO_PAY, + domain.PlacementStatus.PAID, + ] + ) + + return ( + await query.prefetch_related('project', 'project__channel', 'channel', 'creative') + .order_by('-created_at') + .all() + ) + + @staticmethod + async def get_placement_post(workspace_id: uuid.UUID, placement_post_id: uuid.UUID) -> domain.PlacementPost | None: + return await domain.PlacementPost.get_or_none( + id=placement_post_id, placement__project__workspace_id=workspace_id + ).prefetch_related( + 'placement', + 'placement__project', + 'placement__project__channel', + 'placement__channel', + 'placement__creative', + 'post', + 'post__channel', + ) + + @staticmethod + async def count_placement_posts_by_placement(placement_id: uuid.UUID) -> int: + return await domain.PlacementPost.filter(placement_id=placement_id).count() + + @staticmethod + async def get_workspace_placement_posts( + workspace_id: uuid.UUID, + project_id: uuid.UUID | None = None, + placement_channel_id: uuid.UUID | None = None, + creative_id: uuid.UUID | None = None, + placement_id: uuid.UUID | None = None, + include_archived: bool = False, + allowed_project_ids: set[uuid.UUID] | None = None, + date_from: datetime.datetime | None = None, + date_to: datetime.datetime | None = None, + has_post: bool = False, + ) -> list[domain.PlacementPost]: + query = domain.PlacementPost.filter(placement__project__workspace_id=workspace_id) + + if project_id: + query = query.filter(placement__project_id=project_id) + elif allowed_project_ids is not None: + if not allowed_project_ids: + return [] + query = query.filter(placement__project_id__in=list(allowed_project_ids)) + if placement_channel_id: + query = query.filter(placement__channel_id=placement_channel_id) + if creative_id: + query = query.filter(placement__creative_id=creative_id) + if placement_id: + query = query.filter(placement_id=placement_id) + + if date_from: + query = query.filter(created_at__gte=date_from) + if date_to: + query = query.filter(created_at__lte=date_to) + + if has_post: + query = query.filter(post_id__isnull=False) + + return ( + await query.prefetch_related( + 'placement', + 'placement__project', + 'placement__project__channel', + 'placement__channel', + 'placement__creative', + 'post', + 'post__channel', + ) + .order_by('-created_at') + .all() + ) + + @staticmethod + async def get_placement_posts_by_placement_ids( + workspace_id: uuid.UUID, + placement_ids: list[uuid.UUID], + include_archived: bool = False, + ) -> list[domain.PlacementPost]: + if not placement_ids: + return [] + + query = domain.PlacementPost.filter( + placement__project__workspace_id=workspace_id, + placement_id__in=placement_ids, + ) + + return ( + await query.prefetch_related( + 'placement', + 'placement__project', + 'placement__project__channel', + 'placement__channel', + 'placement__creative', + 'post', + 'post__channel', + ) + .order_by('-created_at') + .all() + ) + + @staticmethod + async def create_placement_post(placement_post: domain.PlacementPost) -> None: + await placement_post.save() + + @staticmethod + async def get_placement_post_by_invite_link(invite_link: str) -> domain.PlacementPost | None: + return await domain.PlacementPost.get_or_none(placement__invite_link=invite_link).prefetch_related( + 'placement', + 'placement__project', + 'placement__project__channel', + 'placement__channel', + 'placement__creative', + ) + + # Subscription methods + @staticmethod + async def create_subscription(subscription: domain.Subscription) -> None: + await subscription.save() + + @staticmethod + async def get_subscription_by_subscriber_and_placement_post( + telegram_user_id: uuid.UUID, placement_post_id: uuid.UUID + ) -> domain.Subscription | None: + return await domain.Subscription.get_or_none(telegram_user_id=telegram_user_id, placement_id=placement_post_id) + + @staticmethod + async def get_subscription_by_subscriber_and_placement( + telegram_user_id: uuid.UUID, placement_id: uuid.UUID + ) -> domain.Subscription | None: + return await domain.Subscription.get_or_none(telegram_user_id=telegram_user_id, placement_id=placement_id) + + @staticmethod + async def update_subscription(subscription: domain.Subscription) -> None: + await subscription.save() + + @staticmethod + async def get_subscriptions_for_placement_posts( + placement_post_ids: list[uuid.UUID], + *, + date_from: datetime.datetime | None = None, + date_to: datetime.datetime | None = None, + ) -> list[domain.Subscription]: + """Get subscriptions for placement_posts by finding their parent placements.""" + if not placement_post_ids: + return [] + + # Get placement_ids from placement_posts + placement_posts = await domain.PlacementPost.filter(id__in=placement_post_ids).all() + placement_ids = [pp.placement_id for pp in placement_posts] + + if not placement_ids: + return [] + + query = domain.Subscription.filter(placement_id__in=placement_ids) + + if date_from: + query = query.filter(created_at__gte=date_from) + if date_to: + query = query.filter(created_at__lte=date_to) + + return await query.all() + + @staticmethod + async def get_active_subscriptions_by_subscriber_and_project( + telegram_user_id: uuid.UUID, project_id: uuid.UUID + ) -> list[domain.Subscription]: + return ( + await domain.Subscription.filter( + telegram_user_id=telegram_user_id, + placement__project_id=project_id, + status=domain.SubscriptionStatus.ACTIVE, + ) + .prefetch_related('placement', 'telegram_user') + .all() + ) + + @staticmethod + async def get_active_subscription_by_subscriber_and_project( + telegram_user_id: uuid.UUID, project_id: uuid.UUID + ) -> domain.Subscription | None: + return ( + await domain.Subscription.filter( + telegram_user_id=telegram_user_id, + placement__project_id=project_id, + status=domain.SubscriptionStatus.ACTIVE, + ) + .prefetch_related('placement', 'telegram_user') + .first() + ) + + @staticmethod + async def get_views_history( + post_id: uuid.UUID, *, from_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None + ) -> list[domain.PostViewsHistory]: + query = domain.PostViewsHistory.filter(post_id=post_id) + + if from_date: + query = query.filter(fetched_at__gte=from_date) + if to_date: + query = query.filter(fetched_at__lte=to_date) + + return await query.order_by('fetched_at').all() + + @staticmethod + async def get_latest_views_data_batch(post_ids: list[uuid.UUID]) -> dict[uuid.UUID, tuple[int, datetime.datetime]]: + if not post_ids: + return {} + + results: dict[uuid.UUID, tuple[int, datetime.datetime]] = {} + for post_id in post_ids: + latest = await domain.PostViewsHistory.filter(post_id=post_id).order_by('-fetched_at').first() + if latest: + results[post_id] = (latest.views_count, latest.fetched_at) + + return results + + # Count methods + @staticmethod + async def count_placement_posts_by_creative(creative_id: uuid.UUID) -> int: + return await domain.PlacementPost.filter(placement__creative_id=creative_id).count() + + @staticmethod + async def count_subscriptions_by_placement_post(placement_post_id: uuid.UUID) -> int: + """Count subscriptions for a placement_post by finding its parent placement.""" + placement_post = await domain.PlacementPost.get_or_none(id=placement_post_id) + if not placement_post: + return 0 + return await domain.Subscription.filter(placement_id=placement_post.placement_id).count() + + @staticmethod + async def count_subscriptions_by_placement(placement_id: uuid.UUID) -> int: + return await domain.Subscription.filter(placement_id=placement_id).count() + + @staticmethod + async def count_placement_posts_by_creative_batch(creative_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: + if not creative_ids: + return {} + + from tortoise.functions import Count + + results = ( + await domain.PlacementPost.filter(placement__creative_id__in=creative_ids) + .group_by('placement__creative_id') + .annotate(count=Count('id')) + .values('placement__creative_id', 'count') + ) + + counts = {row['placement__creative_id']: row['count'] for row in results} + return {cid: counts.get(cid, 0) for cid in creative_ids} + + @staticmethod + async def count_subscriptions_by_placement_post_batch(placement_post_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: + """Count subscriptions for placement_posts by finding their parent placements.""" + if not placement_post_ids: + return {} + + from tortoise.functions import Count + + # Get placement_ids from placement_posts + placement_posts = await domain.PlacementPost.filter(id__in=placement_post_ids).all() + placement_id_to_post_ids: dict[uuid.UUID, list[uuid.UUID]] = {} + for pp in placement_posts: + placement_id_to_post_ids.setdefault(pp.placement_id, []).append(pp.id) + + placement_ids = list(placement_id_to_post_ids.keys()) + if not placement_ids: + return dict.fromkeys(placement_post_ids, 0) + + # Count subscriptions by placement + results = ( + await domain.Subscription.filter(placement_id__in=placement_ids) + .group_by('placement_id') + .annotate(count=Count('id')) + .values('placement_id', 'count') + ) + + placement_counts = {row['placement_id']: row['count'] for row in results} + + # Map back to placement_post_ids + post_counts: dict[uuid.UUID, int] = {} + for placement_id, post_ids in placement_id_to_post_ids.items(): + count = placement_counts.get(placement_id, 0) + for post_id in post_ids: + post_counts[post_id] = count + + return {pid: post_counts.get(pid, 0) for pid in placement_post_ids} + + @staticmethod + async def count_subscriptions_by_placement_batch(placement_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: + if not placement_ids: + return {} + + from tortoise.functions import Count + + results = ( + await domain.Subscription.filter(placement_id__in=placement_ids) + .group_by('placement_id') + .annotate(count=Count('id')) + .values('placement_id', 'count') + ) + + counts = {row['placement_id']: row['count'] for row in results} + return {pid: counts.get(pid, 0) for pid in placement_ids} + + @staticmethod + async def count_unsubscriptions_by_placement_post_batch( + placement_post_ids: list[uuid.UUID], + ) -> dict[uuid.UUID, int]: + """Count unsubscriptions for placement_posts by finding their parent placements.""" + if not placement_post_ids: + return {} + + from tortoise.functions import Count + + # Get placement_ids from placement_posts + placement_posts = await domain.PlacementPost.filter(id__in=placement_post_ids).all() + placement_id_to_post_ids: dict[uuid.UUID, list[uuid.UUID]] = {} + for pp in placement_posts: + placement_id_to_post_ids.setdefault(pp.placement_id, []).append(pp.id) + + placement_ids = list(placement_id_to_post_ids.keys()) + if not placement_ids: + return dict.fromkeys(placement_post_ids, 0) + + # Count unsubscriptions by placement (filter by UNSUBSCRIBED status) + results = ( + await domain.Subscription.filter( + placement_id__in=placement_ids, status=domain.SubscriptionStatus.UNSUBSCRIBED + ) + .group_by('placement_id') + .annotate(count=Count('id')) + .values('placement_id', 'count') + ) + + placement_counts = {row['placement_id']: row['count'] for row in results} + + # Map back to placement_post_ids + post_counts: dict[uuid.UUID, int] = {} + for placement_id, post_ids in placement_id_to_post_ids.items(): + count = placement_counts.get(placement_id, 0) + for post_id in post_ids: + post_counts[post_id] = count + + return {pid: post_counts.get(pid, 0) for pid in placement_post_ids} + + @staticmethod + async def has_placement_posts_for_creative(creative_id: uuid.UUID) -> bool: + return await domain.PlacementPost.filter(placement__creative_id=creative_id).exists() + + @staticmethod + async def get_next_post_after(channel_id: uuid.UUID, message_id: int) -> domain.Post | None: + """Get first post in channel after the given message_id.""" + return ( + await domain.Post.filter( + channel_id=channel_id, + message_id__gt=message_id, + deleted_from_channel_at__isnull=True, + ) + .order_by('message_id') + .first() + ) + + @staticmethod + async def get_next_posts_after_batch( + channel_message_pairs: list[tuple[uuid.UUID, int]], + ) -> dict[tuple[uuid.UUID, int], domain.Post]: + """Batch version: get next post for each (channel_id, message_id) pair.""" + if not channel_message_pairs: + return {} + + results: dict[tuple[uuid.UUID, int], domain.Post] = {} + for channel_id, message_id in channel_message_pairs: + next_post = ( + await domain.Post.filter( + channel_id=channel_id, + message_id__gt=message_id, + deleted_from_channel_at__isnull=True, + ) + .order_by('message_id') + .first() + ) + if next_post: + results[(channel_id, message_id)] = next_post + + return results + + @staticmethod + async def get_workspace_placements_for_analytics( + workspace_id: uuid.UUID, + project_ids: list[uuid.UUID] | None = None, + channel_ids: list[uuid.UUID] | None = None, + creative_ids: list[uuid.UUID] | None = None, + status_list: list[str] | None = None, + cost_types: list[str] | None = None, + placement_types: list[str] | None = None, + invite_link_types: list[str] | None = None, + cost_min: float | None = None, + cost_max: float | None = None, + views_min: int | None = None, + views_max: int | None = None, + subscriptions_min: int | None = None, + subscriptions_max: int | None = None, + cpm_min: float | None = None, + cpm_max: float | None = None, + channel_title_contains: str | None = None, + creative_name_contains: str | None = None, + comment_contains: str | None = None, + placement_date_from: datetime.datetime | None = None, + placement_date_to: datetime.datetime | None = None, + sort_by: str = 'created_at', + sort_direction: str = 'desc', + offset: int = 0, + limit: int = 50, + include_archived: bool = False, + allowed_project_ids: set[uuid.UUID] | None = None, + ) -> list[domain.Placement]: + """Get placements for analytics with flexible filtering, sorting, and pagination.""" + from tortoise.queryset import QuerySet + + query: QuerySet[domain.Placement] = domain.Placement.filter( + project__workspace_id=workspace_id, + ) + + if project_ids: + query = query.filter(project_id__in=project_ids) + elif allowed_project_ids is not None: + if not allowed_project_ids: + return [] + query = query.filter(project_id__in=list(allowed_project_ids)) + + if channel_ids: + query = query.filter(channel_id__in=channel_ids) + + if creative_ids: + query = query.filter(creative_id__in=creative_ids) + + if status_list: + query = query.filter(status__in=status_list) + + if cost_types: + query = query.filter(cost_type__in=cost_types) + + if placement_types: + query = query.filter(placement_type__in=placement_types) + + if invite_link_types: + query = query.filter(invite_link_type__in=invite_link_types) + + if cost_min is not None: + query = query.filter(cost_value__gte=cost_min) + if cost_max is not None: + query = query.filter(cost_value__lte=cost_max) + + if placement_date_from: + query = query.filter(placement_at__gte=placement_date_from) + if placement_date_to: + query = query.filter(placement_at__lte=placement_date_to) + + if channel_title_contains: + query = query.filter(channel__title__icontains=channel_title_contains) + + if creative_name_contains: + query = query.filter(creative__name__icontains=creative_name_contains) + + if comment_contains: + query = query.filter(comment__icontains=comment_contains) + + elif not include_archived: + query = query.filter( + status__in=[ + domain.PlacementStatus.NO_STATUS, + domain.PlacementStatus.WRITE, + domain.PlacementStatus.WAITING_RESPONSE, + domain.PlacementStatus.TERMS_APPROVAL, + domain.PlacementStatus.TO_PAY, + domain.PlacementStatus.PAID, + ] + ) + + valid_sort_fields = ['created_at', 'updated_at', 'placement_at', 'cost_value'] + if sort_by not in valid_sort_fields: + sort_by = 'created_at' + + if sort_direction == 'asc': + query = query.order_by(sort_by) + else: + query = query.order_by(f'-{sort_by}') + + return ( + await query.prefetch_related( + 'project', + 'project__channel', + 'channel', + 'creative', + 'placement_posts', + 'placement_posts__post', + 'placement_posts__post__channel', + ) + .offset(offset) + .limit(limit) + .all() + ) + + @staticmethod + async def count_workspace_placements_for_analytics( + workspace_id: uuid.UUID, + project_ids: list[uuid.UUID] | None = None, + channel_ids: list[uuid.UUID] | None = None, + creative_ids: list[uuid.UUID] | None = None, + status_list: list[str] | None = None, + cost_types: list[str] | None = None, + placement_types: list[str] | None = None, + invite_link_types: list[str] | None = None, + cost_min: float | None = None, + cost_max: float | None = None, + views_min: int | None = None, + views_max: int | None = None, + subscriptions_min: int | None = None, + subscriptions_max: int | None = None, + cpm_min: float | None = None, + cpm_max: float | None = None, + channel_title_contains: str | None = None, + creative_name_contains: str | None = None, + comment_contains: str | None = None, + placement_date_from: datetime.datetime | None = None, + placement_date_to: datetime.datetime | None = None, + include_archived: bool = False, + allowed_project_ids: set[uuid.UUID] | None = None, + ) -> int: + """Count placements for analytics with flexible filtering.""" + query = domain.Placement.filter(project__workspace_id=workspace_id) + + if project_ids: + query = query.filter(project_id__in=project_ids) + elif allowed_project_ids is not None: + if not allowed_project_ids: + return 0 + query = query.filter(project_id__in=list(allowed_project_ids)) + + if channel_ids: + query = query.filter(channel_id__in=channel_ids) + + if creative_ids: + query = query.filter(creative_id__in=creative_ids) + + if status_list: + query = query.filter(status__in=status_list) + + if cost_types: + query = query.filter(cost_type__in=cost_types) + + if placement_types: + query = query.filter(placement_type__in=placement_types) + + if invite_link_types: + query = query.filter(invite_link_type__in=invite_link_types) + + if cost_min is not None: + query = query.filter(cost_value__gte=cost_min) + if cost_max is not None: + query = query.filter(cost_value__lte=cost_max) + + if placement_date_from: + query = query.filter(placement_at__gte=placement_date_from) + if placement_date_to: + query = query.filter(placement_at__lte=placement_date_to) + + if channel_title_contains: + query = query.filter(channel__title__icontains=channel_title_contains) + + if creative_name_contains: + query = query.filter(creative__name__icontains=creative_name_contains) + + if comment_contains: + query = query.filter(comment__icontains=comment_contains) + + elif not include_archived: + query = query.filter( + status__in=[ + domain.PlacementStatus.NO_STATUS, + domain.PlacementStatus.WRITE, + domain.PlacementStatus.WAITING_RESPONSE, + domain.PlacementStatus.TERMS_APPROVAL, + domain.PlacementStatus.TO_PAY, + domain.PlacementStatus.PAID, + ] + ) + + return await query.count() diff --git a/src/adapter/s3.py b/src/adapter/s3.py new file mode 100644 index 0000000..9b45e8d --- /dev/null +++ b/src/adapter/s3.py @@ -0,0 +1,112 @@ +import logging + +import aioboto3 # type: ignore[import-untyped] +from botocore.config import Config # type: ignore[import-untyped] +from pydantic import BaseModel +from types_aiobotocore_s3.client import S3Client + +from src.usecase import S3Storage + +log = logging.getLogger(__name__) + + +class S3Config(BaseModel): + ENDPOINT_URL: str + ACCESS_KEY_ID: str + SECRET_ACCESS_KEY: str + BUCKET_NAME: str + REGION: str = 'us-east-1' + PUBLIC_BASE_URL: str | None = None + + +class S3(S3Storage): + def __init__(self, config: S3Config) -> None: + self.config = config + self.session = aioboto3.Session() + self._client: S3Client | None = None + # Конфигурация для S3-совместимых хранилищ (не AWS) + # Отключаем строгую проверку контрольных сумм для совместимости с Beget и другими провайдерами + # См: https://github.com/open-webui/open-webui/issues/16758 + self.boto_config = Config( + signature_version='s3v4', + s3={ + 'payload_signing_enabled': False, + 'addressing_style': 'auto', + }, + # Отключаем строгую проверку контрольных сумм (boto3 >= 1.40.5) + request_checksum_calculation='when_required', + response_checksum_validation='when_required', + ) + + async def connect(self) -> None: + # Создаем клиента для проверки/создания bucket + async with self.session.client( + 's3', + endpoint_url=self.config.ENDPOINT_URL, + aws_access_key_id=self.config.ACCESS_KEY_ID, + aws_secret_access_key=self.config.SECRET_ACCESS_KEY, + region_name=self.config.REGION, + config=self.boto_config, + ) as client: + # Проверяем существование bucket + try: + await client.head_bucket(Bucket=self.config.BUCKET_NAME) + log.info(f'S3 bucket {self.config.BUCKET_NAME} exists') + except Exception: + # Bucket не существует, создаем + log.info(f'Creating S3 bucket {self.config.BUCKET_NAME}') + await client.create_bucket(Bucket=self.config.BUCKET_NAME) + log.info(f'S3 bucket {self.config.BUCKET_NAME} created') + + async def close(self) -> None: + # aioboto3 использует context manager, не нужно закрывать + pass + + async def upload(self, key: str, data: bytes, content_type: str) -> None: + async with self.session.client( + 's3', + endpoint_url=self.config.ENDPOINT_URL, + aws_access_key_id=self.config.ACCESS_KEY_ID, + aws_secret_access_key=self.config.SECRET_ACCESS_KEY, + region_name=self.config.REGION, + config=self.boto_config, + ) as client: + await client.put_object( + Bucket=self.config.BUCKET_NAME, + Key=key, + Body=data, + ContentType=content_type, + ) + log.info(f'Uploaded file to S3: {key}') + + async def get(self, key: str) -> bytes: + async with self.session.client( + 's3', + endpoint_url=self.config.ENDPOINT_URL, + aws_access_key_id=self.config.ACCESS_KEY_ID, + aws_secret_access_key=self.config.SECRET_ACCESS_KEY, + region_name=self.config.REGION, + config=self.boto_config, + ) as client: + response = await client.get_object(Bucket=self.config.BUCKET_NAME, Key=key) + data: bytes = await response['Body'].read() + log.info(f'Downloaded file from S3: {key}') + return data + + async def delete(self, key: str) -> None: + async with self.session.client( + 's3', + endpoint_url=self.config.ENDPOINT_URL, + aws_access_key_id=self.config.ACCESS_KEY_ID, + aws_secret_access_key=self.config.SECRET_ACCESS_KEY, + region_name=self.config.REGION, + config=self.boto_config, + ) as client: + await client.delete_object(Bucket=self.config.BUCKET_NAME, Key=key) + log.info(f'Deleted file from S3: {key}') + + def public_url(self, key: str) -> str: + base_url = self.config.PUBLIC_BASE_URL + if not base_url: + base_url = f'{self.config.ENDPOINT_URL.rstrip("/")}/{self.config.BUCKET_NAME}' + return f'{base_url.rstrip("/")}/{key.lstrip("/")}' diff --git a/src/adapter/telegram_bot.py b/src/adapter/telegram_bot.py new file mode 100644 index 0000000..44be1a1 --- /dev/null +++ b/src/adapter/telegram_bot.py @@ -0,0 +1,157 @@ +import logging +from collections.abc import Sequence +from typing import Any + +from aiogram.types import ( + InlineKeyboardButton, + InlineKeyboardMarkup, + InputMediaAudio, + InputMediaDocument, + InputMediaPhoto, + InputMediaVideo, + LinkPreviewOptions, +) + +from shared.telegram_base import TelegramBase +from src.usecase import TelegramBotWriter + +log = logging.getLogger(__name__) + + +class TelegramBot(TelegramBase, TelegramBotWriter): + async def send_message( + self, + text: str, + chat_id: int, + parse_mode: str | None = None, + disable_preview: bool = True, + reply_to_message_id: int | None = None, + ) -> int: + message = await self.bot.send_message( + chat_id=chat_id, + text=text, + parse_mode=parse_mode, + link_preview_options=LinkPreviewOptions(is_disabled=disable_preview), + reply_to_message_id=reply_to_message_id, + ) + return message.message_id + + async def create_chat_invite_link( + self, chat_id: int, requires_approval: bool = False, name: str | None = None + ) -> str: + invite_link = await self.bot.create_chat_invite_link( + chat_id=chat_id, creates_join_request=requires_approval, name=name + ) + return invite_link.invite_link + + async def send_message_with_inline_keyboard( + self, + text: str, + chat_id: int, + buttons: list[list[InlineKeyboardButton]], + parse_mode: str | None = None, + disable_preview: bool = True, + reply_to_message_id: int | None = None, + ) -> int: + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) + message = await self.bot.send_message( + chat_id=chat_id, + text=text, + reply_markup=keyboard, + parse_mode=parse_mode, + link_preview_options=LinkPreviewOptions(is_disabled=disable_preview), + reply_to_message_id=reply_to_message_id, + ) + return message.message_id + + async def send_media_with_inline_keyboard( + self, + text: str, + chat_id: int, + media_type: str, + media_file_id: str, + buttons: list[list[InlineKeyboardButton]], + parse_mode: str | None = None, + reply_to_message_id: int | None = None, + ) -> int: + keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) if buttons else None + if media_type == 'photo': + message = await self.bot.send_photo( + chat_id=chat_id, + photo=media_file_id, + caption=text, + parse_mode=parse_mode, + reply_markup=keyboard, + reply_to_message_id=reply_to_message_id, + ) + return message.message_id + if media_type == 'video': + message = await self.bot.send_video( + chat_id=chat_id, + video=media_file_id, + caption=text, + parse_mode=parse_mode, + reply_markup=keyboard, + reply_to_message_id=reply_to_message_id, + ) + return message.message_id + if media_type == 'animation': + message = await self.bot.send_animation( + chat_id=chat_id, + animation=media_file_id, + caption=text, + parse_mode=parse_mode, + reply_markup=keyboard, + reply_to_message_id=reply_to_message_id, + ) + return message.message_id + message = await self.bot.send_message( + chat_id=chat_id, + text=text, + parse_mode=parse_mode, + reply_markup=keyboard, + reply_to_message_id=reply_to_message_id, + link_preview_options=LinkPreviewOptions(is_disabled=True), + ) + return message.message_id + + async def send_media_group( + self, + chat_id: int, + media_items: Sequence[Any], + caption: str | None = None, + parse_mode: str | None = None, + reply_to_message_id: int | None = None, + ) -> int: + if not media_items: + raise ValueError('No media items to send') + + group: list[InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo] = [] + for index, item in enumerate(media_items): + item_caption = caption if index == 0 else None + if item.media_type == 'photo': + group.append(InputMediaPhoto( + media=item.media_file_id, + caption=item_caption, + parse_mode=parse_mode, + )) + elif item.media_type == 'video': + group.append(InputMediaVideo( + media=item.media_file_id, + caption=item_caption, + parse_mode=parse_mode, + )) + else: + raise ValueError(f'Unsupported media type for group: {item.media_type}') + + messages = await self.bot.send_media_group( + chat_id=chat_id, media=group, reply_to_message_id=reply_to_message_id + ) + # Return message_id of first message (with caption) + return messages[0].message_id + + async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None: + await self.bot.edit_message_text(chat_id=chat_id, message_id=message_id, text=text, reply_markup=None) + + async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None: + await self.bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id, reply_markup=None) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..799f92f --- /dev/null +++ b/src/config.py @@ -0,0 +1,44 @@ +import json +import typing + +from pydantic import BaseModel, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from shared.config_helper import load_settings +from shared.datebase_base import DatabaseConfig +from shared.jwt_base import JWTConfig +from shared.logger import LoggerConfig +from shared.telegram_base import TelegramConfig +from src.adapter.s3 import S3Config + + +class ParserConfig(BaseModel): + URL: str + + +class AppConfig(BaseModel): + ORIGINS: list[str] + + @field_validator('ORIGINS', mode='before') + @classmethod + def parse_origins(cls, v: str | list[str]) -> list[str]: + if isinstance(v, list): + return v + if isinstance(v, str): + return typing.cast(list[str], json.loads(v)) + raise ValueError('ORIGINS must be a JSON array string') + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file='.env', case_sensitive=False, env_nested_delimiter='__') + + app: AppConfig + db: DatabaseConfig + logger: LoggerConfig + telegram: TelegramConfig + jwt: JWTConfig + parser: ParserConfig + s3: S3Config + + +settings: Settings = load_settings(Settings) diff --git a/src/controller/http_v1/__init__.py b/src/controller/http_v1/__init__.py new file mode 100644 index 0000000..bfa1ab8 --- /dev/null +++ b/src/controller/http_v1/__init__.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter + +from src.controller.http_v1.analytics import analytics_router +from src.controller.http_v1.auth import auth_router +from src.controller.http_v1.channels import channels_router +from src.controller.http_v1.creatives import creatives_router +from src.controller.http_v1.internal import internal_router +from src.controller.http_v1.projects import projects_router +from src.controller.http_v1.purchases import placements_user_router +from src.controller.http_v1.views import views_router +from src.controller.http_v1.workspace_invites import workspace_invites_global_router, workspace_invites_router +from src.controller.http_v1.workspace_members import workspace_members_router +from src.controller.http_v1.workspaces import workspaces_router + +api_router = APIRouter() + +# API v1 endpoints +api_v1_router = APIRouter(prefix='/api/v1') +api_v1_router.include_router(auth_router) +api_v1_router.include_router(internal_router) +api_v1_router.include_router(channels_router) +api_v1_router.include_router(projects_router) +api_v1_router.include_router(placements_user_router) # User-managed placements +api_v1_router.include_router(creatives_router) +api_v1_router.include_router(views_router) +api_v1_router.include_router(analytics_router) +api_v1_router.include_router(workspaces_router) +api_v1_router.include_router(workspace_members_router) +api_v1_router.include_router(workspace_invites_router) +api_v1_router.include_router(workspace_invites_global_router) + +api_router.include_router(api_v1_router) diff --git a/src/controller/http_v1/analytics.py b/src/controller/http_v1/analytics.py new file mode 100644 index 0000000..bbce5fe --- /dev/null +++ b/src/controller/http_v1/analytics.py @@ -0,0 +1,177 @@ +import datetime +import uuid +from typing import Annotated + +from fastapi import Depends +from fastapi.routing import APIRouter +from fastapi_pagination import Page, paginate + +from src import deps, domain, dto +from src.adapter.jwt import JWTPayload + +analytics_router = APIRouter(prefix='/workspaces/{workspace_id}/analytics', tags=['analytics']) + + +@analytics_router.get('/placements') +async def get_placements_analytics( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], + # Categorical filters - accept both list and comma-separated string + project_ids: list[uuid.UUID] | None = None, + status_list: str | None = None, + placement_channel_ids: list[uuid.UUID] | None = None, + creative_ids: list[uuid.UUID] | None = None, + cost_types: str | None = None, + placement_types: str | None = None, + invite_link_types: str | None = None, + # Numeric filters + cost_min: float | None = None, + cost_max: float | None = None, + views_min: int | None = None, + views_max: int | None = None, + subscriptions_min: int | None = None, + subscriptions_max: int | None = None, + cpm_min: float | None = None, + cpm_max: float | None = None, + # Text filters + channel_title_contains: str | None = None, + creative_name_contains: str | None = None, + comment_contains: str | None = None, + # Date filters + placement_date_from: datetime.datetime | None = None, + placement_date_to: datetime.datetime | None = None, + # Pagination and sorting + sort_by: str | None = 'created_at', + sort_direction: str = 'desc', + page: int = 1, + size: int = 50, +) -> dto.GetPlacementsAnalyticsOutput: + # Parse comma-separated lists + parsed_status_list = status_list.split(',') if status_list else None + parsed_cost_types = cost_types.split(',') if cost_types else None + parsed_placement_types = placement_types.split(',') if placement_types else None + parsed_invite_link_types = invite_link_types.split(',') if invite_link_types else None + + input = dto.GetPlacementsAnalyticsInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + project_ids=project_ids, + status_list=parsed_status_list, + placement_channel_ids=placement_channel_ids, + creative_ids=creative_ids, + cost_types=parsed_cost_types, + placement_types=parsed_placement_types, + invite_link_types=parsed_invite_link_types, + cost_min=cost_min, + cost_max=cost_max, + views_min=views_min, + views_max=views_max, + subscriptions_min=subscriptions_min, + subscriptions_max=subscriptions_max, + cpm_min=cpm_min, + cpm_max=cpm_max, + channel_title_contains=channel_title_contains, + creative_name_contains=creative_name_contains, + comment_contains=comment_contains, + placement_date_from=placement_date_from, + placement_date_to=placement_date_to, + sort_by=sort_by, + sort_direction=sort_direction, + page=page, + size=size, + ) + return await deps.get_usecase().get_placements_analytics(input) + + +@analytics_router.get('/creatives') +async def get_creatives_analytics( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], + project_id: uuid.UUID | None = None, + tag: domain.CreativeTag | None = None, +) -> Page[dto.CreativeAnalyticsOutput]: + input = dto.GetCreativesAnalyticsInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + project_id=project_id, + tag=tag, + ) + result = await deps.get_usecase().get_creatives_analytics(input) + return paginate(result) # type: ignore[no-any-return] + + +@analytics_router.get('/channels') +async def get_channel_analytics( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], + project_id: uuid.UUID | None = None, +) -> Page[dto.ChannelAnalyticsOutput]: + input = dto.GetChannelAnalyticsInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + project_id=project_id, + ) + result = await deps.get_usecase().get_channel_analytics(input) + return paginate(result) # type: ignore[no-any-return] + + +@analytics_router.get('/spending') +async def get_spending_analytics( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], + project_id: uuid.UUID | None = None, + date_from: datetime.datetime | None = None, + date_to: datetime.datetime | None = None, + grouping: dto.DateGrouping = dto.DateGrouping.DAY, +) -> dto.GetSpendingAnalyticsOutput: + input = dto.GetSpendingAnalyticsInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + project_id=project_id, + date_from=date_from, + date_to=date_to, + grouping=grouping, + ) + return await deps.get_usecase().get_spending_analytics(input) + + +@analytics_router.get('/overview') +async def get_overview_analytics( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], + date_from: datetime.datetime, + date_to: datetime.datetime, + project_id: uuid.UUID | None = None, +) -> dto.GetOverviewAnalyticsOutput: + input = dto.GetOverviewAnalyticsInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + date_from=date_from, + date_to=date_to, + project_id=project_id, + ) + return await deps.get_usecase().get_overview_analytics(input) + + +@analytics_router.get('/projects') +async def get_projects_analytics( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], + project_ids: list[uuid.UUID] | None = None, + date_from: datetime.datetime | None = None, + date_to: datetime.datetime | None = None, + grouping: dto.DateGrouping = dto.DateGrouping.DAY, + date_grouping: dto.DateGroupingType = dto.DateGroupingType.PLACEMENT_DATE, + metrics: list[dto.ProjectMetrics] | None = None, +) -> dto.GetProjectsAnalyticsOutput: + input = dto.GetProjectsAnalyticsInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + project_ids=project_ids, + date_from=date_from, + date_to=date_to, + grouping=grouping, + date_grouping=date_grouping, + metrics=metrics, + ) + return await deps.get_usecase().get_projects_analytics(input) diff --git a/src/controller/http_v1/auth.py b/src/controller/http_v1/auth.py new file mode 100644 index 0000000..79d61ba --- /dev/null +++ b/src/controller/http_v1/auth.py @@ -0,0 +1,25 @@ +from typing import Annotated + +from fastapi import Depends +from fastapi.routing import APIRouter + +from src import deps, dto +from src.adapter.jwt import JWTPayload + +auth_router = APIRouter(prefix='/auth', tags=['auth']) + + +@auth_router.get('/complete') +async def complete_auth(token: str) -> dto.ValidateLoginTokenOutput: + return await deps.get_usecase().validate_login_token( + input=dto.ValidateLoginTokenInput( + token=token, + ) + ) + + +@auth_router.get('/me') +async def get_me( + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.UserOutput: + return await deps.get_usecase().get_me(user_id=current_user.user_id) diff --git a/src/controller/http_v1/channels.py b/src/controller/http_v1/channels.py new file mode 100644 index 0000000..c353305 --- /dev/null +++ b/src/controller/http_v1/channels.py @@ -0,0 +1,35 @@ +import uuid +from typing import Annotated + +from fastapi import Depends +from fastapi.routing import APIRouter +from fastapi_pagination import Page, paginate + +from src import deps, dto +from src.adapter.jwt import JWTPayload + +channels_router = APIRouter(prefix='/channels', tags=['channels']) + + +@channels_router.get('') +async def get_channels( + _: Annotated[JWTPayload, Depends(deps.get_current_user)], username: str | None = None +) -> Page[dto.ChannelOutput]: + input = dto.GetChannelsInput(username=username) + result = await deps.get_usecase().get_channels(input=input) + return paginate(result) # type: ignore[no-any-return] + + +@channels_router.post('') +async def create_channels( + request: dto.CreateChannelsInput, _: Annotated[JWTPayload, Depends(deps.get_current_user)] +) -> dto.CreateChannelsOutput: + return await deps.get_usecase().create_channels(input=request) + + +@channels_router.get('/{channel_id}') +async def get_channel( + channel_id: uuid.UUID, _: Annotated[JWTPayload, Depends(deps.get_current_user)] +) -> dto.ChannelOutput: + input_data = dto.GetChannelInput(channel_id=channel_id) + return await deps.get_usecase().get_channel(input=input_data) diff --git a/src/controller/http_v1/creatives.py b/src/controller/http_v1/creatives.py new file mode 100644 index 0000000..a32c08d --- /dev/null +++ b/src/controller/http_v1/creatives.py @@ -0,0 +1,84 @@ +import uuid +from typing import Annotated + +from fastapi import Depends, Query +from fastapi.routing import APIRouter +from fastapi_pagination import Page, paginate + +from src import deps, dto +from src.adapter.jwt import JWTPayload + +creatives_router = APIRouter(prefix='/workspaces/{workspace_id}/creatives', tags=['creatives']) + + +@creatives_router.get('') +async def list_creatives( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], + project_id: uuid.UUID | None = None, + include_archived: bool = False, +) -> Page[dto.CreativeOutput]: + input = dto.GetCreativesInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + project_id=project_id, + include_archived=include_archived, + ) + + result = await deps.get_usecase().get_creatives(input=input) + return paginate(result) # type: ignore[no-any-return] + + +@creatives_router.get('/{creative_id}') +async def get_creative( + creative_id: uuid.UUID, + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.CreativeOutput: + input = dto.GetCreativeInput( + creative_id=creative_id, + user_id=current_user.user_id, + workspace_id=workspace_id, + ) + + return await deps.get_usecase().get_creative(input=input) + + +@creatives_router.post('') +async def create_creative( + workspace_id: uuid.UUID, + request: dto.CreateCreativeInput, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], + project_id: uuid.UUID = Query(), +) -> dto.CreativeOutput: + return await deps.get_usecase().create_creative(request, project_id, current_user.user_id, workspace_id) + + +@creatives_router.patch('/{creative_id}') +async def update_creative( + creative_id: uuid.UUID, + request: dto.UpdateCreativeInput, + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.CreativeOutput: + return await deps.get_usecase().update_creative( + creative_id=creative_id, + input=request, + user_id=current_user.user_id, + workspace_id=workspace_id, + ) + + +@creatives_router.delete('/{creative_id}') +async def delete_creative( + creative_id: uuid.UUID, + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> None: + input = dto.DeleteCreativeInput( + creative_id=creative_id, + user_id=current_user.user_id, + workspace_id=workspace_id, + ) + + await deps.get_usecase().delete_creative(input) diff --git a/src/controller/http_v1/internal.py b/src/controller/http_v1/internal.py new file mode 100644 index 0000000..f53f001 --- /dev/null +++ b/src/controller/http_v1/internal.py @@ -0,0 +1,129 @@ +from typing import Annotated, Literal + +from fastapi.routing import APIRouter +from pydantic import BaseModel, Field + +from src import deps, dto + +internal_router = APIRouter(prefix='/internal', tags=['internal']) + + +@internal_router.post('/auth/login-token') +async def create_login_token(input: dto.CreateLoginTokenRequest) -> str: + return await deps.get_usecase().create_telegram_login_token(telegram_id=input.telegram_id) + + +class AttachLoginTokenMessageRequest(BaseModel): + token: str + message_id: int + + +@internal_router.post('/auth/login-token/message') +async def attach_login_token_message(input: AttachLoginTokenMessageRequest) -> None: + await deps.get_usecase().attach_login_token_message(token=input.token, message_id=input.message_id) + + +@internal_router.get('/auth/jwt') +async def get_jwt_by_telegram_id( + telegram_id: int, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, +) -> dto.ValidateLoginTokenOutput: + return await deps.get_usecase().get_jwt_by_telegram_id( + telegram_id=telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + ) + + +class AttachChannelToWorkspaceRequest(BaseModel): + channel_id: str + workspace_id: str + user_telegram_id: int + + +@internal_router.post('/projects') +async def attach_channel_to_workspace(input: AttachChannelToWorkspaceRequest) -> dto.ProjectOutput: + """Привязать канал к workspace (вызывается из Golang бота после выбора workspace)""" + import uuid + + input_data = dto.AttachChannelToWorkspaceInput( + channel_id=uuid.UUID(input.channel_id), + workspace_id=uuid.UUID(input.workspace_id), + user_telegram_id=input.user_telegram_id, + ) + + return await deps.get_usecase().attach_channel_to_workspace(input=input_data) + + +class SubscriptionEventRequest(BaseModel): + type: Literal['subscription'] + user_telegram_id: int + invite_link: str + username: str | None = None + first_name: str | None = None + last_name: str | None = None + + +class UnsubscriptionEventRequest(BaseModel): + type: Literal['unsubscription'] + user_telegram_id: int + channel_telegram_id: int + + +class BotAddedEventRequest(dto.ConnectProjectInput): + type: Literal['bot_added'] + + +class BotRemovedEventRequest(dto.DisconnectProjectByTgIdInput): + type: Literal['bot_removed'] + + +class BotPermissionsEventRequest(dto.UpdateProjectPermissionsInput): + type: Literal['bot_permissions'] + + +EventRequest = Annotated[ + SubscriptionEventRequest + | UnsubscriptionEventRequest + | BotAddedEventRequest + | BotRemovedEventRequest + | BotPermissionsEventRequest, + Field(discriminator='type'), +] + + +@internal_router.post('/events') +async def handle_event(input: EventRequest) -> None: + if isinstance(input, SubscriptionEventRequest): + await deps.get_usecase().handle_subscription( + user_telegram_id=input.user_telegram_id, + username=input.username, + invite_link=input.invite_link, + first_name=input.first_name, + last_name=input.last_name, + ) + return + + if isinstance(input, UnsubscriptionEventRequest): + await deps.get_usecase().handle_unsubscription( + user_telegram_id=input.user_telegram_id, + channel_telegram_id=input.channel_telegram_id, + ) + return + + if isinstance(input, BotAddedEventRequest): + add_data = dto.ConnectProjectInput(**input.dict(exclude={'type'})) + await deps.get_usecase().tg_add_project(input=add_data) + return + + if isinstance(input, BotRemovedEventRequest): + remove_data = dto.DisconnectProjectByTgIdInput(**input.dict(exclude={'type'})) + await deps.get_usecase().disconnect_project_by_tg_id(input=remove_data) + return + + if isinstance(input, BotPermissionsEventRequest): + permissions_data = dto.UpdateProjectPermissionsInput(**input.dict(exclude={'type'})) + await deps.get_usecase().update_project_permissions(input=permissions_data) diff --git a/src/controller/http_v1/projects.py b/src/controller/http_v1/projects.py new file mode 100644 index 0000000..25c415b --- /dev/null +++ b/src/controller/http_v1/projects.py @@ -0,0 +1,117 @@ +import uuid +from typing import Annotated + +from fastapi import Depends, Query +from fastapi.routing import APIRouter +from fastapi_pagination import Page, paginate + +from src import deps, dto +from src.adapter.jwt import JWTPayload +from src.domain import PermissionKey + +projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=['projects']) + + +@projects_router.get('') +async def get_projects( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], + include_archived: bool = Query(default=False), +) -> Page[dto.ProjectOutput]: + input = dto.GetWorkspaceProjectsInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + include_archived=include_archived, + ) + + result = await deps.get_usecase().get_workspace_projects(input=input) + return paginate(result) # type: ignore[no-any-return] + + +@projects_router.get('/{project_id}') +async def get_project( + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.ProjectOutput: + context = await deps.get_usecase().ensure_workspace_permission( + workspace_id, current_user.user_id, PermissionKey.PROJECTS_READ + ) + context.ensure_project_permission(PermissionKey.PROJECTS_READ, project_id) + + input = dto.GetProjectInput( + workspace_id=workspace_id, + project_id=project_id, + ) + return await deps.get_usecase().get_project(input=input) + + +@projects_router.patch('/{project_id}/invite-link-type') +async def update_project_invite_link_type( + workspace_id: uuid.UUID, + project_id: uuid.UUID, + input: dto.UpdateProjectInviteLinkTypeInput, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.ProjectOutput: + return await deps.get_usecase().update_project_invite_link_type( + workspace_id=workspace_id, + project_id=project_id, + purchase_invite_type_default=input.purchase_invite_type_default, + user_id=current_user.user_id, + ) + + +@projects_router.post('/{project_id}/archive') +async def archive_project( + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.ProjectOutput: + input = dto.ArchiveProjectInput( + workspace_id=workspace_id, + project_id=project_id, + user_id=current_user.user_id, + ) + return await deps.get_usecase().archive_project(input=input) + + +@projects_router.post('/{project_id}/unarchive') +async def unarchive_project( + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.ProjectOutput: + input = dto.ArchiveProjectInput( + workspace_id=workspace_id, + project_id=project_id, + user_id=current_user.user_id, + ) + return await deps.get_usecase().unarchive_project(input=input) + + +@projects_router.delete('/{project_id}') +async def delete_project( + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> None: + await deps.get_usecase().delete_project( + workspace_id=workspace_id, + project_id=project_id, + user_id=current_user.user_id, + ) + + +@projects_router.put('/{project_id}/move') +async def move_project( + workspace_id: uuid.UUID, + project_id: uuid.UUID, + request: dto.MoveProjectRequest, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.ProjectOutput: + return await deps.get_usecase().move_project_to_workspace( + user_id=current_user.user_id, + source_workspace_id=workspace_id, + project_id=project_id, + target_workspace_id=request.target_workspace_id, + ) diff --git a/src/controller/http_v1/purchases.py b/src/controller/http_v1/purchases.py new file mode 100644 index 0000000..94786b7 --- /dev/null +++ b/src/controller/http_v1/purchases.py @@ -0,0 +1,132 @@ +import uuid +from typing import Annotated + +from fastapi import Depends +from fastapi.routing import APIRouter +from fastapi_pagination import Page, paginate + +from src import deps, dto +from src.adapter.jwt import JWTPayload + +# Placement router - User-managed planned placements +placements_user_router = APIRouter( + prefix='/workspaces/{workspace_id}/projects/{project_id}/placements', + tags=['placements'], +) + + +@placements_user_router.post('') +async def create_placements( + request: dto.CreatePlacementsInput, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.GetPlacementsOutput: + """Create multiple placements for different channels (bulk creation)""" + return await deps.get_usecase().create_placements( + project_id=project_id, + workspace_id=workspace_id, + user_id=current_user.user_id, + input=request, + ) + + +@placements_user_router.get('') +async def get_placements( + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> Page[dto.PlacementWithPostsOutput]: + """Get all placements for a project""" + input = dto.GetPlacementsInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + project_id=project_id, + ) + result = await deps.get_usecase().get_placements(input=input) + return paginate(result.placements) # type: ignore[no-any-return] + + +@placements_user_router.get('/{placement_id}') +async def get_placement( + placement_id: uuid.UUID, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.PlacementWithPostsOutput: + """Get single placement by ID""" + input = dto.GetPlacementInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + project_id=project_id, + placement_id=placement_id, + ) + return await deps.get_usecase().get_placement_user(input=input) + + +@placements_user_router.patch('/{placement_id}') +async def update_placement( + placement_id: uuid.UUID, + request: dto.UpdatePlacementInput, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.PlacementWithPostsOutput: + return await deps.get_usecase().update_placement( + placement_id=placement_id, + input=request, + workspace_id=workspace_id, + project_id=project_id, + user_id=current_user.user_id, + ) + + +@placements_user_router.post('/{placement_id}/creative') +async def build_placement_creative( + placement_id: uuid.UUID, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.CreativePreviewOutput: + return await deps.get_usecase().build_placement_creative( + placement_id=placement_id, + workspace_id=workspace_id, + project_id=project_id, + user_id=current_user.user_id, + ) + + +@placements_user_router.patch('/{placement_id}/posts/{placement_post_id}') +async def update_placement_post( + placement_id: uuid.UUID, + placement_post_id: uuid.UUID, + request: dto.UpdatePlacementPostInput, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.PlacementWithPostsOutput: + """Update placement post status""" + return await deps.get_usecase().update_placement_post( + placement_id=placement_id, + placement_post_id=placement_post_id, + input=request, + workspace_id=workspace_id, + project_id=project_id, + user_id=current_user.user_id, + ) + + +@placements_user_router.delete('/{placement_id}') +async def delete_placement( + placement_id: uuid.UUID, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> None: + input = dto.DeletePlacementInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + project_id=project_id, + placement_id=placement_id, + ) + await deps.get_usecase().delete_placement(input=input) diff --git a/src/controller/http_v1/views.py b/src/controller/http_v1/views.py new file mode 100644 index 0000000..feaf478 --- /dev/null +++ b/src/controller/http_v1/views.py @@ -0,0 +1,32 @@ +import datetime +import uuid +from typing import Annotated + +from fastapi import Depends +from fastapi.routing import APIRouter +from fastapi_pagination import Page, paginate + +from src import deps, dto +from src.adapter.jwt import JWTPayload + +views_router = APIRouter(prefix='/workspaces/{workspace_id}/placements/{placement_id}/views', tags=['views']) + + +@views_router.get('/history') +async def get_views_history( + placement_id: uuid.UUID, + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], + from_date: datetime.datetime | None = None, + to_date: datetime.datetime | None = None, +) -> Page[dto.PostViewsHistoryOutput]: + input_data = dto.GetViewsHistoryInput( + placement_id=placement_id, + user_id=current_user.user_id, + workspace_id=workspace_id, + from_date=from_date, + to_date=to_date, + ) + + result = await deps.get_usecase().get_views_history(input=input_data) + return paginate(result) # type: ignore[no-any-return] diff --git a/src/controller/http_v1/workspace_invites.py b/src/controller/http_v1/workspace_invites.py new file mode 100644 index 0000000..07ea391 --- /dev/null +++ b/src/controller/http_v1/workspace_invites.py @@ -0,0 +1,51 @@ +import uuid +from typing import Annotated + +from fastapi import Depends +from fastapi.routing import APIRouter +from fastapi_pagination import Page, paginate + +from src import deps, dto +from src.adapter.jwt import JWTPayload + +workspace_invites_router = APIRouter(prefix='/workspaces/{workspace_id}/invites', tags=['workspace invites']) + + +@workspace_invites_router.get('') +async def list_workspace_invites( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> Page[dto.WorkspaceInviteOutput]: + result = await deps.get_usecase().get_workspace_invites( + workspace_id=workspace_id, + user_id=current_user.user_id, + ) + return paginate(result) # type: ignore[no-any-return] + + +@workspace_invites_router.post('') +async def create_workspace_invite( + workspace_id: uuid.UUID, + request: dto.CreateWorkspaceInviteInput, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.WorkspaceInviteOutput: + return await deps.get_usecase().create_workspace_invite( + workspace_id=workspace_id, + user_id=current_user.user_id, + input=request, + ) + + +# Глобальный роутер для invite endpoints (без workspace_id в пути) +workspace_invites_global_router = APIRouter(prefix='/invites', tags=['workspace invites']) + + +@workspace_invites_global_router.post('/{invite_id}/accept') +async def accept_workspace_invite( + invite_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.WorkspaceInviteOutput: + return await deps.get_usecase().accept_workspace_invite( + invite_id=invite_id, + user_id=current_user.user_id, + ) diff --git a/src/controller/http_v1/workspace_members.py b/src/controller/http_v1/workspace_members.py new file mode 100644 index 0000000..081a361 --- /dev/null +++ b/src/controller/http_v1/workspace_members.py @@ -0,0 +1,50 @@ +import uuid +from typing import Annotated + +from fastapi import Depends +from fastapi.routing import APIRouter +from fastapi_pagination import Page, paginate + +from src import deps, dto +from src.adapter.jwt import JWTPayload + +workspace_members_router = APIRouter(prefix='/workspaces/{workspace_id}/members', tags=['workspace members']) + + +@workspace_members_router.get('/me') +async def get_current_member_permissions( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.WorkspaceMemberOutput: + """Get current user's membership and permissions in the workspace.""" + return await deps.get_usecase().get_current_member_permissions( + workspace_id=workspace_id, + user_id=current_user.user_id, + ) + + +@workspace_members_router.get('') +async def list_workspace_members( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> Page[dto.WorkspaceMemberOutput]: + result = await deps.get_usecase().get_workspace_members( + workspace_id=workspace_id, + user_id=current_user.user_id, + ) + return paginate(result) # type: ignore[no-any-return] + + +@workspace_members_router.put('/{workspace_user_id}/permissions') +async def put_workspace_member_permissions( + workspace_id: uuid.UUID, + workspace_user_id: uuid.UUID, + request: dto.UpdateWorkspaceMemberPermissionsInput, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.WorkspaceMemberOutput: + return await deps.get_usecase().update_workspace_member_permissions( + workspace_id=workspace_id, + workspace_user_id=workspace_user_id, + user_id=current_user.user_id, + input=request, + ) diff --git a/src/controller/http_v1/workspaces.py b/src/controller/http_v1/workspaces.py new file mode 100644 index 0000000..7377a01 --- /dev/null +++ b/src/controller/http_v1/workspaces.py @@ -0,0 +1,67 @@ +import uuid +from typing import Annotated + +from fastapi import Depends, File, UploadFile +from fastapi.routing import APIRouter +from fastapi_pagination import Page, paginate + +from src import deps, dto +from src.adapter.jwt import JWTPayload + +workspaces_router = APIRouter(prefix='/workspaces', tags=['workspaces']) + + +@workspaces_router.get('') +async def list_workspaces( + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> Page[dto.WorkspaceMembershipOutput]: + result = await deps.get_usecase().get_workspaces(current_user.user_id) + return paginate(result) # type: ignore[no-any-return] + + +@workspaces_router.post('') +async def create_workspace( + request: dto.CreateWorkspaceInput, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.CreateWorkspaceOutput: + return await deps.get_usecase().create_workspace(current_user.user_id, request) + + +@workspaces_router.patch('/{workspace_id}') +async def update_workspace( + workspace_id: uuid.UUID, + request: dto.UpdateWorkspaceInput, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.WorkspaceMembershipOutput: + return await deps.get_usecase().update_workspace(workspace_id, current_user.user_id, request) + + +@workspaces_router.delete('/{workspace_id}') +async def delete_workspace( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> None: + await deps.get_usecase().delete_workspace(workspace_id, current_user.user_id) + + +@workspaces_router.post('/{workspace_id}/avatar') +async def upload_workspace_avatar( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], + file: UploadFile = File(...), +) -> dto.WorkspaceMembershipOutput: + avatar_data = await file.read() + return await deps.get_usecase().update_workspace_avatar( + workspace_id, + current_user.user_id, + avatar_data, + file.content_type, + ) + + +@workspaces_router.delete('/{workspace_id}/avatar') +async def delete_workspace_avatar( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.WorkspaceMembershipOutput: + return await deps.get_usecase().delete_workspace_avatar(workspace_id, current_user.user_id) diff --git a/src/controller/worker/fetch_placement_post.py b/src/controller/worker/fetch_placement_post.py new file mode 100644 index 0000000..ac98a53 --- /dev/null +++ b/src/controller/worker/fetch_placement_post.py @@ -0,0 +1,17 @@ +import logging +from typing import TYPE_CHECKING + +from shared.worker_base import WorkerBase +from src import deps + +if TYPE_CHECKING: + pass + +log = logging.getLogger(__name__) + + +class FetchPlacementPostWorker(WorkerBase): + """Worker that fetches posts from channels and creates PlacementPosts from Placements""" + + async def _cycle_func(self) -> None: + await deps.get_usecase().fetch_placement_post_cycle(self.config.INTERVAL_SECONDS) diff --git a/src/deps.py b/src/deps.py new file mode 100644 index 0000000..a991228 --- /dev/null +++ b/src/deps.py @@ -0,0 +1,42 @@ +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from src.adapter.jwt import JWT, JWTPayload +from src.config import settings +from src.usecase import Usecase + +_usecase_instance: Usecase | None = None # Singleton + + +def set_usecase(usecase: Usecase) -> None: + global _usecase_instance + _usecase_instance = usecase + + +def get_usecase() -> Usecase: + if _usecase_instance is None: + raise RuntimeError('Usecase not initialized. Call set_usecase() first') + + return _usecase_instance + + +security = HTTPBearer() + + +def get_current_user(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]) -> JWTPayload: + token = credentials.credentials + + jwt_decoder = JWT(settings.jwt) + + try: + payload: JWTPayload = jwt_decoder.decode_access_token(token) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e), + headers={'WWW-Authenticate': 'Bearer'}, + ) from e + + return payload diff --git a/src/domain/__init__.py b/src/domain/__init__.py new file mode 100644 index 0000000..f521ad3 --- /dev/null +++ b/src/domain/__init__.py @@ -0,0 +1,158 @@ +__all__ = ( + 'User', + 'TelegramUser', + 'Workspace', + 'WorkspaceUser', + 'WorkspaceInvite', + 'WorkspaceInviteStatus', + 'WorkspaceUserStatus', + 'WorkspaceUserPermission', + 'WorkspaceUserPermissionScope', + 'WorkspacePermissions', + 'WorkspacePermissionContext', + 'build_workspace_permission_context', + 'PermissionKey', + 'PermissionScopeType', + 'Channel', + 'Project', + 'ProjectStatus', + 'Placement', + 'PlacementStatus', + 'PlacementType', + 'parse_format_duration', + 'parse_format_string', + 'format_display_string', + 'get_feed_duration_seconds', + 'COMMON_FORMATS', + 'PlacementPost', + 'PlacementPostStatus', + 'Creative', + 'CreativeMedia', + 'replace_invite_link_with_tag', + 'validate_media_size', + 'validate_media_items', + 'MAX_CREATIVE_MEDIA_BYTES', + 'MAX_CREATIVE_MEDIA_ITEMS', + 'validate_workspace_avatar_size', + 'MAX_WORKSPACE_AVATAR_BYTES', + 'Post', + 'Subscription', + 'PostViewsHistory', + 'ChannelNotFound', + 'ProjectNotFound', + 'CreativeStatus', + 'CreativeTag', + 'SubscriptionStatus', + 'InviteLinkType', + 'CostType', + 'LoginToken', + 'UserNotFound', + 'WorkspaceNotFound', + 'WorkspaceAccessDenied', + 'WorkspaceInviteNotFound', + 'WorkspaceInviteAlreadyExists', + 'WorkspaceInviteAlreadyProcessed', + 'WorkspaceMemberAlreadyExists', + 'WorkspaceAvatarTooLarge', + 'LoginTokenNotFound', + 'LoginTokenExpired', + 'LoginTokenAlreadyUsed', + 'ProjectNotFound', + 'ProjectChannelConflict', + 'PlacementNotFound', + 'PlacementPostNotFound', + 'PlacementHasPosts', + 'ChannelNotFound', + 'ChannelAlreadyExists', + 'ChannelNoAdminRights', + 'TelegramChannelNotFound', + 'CreativeNotFound', + 'CreativeInUse', + 'CreativeInviteLinkNotFound', + 'CreativeMultipleInviteLinks', + 'CreativeMediaTooLarge', + 'CreativeMediaTooMany', + 'CreativeMediaGroupUnsupported', + 'UserByUsernameNotFound', +) + +from .channel import Channel +from .creative import ( + MAX_CREATIVE_MEDIA_BYTES, + MAX_CREATIVE_MEDIA_ITEMS, + Creative, + CreativeMedia, + CreativeStatus, + CreativeTag, + replace_invite_link_with_tag, + validate_media_items, + validate_media_size, +) +from .error import ( + ChannelAlreadyExists, + ChannelNoAdminRights, + ChannelNotFound, + CreativeInUse, + CreativeInviteLinkNotFound, + CreativeMediaGroupUnsupported, + CreativeMediaTooLarge, + CreativeMediaTooMany, + CreativeMultipleInviteLinks, + CreativeNotFound, + LoginTokenAlreadyUsed, + LoginTokenExpired, + LoginTokenNotFound, + PlacementHasPosts, + PlacementNotFound, + PlacementPostNotFound, + ProjectChannelConflict, + ProjectNotFound, + TelegramChannelNotFound, + UserByUsernameNotFound, + UserNotFound, + WorkspaceAccessDenied, + WorkspaceAvatarTooLarge, + WorkspaceInviteAlreadyExists, + WorkspaceInviteAlreadyProcessed, + WorkspaceInviteNotFound, + WorkspaceMemberAlreadyExists, + WorkspaceNotFound, +) +from .login_token import LoginToken +from .placement import ( + COMMON_FORMATS, + CostType, + InviteLinkType, + Placement, + PlacementStatus, + PlacementType, + format_display_string, + get_feed_duration_seconds, + parse_format_duration, + parse_format_string, +) +from .placement_post import PlacementPost, PlacementPostStatus +from .post import Post +from .post_views_history import PostViewsHistory +from .project import Project, ProjectStatus +from .subscription import Subscription, SubscriptionStatus +from .telegram_user import TelegramUser +from .user import User +from .workspace import ( + MAX_WORKSPACE_AVATAR_BYTES, + PermissionKey, + PermissionScopeType, + Workspace, + WorkspaceInvite, + WorkspaceInviteStatus, + WorkspaceUser, + WorkspaceUserPermission, + WorkspaceUserPermissionScope, + WorkspaceUserStatus, + validate_workspace_avatar_size, +) +from .workspace_permissions import ( + WorkspacePermissionContext, + WorkspacePermissions, + build_workspace_permission_context, +) diff --git a/src/domain/base.py b/src/domain/base.py new file mode 100644 index 0000000..5e7cbc2 --- /dev/null +++ b/src/domain/base.py @@ -0,0 +1,12 @@ +from tortoise import fields +from tortoise.models import Model + + +class TimestampedModel(Model): + id = fields.UUIDField(pk=True) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + deleted_at = fields.DatetimeField(null=True) + + class Meta: + abstract = True diff --git a/src/domain/channel.py b/src/domain/channel.py new file mode 100644 index 0000000..0faa491 --- /dev/null +++ b/src/domain/channel.py @@ -0,0 +1,17 @@ +from tortoise import fields + +from .base import TimestampedModel + + +class Channel(TimestampedModel): + telegram_id = fields.BigIntField(unique=True, index=True) + title = fields.CharField(max_length=255) + username = fields.CharField(max_length=255, unique=True, index=True, null=True) + + access_hash = fields.BigIntField(null=True) + pts = fields.IntField(null=True) + invite_link = fields.CharField(max_length=1024, null=True) + is_accessible = fields.BooleanField(default=True) + + class Meta: + table = 'channel' diff --git a/src/domain/creative.py b/src/domain/creative.py new file mode 100644 index 0000000..71af5c7 --- /dev/null +++ b/src/domain/creative.py @@ -0,0 +1,136 @@ +import enum +import re +from typing import TYPE_CHECKING +from uuid import UUID + +from tortoise import fields + +from .base import TimestampedModel +from .error import ( + CreativeInviteLinkNotFound, + CreativeMediaGroupUnsupported, + CreativeMediaTooLarge, + CreativeMediaTooMany, +) + +if TYPE_CHECKING: + + from .project import Project + from .user import User + + +class CreativeStatus(str, enum.Enum): + ACTIVE = 'active' + ARCHIVED = 'archived' + + +class CreativeTag(str, enum.Enum): + TESTING = 'testing' # Тестовый + PRODUCTION = 'production' # Рабочий + + +class Creative(TimestampedModel): + name = fields.CharField(max_length=255) + text = fields.TextField() + buttons = fields.JSONField(default=list) + status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE) + tag = fields.CharEnumField(CreativeTag, default=CreativeTag.TESTING) + + project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( + 'models.Project', related_name='creatives', on_delete=fields.CASCADE, index=True + ) + created_by_user: fields.ForeignKeyRelation['User'] | None = fields.ForeignKeyField( + 'models.User', related_name='created_creatives', on_delete=fields.SET_NULL, null=True, index=True + ) + + if TYPE_CHECKING: + project_id: UUID + created_by_user_id: UUID | None + media_items: 'fields.ReverseRelation[CreativeMedia]' + + class Meta: + table = 'creative' + + +class CreativeMedia(TimestampedModel): + media_type = fields.CharField(max_length=32) + media_file_id = fields.CharField(max_length=512) + media_s3_key = fields.CharField(max_length=512, null=True) + position = fields.IntField() + + creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField( + 'models.Creative', related_name='media_items', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + creative_id: UUID + + class Meta: + table = 'creative_media' + unique_together = (('creative_id', 'position'),) + + +MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024 +MAX_CREATIVE_MEDIA_ITEMS = 10 + + +_INVITE_LINK_HTML = re.compile( + r'(.*?)', + re.IGNORECASE | re.DOTALL, +) +_INVITE_LINK_PLAIN = re.compile(r'https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+', re.IGNORECASE) +_INVITE_LINK_REPLACED = re.compile(r'(.*?)', re.IGNORECASE | re.DOTALL) + + +def replace_invite_link_with_tag(text: str) -> str: + """Replace ALL Telegram invite links (t.me/+xxx) with tracking tags.""" + # Check for any invite links first (excluding tg-link which are already replaced) + replaced = _INVITE_LINK_REPLACED.findall(text) + html_links = _INVITE_LINK_HTML.findall(text) + + # Remove HTML links and tg-link tags to find plain text links + text_without_links = _INVITE_LINK_HTML.sub('', text) + text_without_links = _INVITE_LINK_REPLACED.sub('', text_without_links) + plain_links = _INVITE_LINK_PLAIN.findall(text_without_links) + + total = len(replaced) + len(html_links) + len(plain_links) + + if total == 0: + raise CreativeInviteLinkNotFound() + + # Normalize already replaced links (preserve anchor text) + def _normalize_replaced(match: re.Match[str]) -> str: + inner = match.group(1).strip() + if inner == "" or _INVITE_LINK_PLAIN.search(inner): + return '' + return f'{inner}' + + text = _INVITE_LINK_REPLACED.sub(_normalize_replaced, text) + + # Replace HTML links with tracking tags (preserve anchor text) + def _replace_html(match: re.Match[str]) -> str: + inner = match.group(1).strip() + if _INVITE_LINK_PLAIN.fullmatch(inner): + return '' + return f'{inner}' + + text = _INVITE_LINK_HTML.sub(_replace_html, text) + + # Replace plain text invite links + text = _INVITE_LINK_PLAIN.sub('', text) + + return text + + +def validate_media_size(media_data: bytes | None) -> None: + if media_data is None: + return + if len(media_data) > MAX_CREATIVE_MEDIA_BYTES: + raise CreativeMediaTooLarge(MAX_CREATIVE_MEDIA_BYTES) + + +def validate_media_items(media_types: list[str]) -> None: + if len(media_types) > MAX_CREATIVE_MEDIA_ITEMS: + raise CreativeMediaTooMany(MAX_CREATIVE_MEDIA_ITEMS) + if len(media_types) > 1 and "animation" in media_types: + raise CreativeMediaGroupUnsupported() diff --git a/src/domain/error.py b/src/domain/error.py new file mode 100644 index 0000000..f5f5f51 --- /dev/null +++ b/src/domain/error.py @@ -0,0 +1,146 @@ +import uuid + +from fastapi import HTTPException, status + + +def UserNotFound(user_id: uuid.UUID | None = None) -> HTTPException: + if user_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'User {user_id} not found') + + +def UserByUsernameNotFound(username: str) -> HTTPException: + return HTTPException(status.HTTP_404_NOT_FOUND, f'User @{username} not found') + + +def LoginTokenNotFound() -> HTTPException: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Login token not found') + + +def LoginTokenExpired() -> HTTPException: + return HTTPException(status.HTTP_400_BAD_REQUEST, 'Login token has expired') + + +def LoginTokenAlreadyUsed() -> HTTPException: + return HTTPException(status.HTTP_400_BAD_REQUEST, 'Login token has already been used') + + +def ChannelNotFound(channel_id: uuid.UUID | None = None) -> HTTPException: + if channel_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Channel not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Channel {channel_id} not found') + + +def TelegramChannelNotFound(username: str) -> HTTPException: + return HTTPException( + status.HTTP_404_NOT_FOUND, f'Telegram channel @{username} not found or is not a public channel' + ) + + +def ProjectNotFound(project_id: uuid.UUID | None = None) -> HTTPException: + if project_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Project not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Project {project_id} not found') + + +def ProjectChannelConflict() -> HTTPException: + return HTTPException(status.HTTP_409_CONFLICT, 'Project channel already exists in target workspace') + + +def PlacementNotFound(placement_id: uuid.UUID | None = None) -> HTTPException: + if placement_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Placement not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Placement {placement_id} not found') + + +def PlacementPostNotFound(placement_post_id: uuid.UUID | None = None) -> HTTPException: + if placement_post_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'PlacementPost not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'PlacementPost {placement_post_id} not found') + + +def PlacementHasPosts(placement_id: uuid.UUID) -> HTTPException: + return HTTPException( + status.HTTP_400_BAD_REQUEST, + f'Placement {placement_id} has placement_posts and cannot remove creative', + ) + + +def ChannelAlreadyExists(telegram_id: int) -> HTTPException: + return HTTPException(status.HTTP_409_CONFLICT, f'Channel {telegram_id} already exists in the system') + + +def ChannelNoAdminRights() -> HTTPException: + return HTTPException( + status.HTTP_403_FORBIDDEN, 'Bot must be added as administrator with invite link creation rights' + ) + + +def CreativeNotFound(creative_id: uuid.UUID | None = None) -> HTTPException: + if creative_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Creative not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Creative {creative_id} not found') + + +def CreativeInviteLinkNotFound() -> HTTPException: + return HTTPException(status.HTTP_400_BAD_REQUEST, 'Creative text must contain one invite link (t.me/+xxx)') + + +def CreativeMultipleInviteLinks() -> HTTPException: + return HTTPException(status.HTTP_400_BAD_REQUEST, 'Creative text must contain only one invite link') + + +def CreativeMediaTooLarge(max_bytes: int) -> HTTPException: + return HTTPException(status.HTTP_400_BAD_REQUEST, f'Creative media is too large (max {max_bytes} bytes)') + + +def CreativeMediaTooMany(max_items: int) -> HTTPException: + return HTTPException(status.HTTP_400_BAD_REQUEST, f'Creative media exceeds max items ({max_items})') + + +def CreativeMediaGroupUnsupported() -> HTTPException: + return HTTPException( + status.HTTP_400_BAD_REQUEST, + 'Creative media group supports only photo/video; animation allowed only as a single item', + ) + + +def CreativeInUse(creative_id: uuid.UUID) -> HTTPException: + return HTTPException( + status.HTTP_400_BAD_REQUEST, + f'Creative {creative_id} is used in active placement_posts and cannot be deleted', + ) + + +def WorkspaceNotFound(workspace_id: uuid.UUID | None = None) -> HTTPException: + if workspace_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Workspace not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Workspace {workspace_id} not found') + + +def WorkspaceAccessDenied(workspace_id: uuid.UUID | None = None) -> HTTPException: + if workspace_id is None: + return HTTPException(status.HTTP_403_FORBIDDEN, 'Workspace access denied') + return HTTPException(status.HTTP_403_FORBIDDEN, f'Workspace {workspace_id} access denied') + + +def WorkspaceInviteNotFound(invite_id: uuid.UUID | None = None) -> HTTPException: + if invite_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Workspace invite not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Workspace invite {invite_id} not found') + + +def WorkspaceInviteAlreadyExists() -> HTTPException: + return HTTPException(status.HTTP_409_CONFLICT, 'Workspace invite already exists for this user') + + +def WorkspaceInviteAlreadyProcessed() -> HTTPException: + return HTTPException(status.HTTP_400_BAD_REQUEST, 'Workspace invite has already been processed') + + +def WorkspaceMemberAlreadyExists() -> HTTPException: + return HTTPException(status.HTTP_409_CONFLICT, 'User is already a workspace member') + + +def WorkspaceAvatarTooLarge(max_bytes: int) -> HTTPException: + return HTTPException(status.HTTP_400_BAD_REQUEST, f'Workspace avatar is too large (max {max_bytes} bytes)') diff --git a/src/domain/login_token.py b/src/domain/login_token.py new file mode 100644 index 0000000..e9a0e56 --- /dev/null +++ b/src/domain/login_token.py @@ -0,0 +1,25 @@ +from typing import TYPE_CHECKING +from uuid import UUID + +from tortoise import fields + +from .base import TimestampedModel + +if TYPE_CHECKING: + from .user import User + + +class LoginToken(TimestampedModel): + token = fields.CharField(max_length=255, unique=True, index=True) + user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField( + 'models.User', related_name='login_tokens', on_delete=fields.CASCADE, index=True + ) + expires_at = fields.DatetimeField() + used_at = fields.DatetimeField(null=True) + message_id = fields.IntField(null=True) + + class Meta: + table = 'login_token' + + if TYPE_CHECKING: + user_id: UUID diff --git a/src/domain/placement.py b/src/domain/placement.py new file mode 100644 index 0000000..80e49dc --- /dev/null +++ b/src/domain/placement.py @@ -0,0 +1,289 @@ +import enum +import re +import uuid +from typing import TYPE_CHECKING + +from tortoise import fields + +from .base import TimestampedModel + +if TYPE_CHECKING: + from .channel import Channel + from .creative import Creative + from .project import Project + +__all__ = [ + 'Placement', + 'PlacementStatus', + 'PlacementType', + 'InviteLinkType', + 'CostType', + 'parse_format_duration', + 'parse_format_string', + 'format_display_string', + 'get_feed_duration_seconds', + 'COMMON_FORMATS', +] + + +class CostType(enum.StrEnum): + FIXED = 'fixed' + CPM = 'cpm' + + +class PlacementStatus(enum.StrEnum): + NO_STATUS = 'Без статуса' + WRITE = 'Написать' + WAITING_RESPONSE = 'Ждём ответа' + TERMS_APPROVAL = 'Согласование условий' + TO_PAY = 'Оплатить' + PAID = 'Оплачено' + CANCELED = 'Отмена' + PRICE_NOT_OK = 'Не подходит цена' + NOT_RELEVANT = 'Неактуально' + NO_RESPONSE = 'Не отвечает' + + +class PlacementType(enum.StrEnum): + SELF_PROMO = 'self_promo' + STANDARD = 'standard' + + +class InviteLinkType(enum.StrEnum): + PUBLIC = 'public' # открытая ссылка + APPROVAL = 'approval' # с одобрением ботом + + +def parse_format_string(format_str: str | None) -> tuple[int | None, int | None]: + """ + Парсит строку формата размещения в числовые значения (минуты). + + Разделяет строку по '/' на 2 части (top, feed). + Top: число <= 12 → часы (*60), > 12 → минуты (для миграции старых данных). + Feed: "без удаления" → 0; "Xд"/"X дней" → дни (*24*60); число → часы (*60). + + Returns: + (top_time_minutes, feed_time_minutes) — (None, None) если не распознано + """ + if not format_str: + return None, None + + format_str = format_str.strip().lower() + + if not format_str: + return None, None + + # Проверяем наличие разделителя + if '/' not in format_str: + return None, None + + parts = format_str.split('/', 1) + if len(parts) != 2: + return None, None + + top_str = parts[0].strip().strip('(').strip(')') + feed_str = parts[1].strip().strip('(').strip(')') + + # Парсим top + top_minutes = _parse_top_part(top_str) + if top_minutes is None: + return None, None + + # Парсим feed + feed_minutes = _parse_feed_part(feed_str) + if feed_minutes is None: + return None, None + + return top_minutes, feed_minutes + + +def _parse_top_part(s: str) -> int | None: + """Парсит часть top из строки формата.""" + s = s.strip() + # Убираем суффиксы единиц + s = re.sub(r'\s*(ч|час|часов|часа|мин|минут|минуты)\s*$', '', s) + s = s.strip() + + match = re.match(r'^(\d+)$', s) + if not match: + return None + + value = int(match.group(1)) + if value <= 0: + return None + + # <= 12 → часы, > 12 → минуты (для обратной совместимости со старыми данными) + if value <= 12: + return value * 60 + return value + + +def _parse_feed_part(s: str) -> int | None: + """Парсит часть feed из строки формата. Возвращает минуты, 0 = без удаления.""" + s = s.strip() + + # "без удаления" + if 'без удаления' in s: + return 0 + + # Ищем дни: "7д", "7 дней", "7 дн", "(7 дней)" + days_match = re.search(r'(\d+)\s*(?:д(?:н|ней|ня)?)', s) + if days_match: + days = int(days_match.group(1)) + if days <= 0: + return None + return days * 24 * 60 + + # Ищем часы: "24ч", "24 часов", или просто число + s = re.sub(r'\s*(ч|час|часов|часа|мин|минут|минуты)\s*$', '', s) + s = s.strip().strip('(').strip(')') + + match = re.match(r'^(\d+)$', s) + if not match: + return None + + value = int(match.group(1)) + if value <= 0: + return None + + # Значение — часы, конвертируем в минуты + return value * 60 + + +def format_display_string(top_minutes: int | None, feed_minutes: int | None) -> str | None: + """ + Формирует строку отображения из числовых значений. + + Returns: + Строка вида "1ч / 24ч", "30мин / 7д", "1ч / без удаления", или None + """ + if top_minutes is None and feed_minutes is None: + return None + + top_str = _format_duration_top(top_minutes) if top_minutes is not None else '?' + feed_str = _format_duration_feed(feed_minutes) if feed_minutes is not None else '?' + + return f'{top_str} / {feed_str}' + + +def _format_duration_top(minutes: int) -> str: + """Форматирует время топа: кратно 60 → 'Xч', иначе → 'Xмин'.""" + if minutes > 0 and minutes % 60 == 0: + return f'{minutes // 60}ч' + return f'{minutes}мин' + + +def _format_duration_feed(minutes: int) -> str: + """Форматирует время ленты: 0 → 'без удаления', ≥7д и кратно дню → 'Xд', кратно 60 → 'Xч', иначе → 'Xмин'.""" + if minutes == 0: + return 'без удаления' + # Дни используем только для >= 7 дней (10080 мин), иначе часы (24ч, 48ч, 72ч выглядят привычнее) + if minutes >= 7 * 24 * 60 and minutes % (24 * 60) == 0: + return f'{minutes // (24 * 60)}д' + if minutes % 60 == 0: + return f'{minutes // 60}ч' + return f'{minutes}мин' + + +def parse_format_duration(format_str: str | None) -> int | None: + """ + Парсит формат размещения и возвращает длительность ленты в секундах. + Обёртка для обратной совместимости. + + Примеры форматов: + - "1 / 24" -> 86400 сек (24 часа) + - "1/48" -> 172800 сек (48 часов) + - "1 / 72" -> 259200 сек (72 часа) + - "1 / (7 дней)" -> 604800 сек (7 дней) + - "1 / (30 дней)" -> 2592000 сек (30 дней) + - "1 / (без удаления)" -> None (не удалять) + - "2 / 24" -> 86400 сек (24 часа) + + Returns: + int | None: Длительность в секундах или None для "без удаления" + """ + _, feed_minutes = parse_format_string(format_str) + if feed_minutes is None or feed_minutes == 0: + return None + return feed_minutes * 60 + + +def get_feed_duration_seconds(placement: 'Placement') -> int | None: + """ + Возвращает длительность ленты в секундах для placement. + + Сначала проверяет числовые поля (feed_time_minutes), + потом fallback на parse_format_duration(placement.format). + 0 = без удаления → None. + + Returns: + int | None: Длительность в секундах или None + """ + if placement.feed_time_minutes is not None: + if placement.feed_time_minutes == 0: + return None + return placement.feed_time_minutes * 60 + + return parse_format_duration(placement.format) + + +COMMON_FORMATS: list[dict[str, int | str]] = [ + {'top_time_minutes': 60, 'feed_time_minutes': 1440, 'label': '1ч / 24ч'}, + {'top_time_minutes': 60, 'feed_time_minutes': 2160, 'label': '1ч / 36ч'}, + {'top_time_minutes': 60, 'feed_time_minutes': 2880, 'label': '1ч / 48ч'}, + {'top_time_minutes': 60, 'feed_time_minutes': 4320, 'label': '1ч / 72ч'}, + {'top_time_minutes': 60, 'feed_time_minutes': 10080, 'label': '1ч / 7д'}, + {'top_time_minutes': 60, 'feed_time_minutes': 43200, 'label': '1ч / 30д'}, + {'top_time_minutes': 60, 'feed_time_minutes': 86400, 'label': '1ч / 60д'}, + {'top_time_minutes': 60, 'feed_time_minutes': 129600, 'label': '1ч / 90д'}, + {'top_time_minutes': 60, 'feed_time_minutes': 0, 'label': '1ч / без удаления'}, + {'top_time_minutes': 120, 'feed_time_minutes': 1440, 'label': '2ч / 24ч'}, + {'top_time_minutes': 120, 'feed_time_minutes': 2160, 'label': '2ч / 36ч'}, + {'top_time_minutes': 120, 'feed_time_minutes': 2880, 'label': '2ч / 48ч'}, + {'top_time_minutes': 120, 'feed_time_minutes': 4320, 'label': '2ч / 72ч'}, + {'top_time_minutes': 120, 'feed_time_minutes': 10080, 'label': '2ч / 7д'}, + {'top_time_minutes': 120, 'feed_time_minutes': 43200, 'label': '2ч / 30д'}, + {'top_time_minutes': 120, 'feed_time_minutes': 86400, 'label': '2ч / 60д'}, + {'top_time_minutes': 120, 'feed_time_minutes': 129600, 'label': '2ч / 90д'}, + {'top_time_minutes': 120, 'feed_time_minutes': 0, 'label': '2ч / без удаления'}, +] + + +class Placement(TimestampedModel): + status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.NO_STATUS) + placement_at = fields.DatetimeField(null=True) + payment_at = fields.DatetimeField(null=True) + cost_type = fields.CharEnumField(CostType, null=True, max_length=8) + cost_value = fields.FloatField(null=True) + cost_before_bargain_type = fields.CharEnumField(CostType, null=True, max_length=8) + cost_before_bargain = fields.FloatField(null=True) + placement_type = fields.CharEnumField(PlacementType, null=True, max_length=16) + format = fields.TextField(null=True) + top_time_minutes = fields.IntField(null=True) + feed_time_minutes = fields.IntField(null=True) + comment = fields.TextField(null=True) + + invite_link = fields.CharField(max_length=512, null=True) + invite_link_created_at = fields.DatetimeField(null=True) + invite_link_type = fields.CharEnumField(InviteLinkType) + + invite_link_name = fields.CharField(max_length=32, null=True) + + project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( + 'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True + ) + creative: fields.ForeignKeyRelation['Creative'] | None = fields.ForeignKeyField( + 'models.Creative', related_name='placements', on_delete=fields.CASCADE, null=True, index=True + ) + channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( + 'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + project_id: uuid.UUID + creative_id: uuid.UUID | None + channel_id: uuid.UUID + + class Meta: + table = 'placement' + indexes = (('project', 'status'),) diff --git a/src/domain/placement_post.py b/src/domain/placement_post.py new file mode 100644 index 0000000..a613552 --- /dev/null +++ b/src/domain/placement_post.py @@ -0,0 +1,49 @@ +import enum +from typing import TYPE_CHECKING +from uuid import UUID + +from tortoise import fields + +from .base import TimestampedModel + +if TYPE_CHECKING: + from .placement import Placement + from .post import Post + +__all__ = ['PlacementPost', 'PlacementPostStatus'] + + +class PlacementPostStatus(enum.StrEnum): + # Ручные статусы + NO_STATUS = 'Без статуса' + SEND_POST = 'Отправить пост' + POST_APPROVAL = 'Согласование поста' + WAITING_SCHEDULE = 'Ожидание отложки' + SCHEDULED = 'Запланирован' + + # Автоматические статусы + POST_PUBLISHED = 'Пост вышел' + COMPLETED_DELETED = 'Размещение отработало - пост удалён' + COMPLETED_NOT_DELETED = 'Размещение отработало - пост не удалён' + + # Проверить (требуют внимания) + CHECK_DELETED_EARLY = 'Проверить - пост удалён раньше срока' + CHECK_NOT_PUBLISHED = 'Проверить - пост не вышел' + CHECK_COMPLETED = 'Размещение отработало' # ручной после проверки + + +class PlacementPost(TimestampedModel): + placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField( + 'models.Placement', related_name='placement_posts', on_delete=fields.CASCADE, index=True + ) + post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField( + 'models.Post', related_name='placement_posts', on_delete=fields.SET_NULL, null=True, index=True + ) + status = fields.CharEnumField(PlacementPostStatus, default=PlacementPostStatus.NO_STATUS, max_length=64) + + if TYPE_CHECKING: + post_id: UUID | None + placement_id: UUID + + class Meta: + table = 'placement_post' diff --git a/src/domain/post.py b/src/domain/post.py new file mode 100644 index 0000000..54b2a73 --- /dev/null +++ b/src/domain/post.py @@ -0,0 +1,42 @@ +import uuid +from typing import TYPE_CHECKING + +from tortoise import fields + +from .base import TimestampedModel + +if TYPE_CHECKING: + from .channel import Channel + + +class Post(TimestampedModel): + message_id = fields.IntField() + text = fields.TextField() + deleted_from_channel_at = fields.DatetimeField(null=True) + published_at = fields.DatetimeField(null=True) + + channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( + 'models.Channel', related_name='posts', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + channel_id: uuid.UUID + + class Meta: + table = 'post' + unique_together = (('channel_id', 'message_id'),) + + @property + def url(self) -> str | None: + if self.channel.username: + return f'https://t.me/{self.channel.username}/{self.message_id}' + + telegram_id = self.channel.telegram_id + if telegram_id is None: + return None + + channel_id = telegram_id + if telegram_id < 0: + channel_id = -telegram_id - 1000000000000 + + return f'https://t.me/c/{channel_id}/{self.message_id}' diff --git a/src/domain/post_views_history.py b/src/domain/post_views_history.py new file mode 100644 index 0000000..e2ff5aa --- /dev/null +++ b/src/domain/post_views_history.py @@ -0,0 +1,25 @@ +from typing import TYPE_CHECKING +from uuid import UUID + +from tortoise import fields + +from .base import TimestampedModel + +if TYPE_CHECKING: + from .post import Post + + +class PostViewsHistory(TimestampedModel): + views_count = fields.IntField() # Количество просмотров на момент снимка + fetched_at = fields.DatetimeField(index=True) + + post: fields.ForeignKeyRelation['Post'] = fields.ForeignKeyField( + 'models.Post', related_name='views_histories', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + post_id: UUID + + class Meta: + table = 'post_views_history' + unique_together = (('post_id', 'fetched_at'),) diff --git a/src/domain/project.py b/src/domain/project.py new file mode 100644 index 0000000..5fe405b --- /dev/null +++ b/src/domain/project.py @@ -0,0 +1,39 @@ +import enum +from typing import TYPE_CHECKING +from uuid import UUID + +from tortoise import fields + +from .base import TimestampedModel +from .placement import InviteLinkType + +if TYPE_CHECKING: + from .channel import Channel + from .workspace import Workspace + + +class ProjectStatus(str, enum.Enum): + ACTIVE = 'active' + INACTIVE = 'inactive' + ARCHIVED = 'archived' + + +class Project(TimestampedModel): + status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE) + purchase_invite_type_default = fields.CharEnumField(InviteLinkType, default=InviteLinkType.APPROVAL, max_length=10) + + channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( + 'models.Channel', related_name='projects', on_delete=fields.CASCADE, index=True + ) + + workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( + 'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + channel_id: UUID + workspace_id: UUID + + class Meta: + table = 'project' + unique_together = (('workspace_id', 'channel_id'),) diff --git a/src/domain/subscription.py b/src/domain/subscription.py new file mode 100644 index 0000000..aeb367b --- /dev/null +++ b/src/domain/subscription.py @@ -0,0 +1,37 @@ +import enum +from typing import TYPE_CHECKING +from uuid import UUID + +from tortoise import fields + +from .base import TimestampedModel + +if TYPE_CHECKING: + from .placement import Placement + from .telegram_user import TelegramUser + + +class SubscriptionStatus(str, enum.Enum): + ACTIVE = 'active' + UNSUBSCRIBED = 'unsubscribed' + + +class Subscription(TimestampedModel): + invite_link = fields.CharField(max_length=512, index=True) + status = fields.CharEnumField(SubscriptionStatus, default=SubscriptionStatus.ACTIVE) + unsubscribed_at = fields.DatetimeField(null=True) + + placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField( + 'models.Placement', related_name='subscriptions', on_delete=fields.CASCADE, index=True + ) + telegram_user: fields.ForeignKeyRelation['TelegramUser'] = fields.ForeignKeyField( + 'models.TelegramUser', related_name='subscriptions', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + placement_id: UUID + telegram_user_id: UUID + + class Meta: + table = 'subscription' + unique_together = (('placement_id', 'telegram_user_id'),) diff --git a/src/domain/telegram_user.py b/src/domain/telegram_user.py new file mode 100644 index 0000000..42cf8a0 --- /dev/null +++ b/src/domain/telegram_user.py @@ -0,0 +1,13 @@ +from tortoise import fields + +from .base import TimestampedModel + + +class TelegramUser(TimestampedModel): + telegram_id = fields.BigIntField(unique=True, index=True) + username = fields.CharField(max_length=255, null=True) + first_name = fields.CharField(max_length=255, null=True) + last_name = fields.CharField(max_length=255, null=True) + + class Meta: + table = 'telegram_user' diff --git a/src/domain/user.py b/src/domain/user.py new file mode 100644 index 0000000..87424c7 --- /dev/null +++ b/src/domain/user.py @@ -0,0 +1,21 @@ +from typing import TYPE_CHECKING +from uuid import UUID + +from tortoise import fields + +from .base import TimestampedModel + +if TYPE_CHECKING: + from .telegram_user import TelegramUser + + +class User(TimestampedModel): + telegram_user: fields.ForeignKeyRelation['TelegramUser'] = fields.OneToOneField( + 'models.TelegramUser', related_name='user', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + telegram_user_id: UUID + + class Meta: + table = 'user' diff --git a/src/domain/workspace.py b/src/domain/workspace.py new file mode 100644 index 0000000..b5511f8 --- /dev/null +++ b/src/domain/workspace.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import enum +import uuid +from typing import TYPE_CHECKING + +from tortoise import fields + +from .base import TimestampedModel +from .error import WorkspaceAvatarTooLarge + +if TYPE_CHECKING: + from .creative import Creative + from .placement import Placement + from .project import Project + from .user import User + + +class Workspace(TimestampedModel): + id = fields.UUIDField(pk=True) + name = fields.CharField(max_length=255) + avatar_s3_key = fields.CharField(max_length=512, null=True) + + class Meta: + table = 'workspace' + + +class WorkspaceUserStatus(str, enum.Enum): + ACTIVE = 'active' + INVITED = 'invited' + BLOCKED = 'blocked' + + +class WorkspaceUser(TimestampedModel): + status = fields.CharEnumField(WorkspaceUserStatus, default=WorkspaceUserStatus.ACTIVE) + + workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField( + 'models.Workspace', related_name='workspace_users', on_delete=fields.CASCADE, index=True + ) + user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( + 'models.User', related_name='workspace_users', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + workspace_id: uuid.UUID + user_id: uuid.UUID + + class Meta: + table = 'workspace_user' + unique_together = (('workspace_id', 'user_id'),) + + +class WorkspaceInviteStatus(str, enum.Enum): + PENDING = 'pending' + ACCEPTED = 'accepted' + REVOKED = 'revoked' + + +class WorkspaceInvite(TimestampedModel): + status = fields.CharEnumField(WorkspaceInviteStatus, default=WorkspaceInviteStatus.PENDING) + + workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField( + 'models.Workspace', related_name='invites', on_delete=fields.CASCADE, index=True + ) + invited_by: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( + 'models.User', related_name='sent_invites', on_delete=fields.CASCADE, index=True + ) + user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( + 'models.User', related_name='workspace_invites', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + workspace_id: uuid.UUID + invited_by_id: uuid.UUID + user_id: uuid.UUID + + class Meta: + table = 'workspace_invite' + unique_together = (('workspace_id', 'user_id'),) + + +class PermissionKey(enum.StrEnum): + ADMIN_FULL = 'admin_full' + + PROJECTS_READ = 'projects_read' + PROJECTS_WRITE = 'projects_write' + + CREATIVES_READ = 'creatives_read' + CREATIVES_WRITE = 'creatives_write' + + PLACEMENTS_READ = 'placements_read' + PLACEMENTS_WRITE = 'placements_write' + + ANALYTICS_READ = 'analytics_read' + ANALYTICS_WITHOUT_CLICKS = 'analytics_without_clicks' + ANALYTICS_OWN_CREATIVES = 'analytics_own_creatives' + + @property + def description(self) -> str: + return { + PermissionKey.ADMIN_FULL: 'полный доступ', + PermissionKey.PROJECTS_READ: 'просмотр каталога каналов (проектов)', + PermissionKey.PROJECTS_WRITE: 'редактирование каталога каналов', + PermissionKey.CREATIVES_READ: 'просмотр креативов', + PermissionKey.CREATIVES_WRITE: 'создание/редактирование креативов', + PermissionKey.PLACEMENTS_READ: 'просмотр планов закупок', + PermissionKey.PLACEMENTS_WRITE: 'создание/редактирование закупок', + PermissionKey.ANALYTICS_READ: 'полный доступ к статистике', + PermissionKey.ANALYTICS_WITHOUT_CLICKS: 'статистика кроме переходов', + PermissionKey.ANALYTICS_OWN_CREATIVES: 'статистика только по своим креативам', + }.get(self, self.value) + + +class PermissionScopeType(enum.StrEnum): + PROJECT = 'project' + CREATIVE = 'creative' + PLACEMENT = 'placement' + + +class WorkspaceUserPermission(TimestampedModel): + permission = fields.CharEnumField(PermissionKey) + + workspace_user: fields.ForeignKeyRelation[WorkspaceUser] = fields.ForeignKeyField( + 'models.WorkspaceUser', related_name='permissions', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + workspace_user_id: uuid.UUID + + class Meta: + table = 'workspace_user_permission' + unique_together = (('workspace_user_id', 'permission'),) + + +class WorkspaceUserPermissionScope(TimestampedModel): + permission = fields.CharEnumField(PermissionKey) + + workspace_user: fields.ForeignKeyRelation[WorkspaceUser] = fields.ForeignKeyField( + 'models.WorkspaceUser', related_name='permission_scopes', on_delete=fields.CASCADE, index=True + ) + + # Exactly one of these must be set (enforced by CHECK constraint in migration) + project: fields.ForeignKeyRelation[Project] | None = fields.ForeignKeyField( + 'models.Project', related_name='permission_scopes', on_delete=fields.CASCADE, null=True, index=True + ) + creative: fields.ForeignKeyRelation[Creative] | None = fields.ForeignKeyField( + 'models.Creative', related_name='permission_scopes', on_delete=fields.CASCADE, null=True, index=True + ) + placement: fields.ForeignKeyRelation[Placement] | None = fields.ForeignKeyField( + 'models.Placement', related_name='permission_scopes', on_delete=fields.CASCADE, null=True, index=True + ) + + if TYPE_CHECKING: + workspace_user_id: uuid.UUID + project_id: uuid.UUID | None + creative_id: uuid.UUID | None + placement_id: uuid.UUID | None + + class Meta: + table = 'workspace_user_permission_scope' + + +MAX_WORKSPACE_AVATAR_BYTES = 5 * 1024 * 1024 + + +def validate_workspace_avatar_size(avatar_data: bytes | None) -> None: + if avatar_data is None: + return + if len(avatar_data) > MAX_WORKSPACE_AVATAR_BYTES: + raise WorkspaceAvatarTooLarge(MAX_WORKSPACE_AVATAR_BYTES) diff --git a/src/domain/workspace_permissions.py b/src/domain/workspace_permissions.py new file mode 100644 index 0000000..903e7aa --- /dev/null +++ b/src/domain/workspace_permissions.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from . import WorkspaceAccessDenied +from .workspace import ( + PermissionKey, + PermissionScopeType, + Workspace, + WorkspaceUser, +) + + +@dataclass +class WorkspacePermissions: + global_permissions: set[PermissionKey] + scoped_permissions: dict[tuple[PermissionKey, PermissionScopeType], set[UUID]] + + @classmethod + def from_membership(cls, membership: WorkspaceUser) -> WorkspacePermissions: + global_permissions = {permission.permission for permission in getattr(membership, 'permissions', []) or []} + + scoped_permissions: dict[tuple[PermissionKey, PermissionScopeType], set[UUID]] = {} + for scope in getattr(membership, 'permission_scopes', []) or []: + if scope.project_id: + key = (scope.permission, PermissionScopeType.PROJECT) + scoped_permissions.setdefault(key, set()).add(scope.project_id) + elif scope.creative_id: + key = (scope.permission, PermissionScopeType.CREATIVE) + scoped_permissions.setdefault(key, set()).add(scope.creative_id) + elif scope.placement_id: + key = (scope.permission, PermissionScopeType.PLACEMENT) + scoped_permissions.setdefault(key, set()).add(scope.placement_id) + + return cls(global_permissions=global_permissions, scoped_permissions=scoped_permissions) + + def has_global(self, permission: PermissionKey) -> bool: + return permission in self.global_permissions or PermissionKey.ADMIN_FULL in self.global_permissions + + def allowed_project_ids(self, permission: PermissionKey) -> set[UUID] | None: + if self.has_global(permission): + return None + + allowed: set[UUID] = set() + for key in (permission, PermissionKey.ADMIN_FULL): + project_scope = self.scoped_permissions.get((key, PermissionScopeType.PROJECT)) + if project_scope: + allowed.update(project_scope) + + return allowed + + def allowed_creative_ids(self, permission: PermissionKey) -> set[UUID] | None: + if self.has_global(permission): + return None + + allowed: set[UUID] = set() + for key in (permission, PermissionKey.ADMIN_FULL): + creative_scope = self.scoped_permissions.get((key, PermissionScopeType.CREATIVE)) + if creative_scope: + allowed.update(creative_scope) + + return allowed + + def allowed_placement_ids(self, permission: PermissionKey) -> set[UUID] | None: + if self.has_global(permission): + return None + + allowed: set[UUID] = set() + for key in (permission, PermissionKey.ADMIN_FULL): + placement_scope = self.scoped_permissions.get((key, PermissionScopeType.PLACEMENT)) + if placement_scope: + allowed.update(placement_scope) + + return allowed + + def has_permission( + self, + permission: PermissionKey, + *, + scope_type: PermissionScopeType | None = None, + scope_id: UUID | None = None, + ) -> bool: + if self.has_global(permission): + return True + + if scope_type is not None and scope_id is not None: + if scope_type == PermissionScopeType.PROJECT: + allowed = self.allowed_project_ids(permission) + elif scope_type == PermissionScopeType.CREATIVE: + allowed = self.allowed_creative_ids(permission) + elif scope_type == PermissionScopeType.PLACEMENT: + allowed = self.allowed_placement_ids(permission) + else: + return False + + if allowed is None: + return True + return scope_id in allowed + + return False + + def has_any(self, permission: PermissionKey) -> bool: + allowed = self.allowed_project_ids(permission) + if allowed is None: + return True + return bool(allowed) + + def has_any_analytics_permission(self) -> bool: + """Check if user has any analytics permission.""" + return ( + self.has_global(PermissionKey.ANALYTICS_READ) + or self.has_global(PermissionKey.ANALYTICS_WITHOUT_CLICKS) + or self.has_global(PermissionKey.ANALYTICS_OWN_CREATIVES) + ) + + def should_hide_subscriptions(self) -> bool: + """Check if subscription data should be hidden (user has analytics_without_clicks but not analytics_read).""" + if self.has_global(PermissionKey.ANALYTICS_READ): + return False + return self.has_global(PermissionKey.ANALYTICS_WITHOUT_CLICKS) + + def should_filter_own_creatives(self) -> bool: + """Check if analytics should be filtered to user's own creatives only.""" + if self.has_global(PermissionKey.ANALYTICS_READ): + return False + if self.has_global(PermissionKey.ANALYTICS_WITHOUT_CLICKS): + return False + return self.has_global(PermissionKey.ANALYTICS_OWN_CREATIVES) + + +@dataclass +class WorkspacePermissionContext: + workspace: Workspace + membership: WorkspaceUser + permissions: WorkspacePermissions + + def allowed_project_ids(self, permission: PermissionKey) -> set[UUID] | None: + return self.permissions.allowed_project_ids(permission) + + def allowed_creative_ids(self, permission: PermissionKey) -> set[UUID] | None: + return self.permissions.allowed_creative_ids(permission) + + def allowed_placement_ids(self, permission: PermissionKey) -> set[UUID] | None: + return self.permissions.allowed_placement_ids(permission) + + def ensure_project_permission(self, permission: PermissionKey, project_id: UUID) -> None: + if not self.permissions.has_permission( + permission, + scope_type=PermissionScopeType.PROJECT, + scope_id=project_id, + ): + raise WorkspaceAccessDenied(self.workspace.id) + + def ensure_creative_permission(self, permission: PermissionKey, creative_id: UUID) -> None: + if not self.permissions.has_permission( + permission, + scope_type=PermissionScopeType.CREATIVE, + scope_id=creative_id, + ): + raise WorkspaceAccessDenied(self.workspace.id) + + def ensure_placement_permission(self, permission: PermissionKey, placement_id: UUID) -> None: + if not self.permissions.has_permission( + permission, + scope_type=PermissionScopeType.PLACEMENT, + scope_id=placement_id, + ): + raise WorkspaceAccessDenied(self.workspace.id) + + def should_hide_subscriptions(self) -> bool: + """Check if subscription data should be hidden.""" + return self.permissions.should_hide_subscriptions() + + def should_filter_own_creatives(self) -> bool: + """Check if analytics should be filtered to user's own creatives only.""" + return self.permissions.should_filter_own_creatives() + + +def build_workspace_permission_context(membership: WorkspaceUser) -> WorkspacePermissionContext: + if membership.workspace is None: + raise ValueError('Workspace relation must be loaded for membership permissions') + + permissions = WorkspacePermissions.from_membership(membership) + + return WorkspacePermissionContext( + workspace=membership.workspace, + membership=membership, + permissions=permissions, + ) diff --git a/src/dto/__init__.py b/src/dto/__init__.py new file mode 100644 index 0000000..80be20c --- /dev/null +++ b/src/dto/__init__.py @@ -0,0 +1,208 @@ +__all__ = ( + 'UpdateProjectInviteLinkTypeInput', + 'UpdateProjectPermissionsInput', + 'GetWorkspaceProjectsInput', + 'GetWorkspaceProjectsOutput', + 'GetProjectInput', + 'ArchiveProjectInput', + 'MoveProjectRequest', + 'DisconnectProjectByTgIdInput', + 'ConnectProjectInput', + 'ProjectOutput', + 'ValidateLoginTokenInput', + 'ValidateLoginTokenOutput', + 'ChannelBotPermissions', + 'ChannelOutput', + 'GetChannelInput', + 'GetChannelsInput', + 'CreateChannelInput', + 'CreateChannelsInput', + 'CreateChannelResult', + 'CreateChannelsOutput', + 'GetChannelsOutput', + 'AttachChannelToWorkspaceInput', + 'PlacementOutput', + 'PlacementDetails', + 'CostInfo', + 'CreatePlacementsInput', + 'CreatePlacementChannelInput', + 'GetPlacementsInput', + 'GetPlacementsOutput', + 'GetPlacementInput', + 'UpdatePlacementInput', + 'DeletePlacementInput', + 'UpdatePlacementPostInput', + 'PlacementPostOutput', + 'PostOutput', + 'PlacementWithPostsOutput', + 'CreativeButton', + 'CreativeMediaInput', + 'CreativeMediaItem', + 'CreativeOutput', + 'CreativePreviewOutput', + 'GetCreativesInput', + 'GetCreativesOutput', + 'GetCreativeInput', + 'CreateCreativeInput', + 'UpdateCreativeInput', + 'DeleteCreativeInput', + 'PostViewsHistoryOutput', + 'GetViewsHistoryInput', + 'GetViewsHistoryOutput', + 'UpdateViewsManuallyInput', + 'UserOutput', + 'DateGrouping', + 'PlacementAnalyticsOutput', + 'ChannelAnalyticsOutput', + 'CreativeAnalyticsOutput', + 'GetPlacementsAnalyticsInput', + 'GetPlacementsAnalyticsOutput', + 'GetCreativesAnalyticsInput', + 'GetCreativesAnalyticsOutput', + 'GetChannelAnalyticsInput', + 'GetChannelAnalyticsOutput', + 'SpendingDataPoint', + 'NumberWithDelta', + 'OverviewDailyPoint', + 'OverviewChannelPerformance', + 'OverviewProjectSpending', + 'GetOverviewAnalyticsInput', + 'GetOverviewAnalyticsOutput', + 'GetSpendingAnalyticsInput', + 'GetSpendingAnalyticsOutput', + 'DateGroupingType', + 'ProjectMetrics', + 'ProjectMetricsData', + 'ProjectAnalyticsPeriod', + 'GetProjectsAnalyticsInput', + 'GetProjectsAnalyticsOutput', + 'WorkspaceMembershipOutput', + 'GetWorkspacesOutput', + 'CreateWorkspaceInput', + 'CreateWorkspaceOutput', + 'UpdateWorkspaceInput', + 'WorkspaceMemberOutput', + 'WorkspaceMemberUserOutput', + 'WorkspacePermissionOutput', + 'WorkspacePermissionScopeOutput', + 'GetWorkspaceMembersOutput', + 'WorkspacePermissionInput', + 'WorkspacePermissionScopeInput', + 'UpdateWorkspaceMemberPermissionsInput', + 'CreateWorkspaceInviteInput', + 'WorkspaceInviteOutput', + 'GetWorkspaceInvitesOutput', +) + +from pydantic import BaseModel + +from .analytics import ( + ChannelAnalyticsOutput, + CreativeAnalyticsOutput, + DateGrouping, + DateGroupingType, + GetChannelAnalyticsInput, + GetChannelAnalyticsOutput, + GetCreativesAnalyticsInput, + GetCreativesAnalyticsOutput, + GetOverviewAnalyticsInput, + GetOverviewAnalyticsOutput, + GetPlacementsAnalyticsInput, + GetPlacementsAnalyticsOutput, + GetProjectsAnalyticsInput, + GetProjectsAnalyticsOutput, + GetSpendingAnalyticsInput, + GetSpendingAnalyticsOutput, + NumberWithDelta, + OverviewChannelPerformance, + OverviewDailyPoint, + OverviewProjectSpending, + PlacementAnalyticsOutput, + ProjectAnalyticsPeriod, + ProjectMetrics, + ProjectMetricsData, + SpendingDataPoint, +) +from .channel import ( + AttachChannelToWorkspaceInput, + ChannelOutput, + CreateChannelInput, + CreateChannelResult, + CreateChannelsInput, + CreateChannelsOutput, + GetChannelInput, + GetChannelsInput, + GetChannelsOutput, +) +from .creative import ( + CreateCreativeInput, + CreativeButton, + CreativeMediaInput, + CreativeMediaItem, + CreativeOutput, + CreativePreviewOutput, + DeleteCreativeInput, + GetCreativeInput, + GetCreativesInput, + GetCreativesOutput, + UpdateCreativeInput, +) +from .project import ( + ArchiveProjectInput, + ChannelBotPermissions, + ConnectProjectInput, + DisconnectProjectByTgIdInput, + GetProjectInput, + GetWorkspaceProjectsInput, + GetWorkspaceProjectsOutput, + MoveProjectRequest, + ProjectOutput, + UpdateProjectInviteLinkTypeInput, + UpdateProjectPermissionsInput, +) +from .purchase import ( + CostInfo, + CreatePlacementChannelInput, + CreatePlacementsInput, + DeletePlacementInput, + GetPlacementInput, + GetPlacementsInput, + GetPlacementsOutput, + PlacementDetails, + PlacementOutput, + PlacementPostOutput, + PlacementWithPostsOutput, + PostOutput, + UpdatePlacementInput, + UpdatePlacementPostInput, +) +from .user import UserOutput +from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput +from .views import ( + GetViewsHistoryInput, + GetViewsHistoryOutput, + PostViewsHistoryOutput, + UpdateViewsManuallyInput, +) +from .workspace import ( + CreateWorkspaceInput, + CreateWorkspaceInviteInput, + CreateWorkspaceOutput, + GetWorkspaceInvitesOutput, + GetWorkspaceMembersOutput, + GetWorkspacesOutput, + UpdateWorkspaceInput, + UpdateWorkspaceMemberPermissionsInput, + WorkspaceInviteOutput, + WorkspaceMemberOutput, + WorkspaceMembershipOutput, + WorkspaceMemberUserOutput, + WorkspacePermissionInput, + WorkspacePermissionOutput, + WorkspacePermissionScopeInput, + WorkspacePermissionScopeOutput, +) + + +class CreateLoginTokenRequest(BaseModel): + telegram_id: int diff --git a/src/dto/analytics.py b/src/dto/analytics.py new file mode 100644 index 0000000..437df5b --- /dev/null +++ b/src/dto/analytics.py @@ -0,0 +1,296 @@ +import datetime +import uuid +from enum import StrEnum + +import pydantic + +from src import domain + + +class DateGrouping(StrEnum): + DAY = 'day' + WEEK = 'week' + MONTH = 'month' + QUARTER = 'quarter' + YEAR = 'year' + + +class DateGroupingType(StrEnum): + PURCHASE_DATE = 'purchase_date' + LINK_DATE = 'link_date' + PLACEMENT_DATE = 'placement_date' + + +class ProjectMetrics(StrEnum): + TOTAL_COST = 'total_cost' + PURCHASES_COUNT = 'purchases_count' + TOTAL_SUBSCRIPTIONS = 'total_subscriptions' + TOTAL_VIEWS = 'total_views' + AVG_CPF = 'avg_cpf' + AVG_CPM = 'avg_cpm' + AVG_POST_COST = 'avg_post_cost' + CLICKS_COUNT = 'clicks_count' + REACH_VOLUME = 'reach_volume' + TOTAL_DISCOUNTS = 'total_discounts' + AVG_DISCOUNT_PERCENT = 'avg_discount_percent' + AVG_CONVERSION = 'avg_conversion' + + +class PlacementAnalyticsOutput(pydantic.BaseModel): + id: uuid.UUID + project_id: uuid.UUID + project_title: str + channel_id: uuid.UUID + channel_title: str + creative_id: uuid.UUID | None = None + creative_name: str | None = None + cost: float | None = None + cost_type: domain.CostType = domain.CostType.FIXED + cost_before_bargain: float | None = None + payment_at: datetime.datetime | None = None + placement_type: domain.PlacementType | None = None + comment: str | None = None + format: str | None = None + invite_link_type: domain.InviteLinkType | None = None + placement_date: datetime.datetime | None = None + subscriptions_count: int = 0 + views_count: int | None = None + cpf: float | None = None + cpm: float | None = None + time_on_top: int | None = None + time_in_feed: int | None = None + invite_link: str | None = None + invite_link_created_at: datetime.datetime | None = None + post_url: str | None = None + post_deleted_at: datetime.datetime | None = None + conversion_24h: float | None = None + conversion_48h: float | None = None + conversion_total: float | None = None + unsubscriptions_count: int = 0 + unsub_percent: float | None = None + total_active: int = 0 + + +class GetPlacementsAnalyticsInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + + # Categorical filters (multiple selection) + project_ids: list[uuid.UUID] | None = None + status_list: list[str] | None = None + placement_channel_ids: list[uuid.UUID] | None = None + creative_ids: list[uuid.UUID] | None = None + cost_types: list[str] | None = None + placement_types: list[str] | None = None + invite_link_types: list[str] | None = None + + # Numeric filters (ranges) + cost_min: float | None = None + cost_max: float | None = None + views_min: int | None = None + views_max: int | None = None + subscriptions_min: int | None = None + subscriptions_max: int | None = None + cpm_min: float | None = None + cpm_max: float | None = None + cpf_min: float | None = None + cpf_max: float | None = None + discount_min: float | None = None + discount_max: float | None = None + conversion_24h_min: float | None = None + conversion_24h_max: float | None = None + conversion_48h_min: float | None = None + conversion_48h_max: float | None = None + conversion_total_min: float | None = None + conversion_total_max: float | None = None + unsub_percent_min: float | None = None + unsub_percent_max: float | None = None + time_on_top_min: int | None = None + time_on_top_max: int | None = None + time_in_feed_min: int | None = None + time_in_feed_max: int | None = None + + # Text filters (substring) + channel_title_contains: str | None = None + creative_name_contains: str | None = None + comment_contains: str | None = None + + # Date filters + placement_date_from: datetime.datetime | None = None + placement_date_to: datetime.datetime | None = None + payment_date_from: datetime.datetime | None = None + payment_date_to: datetime.datetime | None = None + + # Pagination and sorting + sort_by: str | None = 'created_at' + sort_direction: str = 'desc' + page: int = 1 + size: int = 50 + + +class GetPlacementsAnalyticsOutput(pydantic.BaseModel): + items: list[PlacementAnalyticsOutput] + total: int + page: int + size: int + pages: int + + +class CreativeAnalyticsOutput(pydantic.BaseModel): + id: uuid.UUID + name: str + tag: domain.CreativeTag + placements_count: int + total_cost: float + total_subscriptions: int + total_views: int + avg_cpf: float | None + avg_cpm: float | None + + +class ChannelAnalyticsOutput(pydantic.BaseModel): + id: uuid.UUID + title: str + username: str | None + placements_count: int + total_cost: float + total_subscriptions: int + total_views: int + avg_cpf: float | None + avg_cpm: float | None + + +class GetCreativesAnalyticsInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + project_id: uuid.UUID | None = None + tag: domain.CreativeTag | None = None + + +class GetCreativesAnalyticsOutput(pydantic.BaseModel): + creatives: list[CreativeAnalyticsOutput] + + +class GetChannelAnalyticsInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + project_id: uuid.UUID | None = None + + +class GetChannelAnalyticsOutput(pydantic.BaseModel): + channels: list[ChannelAnalyticsOutput] + + +class SpendingDataPoint(pydantic.BaseModel): + period: str + cost: float + subscriptions: int + views: int + cpf: float | None + cpm: float | None + + +class GetSpendingAnalyticsInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + project_id: uuid.UUID | None = None + date_from: datetime.datetime | None = None + date_to: datetime.datetime | None = None + grouping: DateGrouping = DateGrouping.DAY + + +class GetSpendingAnalyticsOutput(pydantic.BaseModel): + total_cost: float + total_subscriptions: int + total_views: int + avg_cpf: float | None + avg_cpm: float | None + chart_data: list[SpendingDataPoint] + placements_count: int = 0 + + +class NumberWithDelta(pydantic.BaseModel): + value: float | int | None + delta_percent: float | None + + +class OverviewDailyPoint(pydantic.BaseModel): + date: datetime.date + cost: float + subscriptions: int + subscriptions_delta: int | None = None + cpf: float | None + + +class OverviewChannelPerformance(pydantic.BaseModel): + channel_id: uuid.UUID + title: str + username: str | None + cpf: float | None + total_cost: float + subscriptions: int + + +class OverviewProjectSpending(pydantic.BaseModel): + project_id: uuid.UUID + project_title: str + project_username: str | None + total_cost: float + + +class GetOverviewAnalyticsInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + date_from: datetime.datetime + date_to: datetime.datetime + project_id: uuid.UUID | None = None + + +class GetOverviewAnalyticsOutput(pydantic.BaseModel): + total_cost: NumberWithDelta + total_reach: NumberWithDelta + placements_count: NumberWithDelta + subscriptions_count: NumberWithDelta + avg_cpm: NumberWithDelta + avg_cpf: NumberWithDelta + daily_stats: list[OverviewDailyPoint] + top_channels_by_cpf: list[OverviewChannelPerformance] + worst_channels_by_cpf: list[OverviewChannelPerformance] + project_spending: list[OverviewProjectSpending] + + +class ProjectMetricsData(pydantic.BaseModel): + total_cost: float | None = None + purchases_count: int | None = None + total_subscriptions: int | None = None + total_views: int | None = None + avg_cpf: float | None = None + avg_cpm: float | None = None + avg_post_cost: float | None = None + clicks_count: int | None = None + reach_volume: int | None = None + total_discounts: float | None = None + avg_discount_percent: float | None = None + avg_conversion: float | None = None + + +class ProjectAnalyticsPeriod(pydantic.BaseModel): + period: str + period_label: str + metrics: ProjectMetricsData + + +class GetProjectsAnalyticsInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + project_ids: list[uuid.UUID] | None = None + date_from: datetime.datetime | None = None + date_to: datetime.datetime | None = None + grouping: DateGrouping = DateGrouping.DAY + date_grouping: DateGroupingType = DateGroupingType.PLACEMENT_DATE + metrics: list[ProjectMetrics] | None = None + + +class GetProjectsAnalyticsOutput(pydantic.BaseModel): + periods: list[ProjectAnalyticsPeriod] + totals: ProjectMetricsData diff --git a/src/dto/channel.py b/src/dto/channel.py new file mode 100644 index 0000000..b19de98 --- /dev/null +++ b/src/dto/channel.py @@ -0,0 +1,80 @@ +import uuid +from typing import Literal + +import pydantic + + +class ChannelOutput(pydantic.BaseModel): + id: uuid.UUID + telegram_id: int | None + title: str | None + username: str | None + + +class GetChannelsInput(pydantic.BaseModel): + username: str | None = None + + +class GetChannelsOutput(pydantic.BaseModel): + channels: list[ChannelOutput] + + +class GetChannelInput(pydantic.BaseModel): + channel_id: uuid.UUID + + +class AttachChannelToWorkspaceInput(pydantic.BaseModel): + channel_id: uuid.UUID + workspace_id: uuid.UUID + user_telegram_id: int + + +class CreateChannelInput(pydantic.BaseModel): + username: str | None = None + invite_link: str | None = None + + @pydantic.field_validator('username') + @classmethod + def normalize_username(cls, value: str | None) -> str | None: + if value is None: + return None + username = value.strip() + if username.startswith('@'): + username = username[1:] + username = username.strip() + if not username: + return None + return username + + @pydantic.field_validator('invite_link') + @classmethod + def normalize_invite_link(cls, value: str | None) -> str | None: + if value is None: + return None + link = value.strip() + if not link: + return None + return link + + @pydantic.model_validator(mode='after') + def validate_input(self) -> 'CreateChannelInput': + has_username = bool(self.username) + has_invite = bool(self.invite_link) + if has_username == has_invite: + raise ValueError('Specify exactly one of username or invite_link') + return self + + +class CreateChannelsInput(pydantic.BaseModel): + channels: list[CreateChannelInput] + + +class CreateChannelResult(pydantic.BaseModel): + index: int + status: Literal['created', 'updated', 'failed'] + channel: ChannelOutput | None = None + error: str | None = None + + +class CreateChannelsOutput(pydantic.BaseModel): + results: list[CreateChannelResult] diff --git a/src/dto/creative.py b/src/dto/creative.py new file mode 100644 index 0000000..d45590b --- /dev/null +++ b/src/dto/creative.py @@ -0,0 +1,86 @@ +import datetime +import uuid + +import pydantic + +from src import domain + + +class CreativeButton(pydantic.BaseModel): + text: str + url: str + + +class CreativeMediaItem(pydantic.BaseModel): + media_type: str + media_file_id: str + position: int + s3_url: str | None = None + + +class CreativeMediaInput(pydantic.BaseModel): + media_type: str + media_file_id: str + media_data: bytes | None = None + + +class CreativeOutput(pydantic.BaseModel): + id: uuid.UUID + name: str + text: str + media_items: list[CreativeMediaItem] + buttons: list[CreativeButton] + project_id: uuid.UUID + project_channel_title: str + created_at: datetime.datetime + status: domain.CreativeStatus + tag: domain.CreativeTag + placements_count: int + + +class CreativePreviewOutput(pydantic.BaseModel): + id: uuid.UUID + name: str + text: str + media_items: list[CreativeMediaItem] + buttons: list[CreativeButton] + + +class GetCreativesInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + project_id: uuid.UUID | None = None + include_archived: bool = False + + +class GetCreativesOutput(pydantic.BaseModel): + creatives: list[CreativeOutput] + + +class GetCreativeInput(pydantic.BaseModel): + creative_id: uuid.UUID + user_id: uuid.UUID + workspace_id: uuid.UUID + + +class CreateCreativeInput(pydantic.BaseModel): + name: str + text: str + media_items: list[CreativeMediaInput] = pydantic.Field(default_factory=list) + buttons: list[CreativeButton] = pydantic.Field(default_factory=list) + tag: domain.CreativeTag | None = None + + +class UpdateCreativeInput(pydantic.BaseModel): + name: str | None = None + text: str | None = None + media_items: list[CreativeMediaInput] | None = None + buttons: list[CreativeButton] | None = None + status: domain.CreativeStatus | None = None + tag: domain.CreativeTag | None = None + + +class DeleteCreativeInput(pydantic.BaseModel): + creative_id: uuid.UUID + user_id: uuid.UUID + workspace_id: uuid.UUID diff --git a/src/dto/project.py b/src/dto/project.py new file mode 100644 index 0000000..94c6a1f --- /dev/null +++ b/src/dto/project.py @@ -0,0 +1,78 @@ +import uuid + +import pydantic + +from src import domain +from src.domain.project import ProjectStatus +from .channel import ChannelOutput + + +class ChannelBotPermissions(pydantic.BaseModel): + is_admin: bool + can_invite_users: bool + can_restrict_members: bool + can_manage_chat: bool | None = None + can_delete_messages: bool | None = None + can_manage_video_chats: bool | None = None + can_post_messages: bool | None = None + can_edit_messages: bool | None = None + can_pin_messages: bool | None = None + + +class ConnectProjectInput(pydantic.BaseModel): + telegram_id: int + title: str + username: str | None + user_telegram_id: int + bot_permissions: ChannelBotPermissions + + +class ProjectOutput(pydantic.BaseModel): + id: uuid.UUID + telegram_id: int + title: str + username: str | None + status: ProjectStatus + purchase_invite_type_default: domain.InviteLinkType + channel: ChannelOutput + + +class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel): + purchase_invite_type_default: domain.InviteLinkType + + +class GetWorkspaceProjectsInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + include_archived: bool = False + + +class GetWorkspaceProjectsOutput(pydantic.BaseModel): + projects: list[ProjectOutput] + + +class DisconnectProjectByTgIdInput(pydantic.BaseModel): + telegram_id: int + user_telegram_id: int + + +class UpdateProjectPermissionsInput(pydantic.BaseModel): + telegram_id: int + permissions: ChannelBotPermissions + chat_title: str + user_telegram_id: int + + +class GetProjectInput(pydantic.BaseModel): + workspace_id: uuid.UUID + project_id: uuid.UUID + + +class ArchiveProjectInput(pydantic.BaseModel): + workspace_id: uuid.UUID + project_id: uuid.UUID + user_id: uuid.UUID + + +class MoveProjectRequest(pydantic.BaseModel): + target_workspace_id: uuid.UUID diff --git a/src/dto/purchase.py b/src/dto/purchase.py new file mode 100644 index 0000000..6241ed2 --- /dev/null +++ b/src/dto/purchase.py @@ -0,0 +1,127 @@ +import datetime +import uuid + +import pydantic + +from src.domain.placement import CostType, InviteLinkType, PlacementStatus, PlacementType +from src.domain.placement_post import PlacementPostStatus + +from .channel import ChannelOutput +from .project import ProjectOutput + + +class CostInfo(pydantic.BaseModel): + type: CostType + value: float + + +class PlacementDetails(pydantic.BaseModel): + placement_at: datetime.datetime | None = None + payment_at: datetime.datetime | None = None + cost: CostInfo | None = None + cost_before_bargain: CostInfo | None = None + placement_type: PlacementType | None = None + format: str | None = None + top_time_minutes: int | None = None + feed_time_minutes: int | None = None + comment: str | None = None + creative_id: uuid.UUID | None = None + invite_link_type: InviteLinkType | None = None + + +class PlacementOutput(pydantic.BaseModel): + id: uuid.UUID + status: PlacementStatus + creative_id: uuid.UUID | None = None + creative_name: str | None = None + comment: str | None = None + invite_link: str | None + invite_link_created_at: datetime.datetime | None = None + invite_link_type: InviteLinkType + channel: ChannelOutput + project: ProjectOutput | None = None + short_id: str + details: PlacementDetails | None = None + created_at: datetime.datetime + + +class PostOutput(pydantic.BaseModel): + id: uuid.UUID + message_id: int + text: str + url: str | None + deleted_from_channel_at: datetime.datetime | None + created_at: datetime.datetime + updated_at: datetime.datetime + + +class PlacementPostOutput(pydantic.BaseModel): + id: uuid.UUID + status: PlacementPostStatus + subscriptions_count: int + views_count: int | None + created_at: datetime.datetime + time_on_top: int | None = None + post: PostOutput + + +class PlacementWithPostsOutput(PlacementOutput): + placement_post: PlacementPostOutput | None = None + + +class CreatePlacementChannelInput(pydantic.BaseModel): + """Input для создания одного placement (channel + детали)""" + + channel_id: uuid.UUID + status: PlacementStatus | None = None + comment: str | None = None + details: PlacementDetails | None = None + + +class CreatePlacementsInput(pydantic.BaseModel): + """Input для создания нескольких placements (бывший CreatePurchaseInput)""" + + creative_id: uuid.UUID | None = None + channels: list[CreatePlacementChannelInput] + + +class GetPlacementsInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + project_id: uuid.UUID + + +class GetPlacementsOutput(pydantic.BaseModel): + placements: list[PlacementWithPostsOutput] + + +class GetPlacementInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + project_id: uuid.UUID + placement_id: uuid.UUID + + +class UpdatePlacementInput(pydantic.BaseModel): + status: PlacementStatus | None = None + comment: str | None = None + creative_id: uuid.UUID | None = None + placement_at: datetime.datetime | None = None + payment_at: datetime.datetime | None = None + cost: CostInfo | None = None + cost_before_bargain: CostInfo | None = None + placement_type: PlacementType | None = None + format: str | None = None + top_time_minutes: int | None = None + feed_time_minutes: int | None = None + + +class DeletePlacementInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + project_id: uuid.UUID + placement_id: uuid.UUID + + +class UpdatePlacementPostInput(pydantic.BaseModel): + status: PlacementPostStatus | None = None diff --git a/src/dto/user.py b/src/dto/user.py new file mode 100644 index 0000000..ac29953 --- /dev/null +++ b/src/dto/user.py @@ -0,0 +1,11 @@ +import uuid + +import pydantic + + +class UserOutput(pydantic.BaseModel): + id: uuid.UUID + telegram_id: int + username: str | None + first_name: str | None + last_name: str | None diff --git a/src/dto/validate_login_token.py b/src/dto/validate_login_token.py new file mode 100644 index 0000000..28b8bd5 --- /dev/null +++ b/src/dto/validate_login_token.py @@ -0,0 +1,9 @@ +import pydantic + + +class ValidateLoginTokenInput(pydantic.BaseModel): + token: str + + +class ValidateLoginTokenOutput(pydantic.BaseModel): + access_token: str diff --git a/src/dto/views.py b/src/dto/views.py new file mode 100644 index 0000000..9bc1602 --- /dev/null +++ b/src/dto/views.py @@ -0,0 +1,39 @@ +import datetime +import uuid + +import pydantic + + +class PostViewsHistoryOutput(pydantic.BaseModel): + """История просмотров поста.""" + + id: uuid.UUID + post_id: uuid.UUID + views_count: int + fetched_at: datetime.datetime + created_at: datetime.datetime + + +class GetViewsHistoryInput(pydantic.BaseModel): + """Получить историю просмотров для placement.""" + + placement_id: uuid.UUID + user_id: uuid.UUID + workspace_id: uuid.UUID + from_date: datetime.datetime | None = None # Фильтр: с какой даты + to_date: datetime.datetime | None = None # Фильтр: по какую дату + + +class GetViewsHistoryOutput(pydantic.BaseModel): + """История просмотров.""" + + histories: list[PostViewsHistoryOutput] + + +class UpdateViewsManuallyInput(pydantic.BaseModel): + """Ручное обновление просмотров.""" + + placement_id: uuid.UUID + user_id: uuid.UUID + workspace_id: uuid.UUID + views_count: int diff --git a/src/dto/workspace.py b/src/dto/workspace.py new file mode 100644 index 0000000..e235ce1 --- /dev/null +++ b/src/dto/workspace.py @@ -0,0 +1,196 @@ +import uuid + +import pydantic + +from src import domain + + +class WorkspaceMembershipOutput(pydantic.BaseModel): + id: uuid.UUID + name: str + avatar_url: str | None = None + + +class CreateWorkspaceOutput(WorkspaceMembershipOutput): ... + + +class GetWorkspacesOutput(pydantic.BaseModel): + workspaces: list[WorkspaceMembershipOutput] + + +class CreateWorkspaceInput(pydantic.BaseModel): + name: str + + +class UpdateWorkspaceInput(pydantic.BaseModel): + name: str | None = None + + +class WorkspaceMemberUserOutput(pydantic.BaseModel): + id: uuid.UUID + telegram_id: int + username: str | None + + +class WorkspacePermissionScopeOutput(pydantic.BaseModel): + type: domain.PermissionScopeType + id: uuid.UUID + + +class WorkspacePermissionOutput(pydantic.BaseModel): + key: domain.PermissionKey + scopes: list[WorkspacePermissionScopeOutput] + + +class WorkspaceMemberOutput(pydantic.BaseModel): + id: uuid.UUID + status: domain.WorkspaceUserStatus + user: WorkspaceMemberUserOutput + permissions: list[WorkspacePermissionOutput] + + @classmethod + def from_domain(cls, member: domain.WorkspaceUser) -> 'WorkspaceMemberOutput': + if member.user is None or member.user.telegram_user is None: + raise ValueError('Workspace member user relation is not fully loaded') + + telegram_user = member.user.telegram_user + + permissions_map: dict[domain.PermissionKey, list[WorkspacePermissionScopeOutput]] = {} + + for permission in getattr(member, 'permissions', []) or []: + permissions_map.setdefault(permission.permission, []) + + for scope in getattr(member, 'permission_scopes', []) or []: + # Определяем тип и ID scope на основе заполненных полей + scope_type = None + scope_id = None + + if scope.project_id: + scope_type = domain.PermissionScopeType.PROJECT + scope_id = scope.project_id + elif scope.creative_id: + scope_type = domain.PermissionScopeType.CREATIVE + scope_id = scope.creative_id + elif scope.placement_id: + scope_type = domain.PermissionScopeType.PLACEMENT + scope_id = scope.placement_id + + if scope_type and scope_id: + permissions_map.setdefault(scope.permission, []).append( + WorkspacePermissionScopeOutput(type=scope_type, id=scope_id) + ) + + return cls( + id=member.id, + status=member.status, + user=WorkspaceMemberUserOutput( + id=member.user.id, + telegram_id=telegram_user.telegram_id, + username=telegram_user.username, + ), + permissions=[ + WorkspacePermissionOutput(key=key, scopes=scopes) + for key, scopes in sorted(permissions_map.items(), key=lambda item: item[0].value) + ], + ) + + +class GetWorkspaceMembersOutput(pydantic.BaseModel): + members: list[WorkspaceMemberOutput] + + +class WorkspacePermissionScopeInput(pydantic.BaseModel): + type: domain.PermissionScopeType = pydantic.Field( + description='Тип области действия. Сейчас используется только "project".' + ) + id: uuid.UUID = pydantic.Field(description='Идентификатор сущности в указанной области (например, project_id).') + + +class WorkspacePermissionInput(pydantic.BaseModel): + key: domain.PermissionKey = pydantic.Field( + description=( + 'Ключ права. Доступные значения: ' + + ', '.join(f'"{k.value}" - {k.description}' for k in domain.PermissionKey) + ) + ) + scopes: list[WorkspacePermissionScopeInput] = pydantic.Field( + default_factory=list, + description='Ограничения по областям. Пустой список означает глобальное право.', + ) + + +class UpdateWorkspaceMemberPermissionsInput(pydantic.BaseModel): + permissions: list[WorkspacePermissionInput] = pydantic.Field( + default_factory=list, + description='Список прав, который полностью заменит текущий набор участника.', + ) + + model_config = pydantic.ConfigDict( + json_schema_extra={ + 'examples': [ + { + 'permissions': [ + {'key': 'admin_full'}, + { + 'key': 'projects_write', + 'scopes': [ + {'type': 'project', 'id': '11111111-1111-1111-1111-111111111111'}, + {'type': 'project', 'id': '22222222-2222-2222-2222-222222222222'}, + ], + }, + ] + } + ] + } + ) + + +class CreateWorkspaceInviteInput(pydantic.BaseModel): + username: str = pydantic.Field(min_length=1) + + @pydantic.field_validator('username') + @classmethod + def normalize_username(cls, value: str) -> str: + username = value.strip() + if username.startswith('@'): + username = username[1:] + username = username.strip() + if not username: + raise ValueError('Username must not be empty') + return username + + +class WorkspaceInviteOutput(pydantic.BaseModel): + id: uuid.UUID + status: domain.WorkspaceInviteStatus + user: WorkspaceMemberUserOutput + invited_by: WorkspaceMemberUserOutput + + @classmethod + def from_domain(cls, invite: domain.WorkspaceInvite) -> 'WorkspaceInviteOutput': + if ( + invite.user is None + or invite.invited_by is None + or invite.user.telegram_user is None + or invite.invited_by.telegram_user is None + ): + raise ValueError('Workspace invite relations are not fully loaded') + + return cls( + id=invite.id, + status=invite.status, + user=WorkspaceMemberUserOutput( + id=invite.user.id, + telegram_id=invite.user.telegram_user.telegram_id, + username=invite.user.telegram_user.username, + ), + invited_by=WorkspaceMemberUserOutput( + id=invite.invited_by.id, + telegram_id=invite.invited_by.telegram_user.telegram_id, + username=invite.invited_by.telegram_user.username, + ), + ) + + +class GetWorkspaceInvitesOutput(pydantic.BaseModel): + invites: list[WorkspaceInviteOutput] diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..1de6fce --- /dev/null +++ b/src/main.py @@ -0,0 +1,73 @@ +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi_pagination import add_pagination +from fastapi_pagination.utils import disable_installed_extensions_check + +from shared import logger +from shared.worker_base import WorkerConfig +from src import deps +from src.adapter.jwt import JWT +from src.adapter.parser import ParserClient +from src.adapter.postgres import Postgres +from src.adapter.s3 import S3 +from src.adapter.telegram_bot import TelegramBot +from src.config import settings +from src.controller.http_v1 import api_router +from src.controller.worker.fetch_placement_post import FetchPlacementPostWorker +from src.usecase import Usecase + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncGenerator[None]: + await postgres.connect() + await s3.connect() + await fetch_placement_post_worker.start() + + yield + + await fetch_placement_post_worker.stop() + await s3.close() + await postgres.close() + + +logger.init(settings.logger) +disable_installed_extensions_check() + +postgres = Postgres(settings.db) +telegram = TelegramBot(settings.telegram) +jwt_encoder = JWT(settings.jwt) +parser_client = ParserClient(base_url=settings.parser.URL) +s3 = S3(settings.s3) + +usecase = Usecase( + database=postgres, + telegram_bot=telegram, + jwt_encoder=jwt_encoder, + parser=parser_client, + s3=s3, +) +deps.set_usecase(usecase) + +fetch_placement_post_worker = FetchPlacementPostWorker(config=WorkerConfig(INTERVAL_SECONDS=5)) + +app = FastAPI( + lifespan=lifespan, + version=settings.logger.APP_VERSION, + # Сворачиваем Schemas + swagger_ui_parameters={'defaultModelsExpandDepth': 0}, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.app.ORIGINS, + allow_credentials=True, + allow_methods=['*'], + allow_headers=['*'], +) + +add_pagination(app) + +app.include_router(api_router) diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py new file mode 100644 index 0000000..a152dc4 --- /dev/null +++ b/src/usecase/__init__.py @@ -0,0 +1,275 @@ +import typing +from collections.abc import Sequence +from dataclasses import dataclass +from uuid import UUID + +if typing.TYPE_CHECKING: + from aiogram.types import InlineKeyboardButton + + from src.adapter.postgres import Postgres + +from src import domain + +from .analytics.get_channel_analytics import get_channel_analytics +from .analytics.get_creatives_analytics import get_creatives_analytics +from .analytics.get_overview_analytics import get_overview_analytics +from .analytics.get_placements_analytics import get_placements_analytics +from .analytics.get_projects_analytics import get_projects_analytics +from .analytics.get_spending_analytics import get_spending_analytics +from .auth.attach_login_token_message import attach_login_token_message +from .auth.create_telegram_login_token import create_telegram_login_token +from .auth.get_jwt_by_telegram_id import get_jwt_by_telegram_id +from .auth.get_me import get_me +from .auth.validate_login_token import validate_login_token +from .channel.attach_channel_to_workspace import attach_channel_to_workspace +from .channel.create_channels import create_channels +from .channel.get_channel import get_channel +from .channel.get_channels import get_channels +from .creative.create_creative import create_creative +from .creative.delete_creative import delete_creative +from .creative.get_creative import get_creative +from .creative.get_creatives import get_creatives +from .creative.update_creative import update_creative +from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle +from .placement.update_post_status_cycle import update_post_status_cycle +from .project.archive_project import archive_project, unarchive_project +from .project.delete_project import delete_project +from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id +from .project.get_project import get_project +from .project.get_workspace_projects import get_workspace_projects +from .project.move_project_to_workspace import move_project_to_workspace +from .project.tg_add_project import tg_add_project +from .project.update_project_invite_link_type import update_project_invite_link_type +from .project.update_project_permissions import update_project_permissions +from .purchase.build_placement_creative import build_placement_creative +from .purchase.create_placements import create_placements +from .purchase.delete_placement import delete_placement +from .purchase.get_placement import get_placement_user +from .purchase.get_placements import get_placements +from .purchase.update_placement import update_placement +from .purchase.update_placement_post import update_placement_post +from .subscription.handle_subscription import handle_subscription +from .subscription.handle_unsubscription import handle_unsubscription +from .views.get_views_history import get_views_history +from .workspace.accept_workspace_invite import accept_workspace_invite +from .workspace.create_workspace import create_workspace +from .workspace.create_workspace_invite import create_workspace_invite +from .workspace.delete_workspace import delete_workspace +from .workspace.delete_workspace_avatar import delete_workspace_avatar +from .workspace.get_workspace_invites import get_workspace_invites +from .workspace.get_workspace_members import get_workspace_members, get_current_member_permissions +from .workspace.get_workspaces import get_workspaces +from .workspace.tg_accept_workspace_invite import tg_accept_workspace_invite +from .workspace.update_workspace import update_workspace +from .workspace.update_workspace_avatar import update_workspace_avatar +from .workspace.update_workspace_member_permissions import update_workspace_member_permissions + + +class MediaItem(typing.Protocol): + media_type: str + media_file_id: str + + +class TelegramBotWriter(typing.Protocol): + async def send_message( + self, + text: str, + chat_id: int, + parse_mode: str | None = None, + disable_preview: bool = False, + reply_to_message_id: int | None = None, + ) -> int: ... + + async def send_message_with_inline_keyboard( + self, + text: str, + chat_id: int, + buttons: list[list['InlineKeyboardButton']], + parse_mode: str | None = None, + disable_preview: bool = False, + reply_to_message_id: int | None = None, + ) -> int: ... + + async def send_media_with_inline_keyboard( + self, + text: str, + chat_id: int, + media_type: str, + media_file_id: str, + buttons: list[list['InlineKeyboardButton']], + parse_mode: str | None = None, + reply_to_message_id: int | None = None, + ) -> int: ... + + async def send_media_group( + self, + chat_id: int, + media_items: Sequence[MediaItem], + caption: str | None = None, + parse_mode: str | None = None, + reply_to_message_id: int | None = None, + ) -> int: ... + + async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None: ... + + async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None: ... + + async def create_chat_invite_link( + self, chat_id: int, requires_approval: bool = False, name: str | None = None + ) -> str: ... + + +class JWTEncoder(typing.Protocol): + def encode_access_token(self, user_id: UUID, telegram_id: int, username: str | None = None) -> str: ... + + +class FetchChannelResponse(typing.Protocol): + telegram_id: int + username: str | None + title: str | None + access_hash: int | None + pts: int | None + + +class Parser(typing.Protocol): + async def fetch_telegram_channel(self, username: str) -> FetchChannelResponse | None: ... + async def resolve_telegram_channel_by_invite(self, invite_link: str) -> FetchChannelResponse | None: ... + + +class S3Storage(typing.Protocol): + async def upload(self, key: str, data: bytes, content_type: str) -> None: ... + + async def get(self, key: str) -> bytes: ... + + async def delete(self, key: str) -> None: ... + + def public_url(self, key: str) -> str: ... + + +@dataclass +class Usecase: + database: 'Postgres' + telegram_bot: TelegramBotWriter + jwt_encoder: JWTEncoder + parser: Parser + s3: S3Storage + + async def ensure_workspace_permission( + self, workspace_id: UUID, user_id: UUID, permission: domain.PermissionKey, *, for_project_id: UUID | None = None + ) -> domain.WorkspacePermissionContext: + membership = await self.database.get_workspace_membership(workspace_id, user_id) + if not membership or not membership.workspace: + raise domain.WorkspaceNotFound(workspace_id) + + permissions = domain.WorkspacePermissions.from_membership(membership) + + if for_project_id is not None: + has_permission = permissions.has_permission( + permission, + scope_type=domain.PermissionScopeType.PROJECT, + scope_id=for_project_id, + ) + else: + has_permission = permissions.has_any(permission) + + if not has_permission: + raise domain.WorkspaceAccessDenied(workspace_id) + + return domain.build_workspace_permission_context(membership) + + async def ensure_analytics_permission( + self, workspace_id: UUID, user_id: UUID, *, for_project_id: UUID | None = None + ) -> domain.WorkspacePermissionContext: + """Ensure user has any analytics permission.""" + membership = await self.database.get_workspace_membership(workspace_id, user_id) + if not membership or not membership.workspace: + raise domain.WorkspaceNotFound(workspace_id) + + permissions = domain.WorkspacePermissions.from_membership(membership) + + if not permissions.has_any_analytics_permission(): + raise domain.WorkspaceAccessDenied(workspace_id) + + return domain.build_workspace_permission_context(membership) + + async def get_or_create_personal_workspace(self, user: domain.User) -> domain.Workspace: + workspace = await self.database.get_default_workspace_for_user(user.id) + if workspace: + return workspace + + if not user.telegram_user: + raise domain.UserNotFound(user.id) + + telegram_user = user.telegram_user + workspace_name = telegram_user.username or f'Workspace {telegram_user.telegram_id}' + workspace = domain.Workspace(name=workspace_name) + + async with self.database.transaction(): + await self.database.create_workspace(workspace) + membership = await self.database.add_user_to_workspace(workspace.id, user.id) + await self.database.set_workspace_user_permissions( + membership.id, + global_permissions={domain.PermissionKey.ADMIN_FULL}, + scoped_permissions=[], + ) + + return workspace + + validate_login_token = validate_login_token + create_telegram_login_token = create_telegram_login_token + attach_login_token_message = attach_login_token_message + get_jwt_by_telegram_id = get_jwt_by_telegram_id + get_me = get_me + tg_add_project = tg_add_project + get_workspace_projects = get_workspace_projects + get_project = get_project + archive_project = archive_project + unarchive_project = unarchive_project + delete_project = delete_project + move_project_to_workspace = move_project_to_workspace + disconnect_project_by_tg_id = disconnect_project_by_tg_id + update_project_permissions = update_project_permissions + update_project_invite_link_type = update_project_invite_link_type + get_channels = get_channels + create_channels = create_channels + get_channel = get_channel + attach_channel_to_workspace = attach_channel_to_workspace + # Placement (user-managed) use cases + create_placements = create_placements + get_placements = get_placements + get_placement_user = get_placement_user + build_placement_creative = build_placement_creative + update_placement = update_placement + update_placement_post = update_placement_post + delete_placement = delete_placement + # Creative use cases + get_creatives = get_creatives + get_creative = get_creative + create_creative = create_creative + update_creative = update_creative + delete_creative = delete_creative + # PlacementPost (system-managed) use cases + fetch_placement_post_cycle = fetch_placement_post_cycle + update_post_status_cycle = update_post_status_cycle + handle_subscription = handle_subscription + handle_unsubscription = handle_unsubscription + get_views_history = get_views_history + get_placements_analytics = get_placements_analytics + get_creatives_analytics = get_creatives_analytics + get_channel_analytics = get_channel_analytics + get_projects_analytics = get_projects_analytics + get_spending_analytics = get_spending_analytics + get_overview_analytics = get_overview_analytics + get_workspaces = get_workspaces + create_workspace = create_workspace + update_workspace = update_workspace + delete_workspace = delete_workspace + update_workspace_avatar = update_workspace_avatar + delete_workspace_avatar = delete_workspace_avatar + get_workspace_members = get_workspace_members + get_current_member_permissions = get_current_member_permissions + update_workspace_member_permissions = update_workspace_member_permissions + create_workspace_invite = create_workspace_invite + get_workspace_invites = get_workspace_invites + accept_workspace_invite = accept_workspace_invite + tg_accept_workspace_invite = tg_accept_workspace_invite diff --git a/src/usecase/analytics/get_channel_analytics.py b/src/usecase/analytics/get_channel_analytics.py new file mode 100644 index 0000000..467211d --- /dev/null +++ b/src/usecase/analytics/get_channel_analytics.py @@ -0,0 +1,111 @@ +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING +from uuid import UUID + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +def _get_cost(placement_post: domain.PlacementPost) -> float: + placement = placement_post.placement + return placement.cost_value if placement and placement.cost_value is not None else 0.0 + + +async def get_channel_analytics( + self: 'Usecase', input: dto.GetChannelAnalyticsInput +) -> list[dto.ChannelAnalyticsOutput]: + context = await self.ensure_analytics_permission(input.workspace_id, input.user_id) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ) + hide_subscriptions = context.should_hide_subscriptions() + + if input.project_id: + project = await self.database.get_project(input.workspace_id, input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) + allowed_project_filter = None + else: + allowed_project_filter = allowed_project_ids + + placements = await self.database.get_workspace_placement_posts( + input.workspace_id, + input.project_id, + include_archived=False, + allowed_project_ids=allowed_project_filter, + ) + + # Collect unique channels from placements + channel_map: dict[UUID, domain.Channel] = {} + for placement_post in placements: + placement = placement_post.placement + if placement and placement.channel: + channel_map[placement.channel_id] = placement.channel + channels = list(channel_map.values()) + + @dataclass + class ChannelStats: + total_cost: float = 0.0 + total_subscriptions: int = 0 + total_views: int = 0 + placements_count: int = 0 + + channel_stats: dict[UUID, ChannelStats] = {ch.id: ChannelStats() for ch in channels} + + # Batch fetch views data for all posts + post_ids = [p.post.id for p in placements if p.post] + views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} + + # Batch fetch subscriptions counts + placement_ids = [p.id for p in placements] + subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids) + + for placement_post in placements: + placement = placement_post.placement + if not placement: + continue + stats = channel_stats.get(placement.channel_id) + if stats is None: + continue + + stats.total_cost += _get_cost(placement_post) + stats.total_subscriptions += subscriptions_counts.get(placement_post.id, 0) + + # Get views from batch data + if placement_post.post and placement_post.post.id in views_map: + views_count = views_map[placement_post.post.id][0] + stats.total_views += views_count + + stats.placements_count += 1 + + result = [] + for ch in channels: + stats = channel_stats[ch.id] + + total_subscriptions = 0 if hide_subscriptions else stats.total_subscriptions + avg_cpf = None + if not hide_subscriptions and stats.total_subscriptions > 0 and stats.total_cost > 0: + avg_cpf = stats.total_cost / stats.total_subscriptions + avg_cpm = ( + (stats.total_cost / stats.total_views * 1000) if stats.total_views > 0 and stats.total_cost > 0 else None + ) + + result.append( + dto.ChannelAnalyticsOutput( + id=ch.id, + title=ch.title, + username=ch.username, + placements_count=stats.placements_count, + total_cost=stats.total_cost, + total_subscriptions=total_subscriptions, + total_views=stats.total_views, + avg_cpf=avg_cpf, + avg_cpm=avg_cpm, + ) + ) + + return result diff --git a/src/usecase/analytics/get_creatives_analytics.py b/src/usecase/analytics/get_creatives_analytics.py new file mode 100644 index 0000000..d28e0f8 --- /dev/null +++ b/src/usecase/analytics/get_creatives_analytics.py @@ -0,0 +1,115 @@ +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING +from uuid import UUID + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +def _get_cost(placement_post: domain.PlacementPost) -> float: + placement = placement_post.placement + return placement.cost_value if placement and placement.cost_value is not None else 0.0 + + +async def get_creatives_analytics( + self: 'Usecase', input: dto.GetCreativesAnalyticsInput +) -> list[dto.CreativeAnalyticsOutput]: + context = await self.ensure_analytics_permission(input.workspace_id, input.user_id) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ) + hide_subscriptions = context.should_hide_subscriptions() + filter_own_creatives = context.should_filter_own_creatives() + + # Get user_id for own creatives filter + created_by_filter: UUID | None = None + if filter_own_creatives: + created_by_filter = context.membership.user_id + + if input.project_id: + project = await self.database.get_project(input.workspace_id, input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) + allowed_project_ids = None + + creatives = await self.database.get_workspace_creatives( + input.workspace_id, + input.project_id, + include_archived=False, + allowed_project_ids=allowed_project_ids, + created_by_user_id=created_by_filter, + tag=input.tag, + ) + placements = await self.database.get_workspace_placement_posts( + input.workspace_id, + input.project_id, + include_archived=False, + allowed_project_ids=allowed_project_ids, + ) + + @dataclass + class CreativeStats: + total_cost: float = 0.0 + total_subscriptions: int = 0 + total_views: int = 0 + placements_count: int = 0 + + creative_stats: dict[UUID, CreativeStats] = {cr.id: CreativeStats() for cr in creatives} + + # Batch fetch views data for all posts + post_ids = [p.post.id for p in placements if p.post] + views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} + + # Batch fetch subscriptions counts + placement_ids = [p.id for p in placements] + subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids) + + for placement_post in placements: + placement = placement_post.placement + if not placement or not placement.creative_id: + continue + stats = creative_stats.get(placement.creative_id) + if stats is None: + continue + + stats.total_cost += _get_cost(placement_post) + stats.total_subscriptions += subscriptions_counts.get(placement_post.id, 0) + + # Get views from batch data + if placement_post.post and placement_post.post.id in views_map: + views_count = views_map[placement_post.post.id][0] + stats.total_views += views_count + + stats.placements_count += 1 + + result = [] + for cr in creatives: + stats = creative_stats[cr.id] + + total_subscriptions = 0 if hide_subscriptions else stats.total_subscriptions + avg_cpf = None + if not hide_subscriptions and stats.total_subscriptions > 0 and stats.total_cost > 0: + avg_cpf = stats.total_cost / stats.total_subscriptions + avg_cpm = ( + (stats.total_cost / stats.total_views * 1000) if stats.total_views > 0 and stats.total_cost > 0 else None + ) + + result.append( + dto.CreativeAnalyticsOutput( + id=cr.id, + name=cr.name, + tag=cr.tag, + placements_count=stats.placements_count, + total_cost=stats.total_cost, + total_subscriptions=total_subscriptions, + total_views=stats.total_views, + avg_cpf=avg_cpf, + avg_cpm=avg_cpm, + ) + ) + + return result diff --git a/src/usecase/analytics/get_overview_analytics.py b/src/usecase/analytics/get_overview_analytics.py new file mode 100644 index 0000000..2100d5f --- /dev/null +++ b/src/usecase/analytics/get_overview_analytics.py @@ -0,0 +1,253 @@ +import datetime +from collections import defaultdict +from dataclasses import dataclass +from typing import TYPE_CHECKING +from uuid import UUID + +from fastapi import HTTPException, status + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +def _calc_delta(current: float | None, previous: float | None) -> float | None: + if current is None or previous is None or previous == 0: + return None + return (current - previous) / previous * 100 + + +def _as_number_with_delta(value: float | None, previous: float | None) -> dto.NumberWithDelta: + return dto.NumberWithDelta(value=value, delta_percent=_calc_delta(value, previous)) + + +@dataclass +class ChannelAggregate: + channel: domain.Channel | None + total_cost: float = 0.0 + total_subs: int = 0 + + +def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime: + placement = placement_post.placement + if placement and placement.placement_at: + return placement.placement_at + if placement_post.post and placement_post.post.created_at: + return placement_post.post.created_at + return placement_post.created_at + + +def _get_cost(placement_post: domain.PlacementPost) -> float: + placement = placement_post.placement + return placement.cost_value if placement and placement.cost_value is not None else 0.0 + + +async def get_overview_analytics( + self: 'Usecase', input: dto.GetOverviewAnalyticsInput +) -> dto.GetOverviewAnalyticsOutput: + if input.date_from > input.date_to: + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'date_from must be before date_to') + + context = await self.ensure_analytics_permission(input.workspace_id, input.user_id) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ) + hide_subscriptions = context.should_hide_subscriptions() + + if input.project_id: + project = await self.database.get_project(input.workspace_id, input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) + allowed_project_ids = None + + raw_duration = input.date_to - input.date_from + if raw_duration.total_seconds() < 0: + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'date_from must be before date_to') + + period_length = raw_duration if raw_duration.total_seconds() > 0 else datetime.timedelta(days=1) + previous_period_start = input.date_from - period_length + + placements = await self.database.get_workspace_placement_posts( + input.workspace_id, + project_id=input.project_id, + include_archived=False, + allowed_project_ids=allowed_project_ids, + date_from=previous_period_start, + date_to=input.date_to, + ) + + current_placements: list[domain.PlacementPost] = [] + previous_placements: list[domain.PlacementPost] = [] + + for placement_post in placements: + placement_date = _get_placement_date(placement_post) + if placement_date >= input.date_from and placement_date <= input.date_to: + current_placements.append(placement_post) + elif placement_date >= previous_period_start and placement_date < input.date_from: + previous_placements.append(placement_post) + + placement_post_ids = [p.id for p in placements] + subscriptions = await self.database.get_subscriptions_for_placement_posts( + placement_post_ids, date_from=previous_period_start, date_to=input.date_to + ) + + # Map placement_post_id to placement_id for subscription aggregation + # Since subscriptions link to placement, we need to map back to placement_post + placement_post_to_placement: dict[UUID, UUID] = {p.id: p.placement_id for p in placements} + + subs_per_placement_post_current: dict[UUID, int] = defaultdict(int) + subs_per_placement_post_previous: dict[UUID, int] = defaultdict(int) + subs_per_day_current: dict[datetime.date, int] = defaultdict(int) + + for sub in subscriptions: + created_at = sub.created_at + if created_at is None: + continue + + # Find which placement_post(s) this subscription belongs to via placement_id + # A placement can have multiple placement_posts, so we count it for each + for pp_id, p_id in placement_post_to_placement.items(): + if p_id == sub.placement_id: + if created_at >= input.date_from and created_at <= input.date_to: + subs_per_placement_post_current[pp_id] += 1 + elif created_at >= previous_period_start and created_at < input.date_from: + subs_per_placement_post_previous[pp_id] += 1 + + # Count each subscription only once for daily stats + if created_at >= input.date_from and created_at <= input.date_to: + subs_per_day_current[created_at.date()] += 1 + + post_ids = [p.post.id for p in placements if p.post] + views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} + + def _aggregate_totals( + placement_list: list[domain.PlacementPost], subs_per_placement_post: dict[UUID, int] + ) -> tuple[float, int, int]: + total_cost = 0.0 + total_subscriptions = 0 + total_views = 0 + + for placement_post in placement_list: + total_cost += _get_cost(placement_post) + + subs = subs_per_placement_post.get(placement_post.id, 0) + total_subscriptions += subs + + if placement_post.post and placement_post.post.id in views_map: + total_views += views_map[placement_post.post.id][0] + + return total_cost, total_subscriptions, total_views + + current_cost, current_subs, current_views = _aggregate_totals( + current_placements, subs_per_placement_post_current + ) + previous_cost, previous_subs, previous_views = _aggregate_totals( + previous_placements, subs_per_placement_post_previous + ) + + # Apply hide_subscriptions filter + if hide_subscriptions: + current_subs = 0 + previous_subs = 0 + current_avg_cpf = None + previous_avg_cpf = None + else: + current_avg_cpf = current_cost / current_subs if current_cost > 0 and current_subs > 0 else None + previous_avg_cpf = previous_cost / previous_subs if previous_cost > 0 and previous_subs > 0 else None + + current_avg_cpm = (current_cost / current_views * 1000) if current_cost > 0 and current_views > 0 else None + previous_avg_cpm = (previous_cost / previous_views * 1000) if previous_cost > 0 and previous_views > 0 else None + + cost_per_day: dict[datetime.date, float] = defaultdict(float) + channel_stats: dict[UUID, ChannelAggregate] = {} + project_spending_map: dict[UUID, float] = defaultdict(float) + project_meta: dict[UUID, domain.Project] = {} + + for placement_post in current_placements: + placement_day = _get_placement_date(placement_post).date() + placement = placement_post.placement + if not placement: + continue + if placement.project: + project_meta[placement.project_id] = placement.project + + cost_value = _get_cost(placement_post) + cost_per_day[placement_day] += cost_value + project_spending_map[placement.project_id] += cost_value + subs = subs_per_placement_post_current.get(placement_post.id, 0) + + channel_id = placement.channel_id + if channel_id not in channel_stats: + channel_stats[channel_id] = ChannelAggregate(channel=placement.channel) + channel_stats[channel_id].total_cost += cost_value + channel_stats[channel_id].total_subs += subs + + start_date = input.date_from.date() + end_date = input.date_to.date() + days_span = (end_date - start_date).days + + daily_stats: list[dto.OverviewDailyPoint] = [] + previous_day_subs: int | None = None + for day_offset in range(days_span + 1): + day = start_date + datetime.timedelta(days=day_offset) + cost = cost_per_day.get(day, 0.0) + subs = 0 if hide_subscriptions else subs_per_day_current.get(day, 0) + delta = None if hide_subscriptions else ((subs - previous_day_subs) if previous_day_subs is not None else subs) + cpf = None if hide_subscriptions else (cost / subs if subs > 0 and cost > 0 else None) + daily_stats.append( + dto.OverviewDailyPoint(date=day, cost=cost, subscriptions=subs, subscriptions_delta=delta, cpf=cpf) + ) + previous_day_subs = subs + + channel_performance = [] + for stats in channel_stats.values(): + subs = 0 if hide_subscriptions else stats.total_subs + cost_value = stats.total_cost + cpf = None if hide_subscriptions else (cost_value / stats.total_subs if stats.total_subs > 0 else None) + channel = stats.channel + + if not channel: + continue + # Skip channels without CPF only if we're not hiding subscriptions + if not hide_subscriptions and cpf is None: + continue + + channel_performance.append( + dto.OverviewChannelPerformance( + channel_id=channel.id, + title=channel.title, + username=channel.username, + cpf=cpf, + total_cost=cost_value, + subscriptions=subs, + ) + ) + + top_channels = sorted(channel_performance, key=lambda c: c.cpf or float('inf'))[:5] + worst_channels = sorted(channel_performance, key=lambda c: c.cpf or float('inf'), reverse=True)[:5] + + project_spending = [] + for project_id, total_cost in project_spending_map.items(): + project = project_meta.get(project_id) + project_spending.append( + dto.OverviewProjectSpending( + project_id=project_id, + project_title=project.channel.title if project and project.channel else 'Unnamed project', + project_username=project.channel.username if project and project.channel else None, + total_cost=total_cost, + ) + ) + project_spending = sorted(project_spending, key=lambda p: p.total_cost, reverse=True) + + return dto.GetOverviewAnalyticsOutput( + total_cost=_as_number_with_delta(current_cost, previous_cost), + total_reach=_as_number_with_delta(current_views, previous_views), + placements_count=_as_number_with_delta(len(current_placements), len(previous_placements)), + subscriptions_count=_as_number_with_delta(current_subs, previous_subs), + avg_cpm=_as_number_with_delta(current_avg_cpm, previous_avg_cpm), + avg_cpf=_as_number_with_delta(current_avg_cpf, previous_avg_cpf), + daily_stats=daily_stats, + top_channels_by_cpf=top_channels, + worst_channels_by_cpf=worst_channels, + project_spending=project_spending, + ) diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py new file mode 100644 index 0000000..5614899 --- /dev/null +++ b/src/usecase/analytics/get_placements_analytics.py @@ -0,0 +1,257 @@ +import logging +import uuid +from typing import TYPE_CHECKING + +from tortoise import timezone + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +def _calculate_cpf(cost: float | None, subscriptions: int) -> float | None: + if cost is None or subscriptions == 0: + return None + return cost / subscriptions + + +def _calculate_cpm(cost: float | None, views: int | None) -> float | None: + if cost is None or views is None or views == 0: + return None + return (cost / views) * 1000 + + +def _calculate_discount_percent(cost: float | None, cost_before: float | None) -> float | None: + if cost is None or cost_before is None or cost_before == 0: + return None + return ((cost_before - cost) / cost_before) * 100 + + +async def get_placements_analytics( + self: 'Usecase', input: dto.GetPlacementsAnalyticsInput +) -> dto.GetPlacementsAnalyticsOutput: + context = await self.ensure_analytics_permission(input.workspace_id, input.user_id) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ) + hide_subscriptions = context.should_hide_subscriptions() + + # Filter by project permissions + project_ids = input.project_ids + if project_ids: + # Verify all requested projects exist and user has access + for pid in project_ids: + project = await self.database.get_project(input.workspace_id, pid) + if not project: + raise domain.ProjectNotFound(pid) + allowed_project_ids = None + + placements = await self.database.get_workspace_placements_for_analytics( + workspace_id=input.workspace_id, + project_ids=project_ids or input.project_ids, + channel_ids=input.placement_channel_ids, + creative_ids=input.creative_ids, + status_list=input.status_list, + cost_types=input.cost_types, + placement_types=input.placement_types, + invite_link_types=input.invite_link_types, + cost_min=input.cost_min, + cost_max=input.cost_max, + views_min=input.views_min, + views_max=input.views_max, + subscriptions_min=input.subscriptions_min, + subscriptions_max=input.subscriptions_max, + cpm_min=input.cpm_min, + cpm_max=input.cpm_max, + channel_title_contains=input.channel_title_contains, + creative_name_contains=input.creative_name_contains, + comment_contains=input.comment_contains, + placement_date_from=input.placement_date_from, + placement_date_to=input.placement_date_to, + sort_by=input.sort_by or 'created_at', + sort_direction=input.sort_direction or 'desc', + offset=(input.page - 1) * input.size, + limit=input.size, + include_archived=False, + allowed_project_ids=allowed_project_ids, + ) + + total = await self.database.count_workspace_placements_for_analytics( + workspace_id=input.workspace_id, + project_ids=project_ids or input.project_ids, + channel_ids=input.placement_channel_ids, + creative_ids=input.creative_ids, + status_list=input.status_list, + cost_types=input.cost_types, + placement_types=input.placement_types, + invite_link_types=input.invite_link_types, + cost_min=input.cost_min, + cost_max=input.cost_max, + views_min=input.views_min, + views_max=input.views_max, + subscriptions_min=input.subscriptions_min, + subscriptions_max=input.subscriptions_max, + cpm_min=input.cpm_min, + cpm_max=input.cpm_max, + channel_title_contains=input.channel_title_contains, + creative_name_contains=input.creative_name_contains, + comment_contains=input.comment_contains, + placement_date_from=input.placement_date_from, + placement_date_to=input.placement_date_to, + include_archived=False, + allowed_project_ids=allowed_project_ids, + ) + + # Extract placement_post_ids and post_ids from placement.placement_posts + placement_post_ids = [pp.id for p in placements for pp in p.placement_posts] + post_ids = [ + pp.post.id + for p in placements + for pp in p.placement_posts + if pp.post + ] + views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} + subscriptions_counts = ( + await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids) + if placement_post_ids + else {} + ) + unsubscriptions_counts = ( + await self.database.count_unsubscriptions_by_placement_post_batch(placement_post_ids) + if placement_post_ids + else {} + ) + + # Collect (channel_id, message_id) pairs for batch next post lookup + channel_message_pairs = [ + (pp.post.channel_id, pp.post.message_id) + for p in placements + for pp in p.placement_posts + if pp.post and pp.post.published_at + ] + next_posts_map = ( + await self.database.get_next_posts_after_batch(channel_message_pairs) + if channel_message_pairs + else {} + ) + + # Calculate time_on_top for each placement_post + time_on_top_map: dict[uuid.UUID, int] = {} + now = timezone.now() + for placement in placements: + for pp in placement.placement_posts: + post = pp.post + if not post or not post.published_at: + continue + published_at = post.published_at + key = (post.channel_id, post.message_id) + next_post = next_posts_map.get(key) + if next_post and next_post.published_at: + time_on_top_map[pp.id] = int((next_post.published_at - published_at).total_seconds()) + else: + time_on_top_map[pp.id] = int((now - published_at).total_seconds()) + + results: list[dto.PlacementAnalyticsOutput] = [] + for placement in placements: + placement_post = placement.placement_posts[0] if placement.placement_posts else None + + project = placement.project + channel = placement.channel + creative = placement.creative + + if not project or not channel: + log.warning('Placement %s missing project or channel', placement.id) + continue + + cost_value = placement.cost_value + cost_type = placement.cost_type if placement.cost_type else domain.CostType.FIXED + cost_before = placement.cost_before_bargain + + subs_count = 0 + if placement_post and not hide_subscriptions: + subs_count = subscriptions_counts.get(placement_post.id, 0) + + unsubs_count = 0 + if placement_post and not hide_subscriptions: + unsubs_count = unsubscriptions_counts.get(placement_post.id, 0) + + views_count = None + if placement_post and placement_post.post: + post_id = placement_post.post.id + if post_id in views_map: + views_count = views_map[post_id][0] + + cpm_value = _calculate_cpm(cost_value, views_count) + cpf_value = None if hide_subscriptions else _calculate_cpf(cost_value, subs_count) + discount_percent = _calculate_discount_percent(cost_value, cost_before) + + conversion_24h = None + conversion_48h = None + conversion_total = None + if placement_post and views_count and views_count > 0 and not hide_subscriptions: + if subs_count > 0: + conversion_total = (subs_count / views_count) * 100 + + total_subs = subs_count + unsubs_count + unsub_percent = None if total_subs == 0 else (unsubs_count / total_subs) * 100 + + total_active = subs_count if not hide_subscriptions else 0 + + post_url = None + if placement_post and placement_post.post and placement_post.post.channel.username: + post_url = f'https://t.me/{placement_post.post.channel.username}/{placement_post.post.message_id}' + + post_deleted_at = ( + placement_post.post.deleted_from_channel_at if placement_post and placement_post.post else None + ) + + results.append( + dto.PlacementAnalyticsOutput( + id=placement.id, + project_id=project.id, + project_title=getattr(project, 'title', None) or getattr(project.channel, 'title', '') + if project and project.channel + else '', + channel_id=channel.id, + channel_title=channel.title, + creative_id=placement.creative_id, + creative_name=creative.name if creative else None, + cost=cost_value, + cost_type=cost_type, + cost_before_bargain=cost_before, + payment_at=placement.payment_at, + placement_type=placement.placement_type, + comment=placement.comment, + format=placement.format, + invite_link_type=placement.invite_link_type, + placement_date=placement.placement_at, + subscriptions_count=subs_count, + views_count=views_count, + cpf=cpf_value, + cpm=cpm_value, + time_on_top=time_on_top_map.get(placement_post.id) if placement_post else None, + time_in_feed=None, + invite_link=placement.invite_link, + invite_link_created_at=placement.invite_link_created_at, + post_url=post_url, + post_deleted_at=post_deleted_at, + conversion_24h=conversion_24h, + conversion_48h=conversion_48h, + conversion_total=conversion_total, + unsubscriptions_count=unsubs_count, + unsub_percent=unsub_percent, + total_active=total_active, + ) + ) + + pages = (total + input.size - 1) // input.size if total > 0 else 0 + + return dto.GetPlacementsAnalyticsOutput( + items=results, + total=total, + page=input.page, + size=input.size, + pages=pages, + ) diff --git a/src/usecase/analytics/get_projects_analytics.py b/src/usecase/analytics/get_projects_analytics.py new file mode 100644 index 0000000..24b359e --- /dev/null +++ b/src/usecase/analytics/get_projects_analytics.py @@ -0,0 +1,315 @@ +import datetime +from collections import defaultdict +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from fastapi import HTTPException, status +from tortoise import timezone + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +def _format_period(dt: datetime.datetime, grouping: dto.DateGrouping) -> str: + match grouping: + case dto.DateGrouping.DAY: + return dt.strftime('%Y-%m-%d') + case dto.DateGrouping.WEEK: + # ISO week + year, week, _ = dt.isocalendar() + return f'{year}-W{week:02d}' + case dto.DateGrouping.MONTH: + return dt.strftime('%Y-%m') + case dto.DateGrouping.QUARTER: + quarter = (dt.month - 1) // 3 + 1 + return f'{dt.year}-Q{quarter}' + case _: + raise ValueError('Invalid date grouping') + + +def _format_period_label(dt: datetime.datetime, grouping: dto.DateGrouping) -> str: + match grouping: + case dto.DateGrouping.DAY: + # "1 дек" или "1 дек 2024" + day = dt.day + month_names = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'] + month = month_names[dt.month - 1] + return f'{day} {month}' + case dto.DateGrouping.WEEK: + # "49 нед. 2024" или "1-7 дек" + year, week, _ = dt.isocalendar() + # Находим первый день недели (понедельник) + days_since_monday = dt.weekday() + week_start = dt - datetime.timedelta(days=days_since_monday) + week_end = week_start + datetime.timedelta(days=6) + month_names = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'] + if week_start.month == week_end.month: + return f'{week_start.day}-{week_end.day} {month_names[week_start.month - 1]}' + else: + return f'{week_start.day} {month_names[week_start.month - 1]}-{week_end.day} {month_names[week_end.month - 1]}' # noqa: E501 + case dto.DateGrouping.MONTH: + # "дек 2024" + month_names = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'] + return f'{month_names[dt.month - 1]} {dt.year}' + case dto.DateGrouping.QUARTER: + # "Q4 2024" + quarter = (dt.month - 1) // 3 + 1 + return f'Q{quarter} {dt.year}' + case _: + raise ValueError('Invalid date grouping') + + +def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime: + placement = placement_post.placement + if placement and placement.placement_at: + return placement.placement_at + if placement_post.post and placement_post.post.created_at: + return placement_post.post.created_at + return placement_post.created_at + + +def _get_grouping_date(placement_post: domain.PlacementPost, date_grouping: dto.DateGroupingType) -> datetime.datetime: + match date_grouping: + case dto.DateGroupingType.PLACEMENT_DATE: + return _get_placement_date(placement_post) + case dto.DateGroupingType.PURCHASE_DATE: + if placement_post.placement: + return placement_post.placement.created_at + return _get_placement_date(placement_post) # Fallback + case dto.DateGroupingType.LINK_DATE: + if placement_post.placement: + return placement_post.placement.created_at + return _get_placement_date(placement_post) # Fallback + case _: + return _get_placement_date(placement_post) + + +@dataclass +class PeriodMetrics: + total_cost: float = 0.0 + purchases_count: int = 0 + total_subscriptions: int = 0 + total_views: int = 0 + clicks_count: int = 0 + reach_volume: int = 0 + total_discounts: float = 0.0 + discount_count: int = 0 + discount_sum: float = 0.0 + + +def _calculate_metrics( + period_metrics: PeriodMetrics, + requested_metrics: list[dto.ProjectMetrics] | None, + hide_subscriptions: bool = False, +) -> dto.ProjectMetricsData: + all_metrics = requested_metrics is None or len(requested_metrics) == 0 + + def should_include(metric: dto.ProjectMetrics) -> bool: + return all_metrics or (requested_metrics is not None and metric in requested_metrics) + + metrics = dto.ProjectMetricsData() + + if should_include(dto.ProjectMetrics.TOTAL_COST): + metrics.total_cost = period_metrics.total_cost + + if should_include(dto.ProjectMetrics.PURCHASES_COUNT): + metrics.purchases_count = period_metrics.purchases_count + + if should_include(dto.ProjectMetrics.TOTAL_SUBSCRIPTIONS): + metrics.total_subscriptions = 0 if hide_subscriptions else period_metrics.total_subscriptions + + if should_include(dto.ProjectMetrics.TOTAL_VIEWS): + metrics.total_views = period_metrics.total_views + + if should_include(dto.ProjectMetrics.CLICKS_COUNT): + metrics.clicks_count = 0 if hide_subscriptions else period_metrics.clicks_count + + if should_include(dto.ProjectMetrics.REACH_VOLUME): + metrics.reach_volume = period_metrics.reach_volume + + if should_include(dto.ProjectMetrics.TOTAL_DISCOUNTS): + metrics.total_discounts = period_metrics.total_discounts + + # Средние значения + if should_include(dto.ProjectMetrics.AVG_CPF): + if hide_subscriptions: + metrics.avg_cpf = None + else: + metrics.avg_cpf = ( + period_metrics.total_cost / period_metrics.total_subscriptions + if period_metrics.total_subscriptions > 0 and period_metrics.total_cost > 0 + else None + ) + + if should_include(dto.ProjectMetrics.AVG_CPM): + metrics.avg_cpm = ( + (period_metrics.total_cost / period_metrics.total_views) * 1000 + if period_metrics.total_views > 0 and period_metrics.total_cost > 0 + else None + ) + + if should_include(dto.ProjectMetrics.AVG_POST_COST): + metrics.avg_post_cost = ( + period_metrics.total_cost / period_metrics.purchases_count + if period_metrics.purchases_count > 0 and period_metrics.total_cost > 0 + else None + ) + + if should_include(dto.ProjectMetrics.AVG_DISCOUNT_PERCENT): + if period_metrics.discount_count > 0: + metrics.avg_discount_percent = period_metrics.discount_sum / period_metrics.discount_count + else: + metrics.avg_discount_percent = 0.0 + + if should_include(dto.ProjectMetrics.AVG_CONVERSION): + # Конверсия = подписки / просмотры * 100 + if hide_subscriptions: + metrics.avg_conversion = None + else: + metrics.avg_conversion = ( + (period_metrics.total_subscriptions / period_metrics.total_views) * 100 + if period_metrics.total_views > 0 and period_metrics.total_subscriptions > 0 + else 0.0 + ) + + return metrics + + +async def get_projects_analytics( + self: 'Usecase', input: dto.GetProjectsAnalyticsInput +) -> dto.GetProjectsAnalyticsOutput: + if input.date_from and input.date_to and input.date_from > input.date_to: + raise HTTPException(status.HTTP_400_BAD_REQUEST, 'date_from must be before date_to') + + context = await self.ensure_analytics_permission(input.workspace_id, input.user_id) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ) + hide_subscriptions = context.should_hide_subscriptions() + + # Фильтрация по project_ids если указаны + if input.project_ids: + if allowed_project_ids is not None: + # Пересечение разрешенных и запрошенных + filtered_ids = [pid for pid in input.project_ids if pid in allowed_project_ids] + if not filtered_ids: + return dto.GetProjectsAnalyticsOutput(periods=[], totals=dto.ProjectMetricsData()) + allowed_project_ids = set(filtered_ids) + else: + allowed_project_ids = set(input.project_ids) + + placements = await self.database.get_workspace_placement_posts( + input.workspace_id, + project_id=None, + include_archived=False, + allowed_project_ids=allowed_project_ids, + date_from=input.date_from, + date_to=input.date_to, + ) + + # Фильтрация по датам на основе date_grouping + filtered_placements = [] + for placement_post in placements: + grouping_date = _get_grouping_date(placement_post, input.date_grouping) + if input.date_from and grouping_date < input.date_from: + continue + if input.date_to and grouping_date > input.date_to: + continue + filtered_placements.append(placement_post) + + # Batch fetch views data + post_ids = [p.post.id for p in filtered_placements if p.post] + views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} + + # Batch fetch subscriptions counts + placement_post_ids = [p.id for p in filtered_placements] + subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids) + + # Группировка по периодам + period_data: dict[str, PeriodMetrics] = defaultdict(PeriodMetrics) + total_metrics = PeriodMetrics() + + for placement_post in filtered_placements: + grouping_date = _get_grouping_date(placement_post, input.date_grouping) + period = _format_period(grouping_date, input.grouping) + pd = period_data[period] + + # Обновляем метрики периода + pd.purchases_count += 1 + total_metrics.purchases_count += 1 + + placement = placement_post.placement + cost = placement.cost_value if placement and placement.cost_value is not None else 0.0 + pd.total_cost += cost + total_metrics.total_cost += cost + + subs_count = subscriptions_counts.get(placement_post.id, 0) + pd.total_subscriptions += subs_count + pd.clicks_count += subs_count + total_metrics.total_subscriptions += subs_count + total_metrics.clicks_count += subs_count + + if placement_post.post and placement_post.post.id in views_map: + views_count = views_map[placement_post.post.id][0] + pd.total_views += views_count + pd.reach_volume += views_count + total_metrics.total_views += views_count + total_metrics.reach_volume += views_count + + # Расчет скидок + if placement and placement.cost_before_bargain and placement.cost_before_bargain > cost: + discount = placement.cost_before_bargain - cost + discount_percent = ( + (discount / placement.cost_before_bargain) * 100 if placement.cost_before_bargain > 0 else 0.0 + ) + + pd.total_discounts += discount + pd.discount_count += 1 + pd.discount_sum += discount_percent + + total_metrics.total_discounts += discount + total_metrics.discount_count += 1 + total_metrics.discount_sum += discount_percent + + # Формируем периоды с метриками + periods: list[dto.ProjectAnalyticsPeriod] = [] + for period_key in sorted(period_data.keys()): + pd = period_data[period_key] + + # Определяем дату для period_label (берем первую дату периода) + if input.grouping == dto.DateGrouping.DAY: + period_dt = datetime.datetime.strptime(period_key, '%Y-%m-%d').replace(tzinfo=datetime.UTC) + elif input.grouping == dto.DateGrouping.WEEK: + # ISO week format: YYYY-Www + year, week_str = period_key.split('-W') + week = int(week_str) + # Находим первый день недели (понедельник) для данной ISO недели + jan4 = datetime.datetime(int(year), 1, 4, tzinfo=datetime.UTC) + jan4_weekday = jan4.weekday() # 0=Monday, 6=Sunday + days_since_monday = (jan4_weekday + 1) % 7 + jan4_monday = jan4 - datetime.timedelta(days=days_since_monday) + period_dt = jan4_monday + datetime.timedelta(weeks=week - 1) + elif input.grouping == dto.DateGrouping.MONTH: + period_dt = datetime.datetime.strptime(period_key, '%Y-%m').replace(tzinfo=datetime.UTC) + elif input.grouping == dto.DateGrouping.QUARTER: + year, quarter = period_key.split('-Q') + month = (int(quarter) - 1) * 3 + 1 + period_dt = datetime.datetime(int(year), month, 1, tzinfo=datetime.UTC) + else: + period_dt = timezone.now() + + period_label = _format_period_label(period_dt, input.grouping) + metrics = _calculate_metrics(pd, input.metrics, hide_subscriptions) + + periods.append( + dto.ProjectAnalyticsPeriod( + period=period_key, + period_label=period_label, + metrics=metrics, + ) + ) + + totals = _calculate_metrics(total_metrics, input.metrics, hide_subscriptions) + + return dto.GetProjectsAnalyticsOutput(periods=periods, totals=totals) diff --git a/src/usecase/analytics/get_spending_analytics.py b/src/usecase/analytics/get_spending_analytics.py new file mode 100644 index 0000000..ff80d38 --- /dev/null +++ b/src/usecase/analytics/get_spending_analytics.py @@ -0,0 +1,159 @@ +import datetime +import logging +from collections import defaultdict +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +def _format_period(dt: 'datetime.datetime', grouping: dto.DateGrouping) -> str: + match grouping: + case dto.DateGrouping.DAY: + return str(dt.strftime('%Y-%m-%d')) + case dto.DateGrouping.WEEK: + # ISO week + return str(dt.strftime('%Y-W%W')) + case dto.DateGrouping.MONTH: + return str(dt.strftime('%Y-%m')) + case dto.DateGrouping.QUARTER: + quarter = (dt.month - 1) // 3 + 1 + return f'{dt.year}-Q{quarter}' + case dto.DateGrouping.YEAR: + return str(dt.year) + + raise ValueError('Invalid date grouping') + + +def _get_placement_date(placement_post: domain.PlacementPost) -> datetime.datetime: + placement = placement_post.placement + if placement and placement.placement_at: + return placement.placement_at + if placement_post.post and placement_post.post.created_at: + return placement_post.post.created_at + return placement_post.created_at + + +def _get_cost(placement_post: domain.PlacementPost) -> float | None: + placement = placement_post.placement + return placement.cost_value if placement else None + + +async def get_spending_analytics( + self: 'Usecase', input: dto.GetSpendingAnalyticsInput +) -> dto.GetSpendingAnalyticsOutput: + context = await self.ensure_analytics_permission(input.workspace_id, input.user_id) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ) + hide_subscriptions = context.should_hide_subscriptions() + + if input.project_id: + project = await self.database.get_project(input.workspace_id, input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) + allowed_project_ids = None + + placements = await self.database.get_workspace_placement_posts( + input.workspace_id, + input.project_id, + include_archived=False, + allowed_project_ids=allowed_project_ids, + ) + + filtered: list[tuple[domain.PlacementPost, datetime.datetime]] = [] + for placement_post in placements: + placement_date = _get_placement_date(placement_post) + if input.date_from and placement_date < input.date_from: + continue + if input.date_to and placement_date > input.date_to: + continue + filtered.append((placement_post, placement_date)) + + total_cost = 0.0 + total_subs = 0 + total_views = 0 + + @dataclass + class PeriodData: + cost: float = 0.0 + subscriptions: int = 0 + views: int = 0 + + # Batch fetch views data for all posts + post_ids = [p.post.id for p, _ in filtered if p.post] + views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} + + # Batch fetch subscriptions counts + placement_ids = [p.id for p, _ in filtered] + subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids) + + # Группировка по периодам + period_data: dict[str, PeriodData] = defaultdict(PeriodData) + + for p, placement_date in filtered: + period = _format_period(placement_date, input.grouping) + pd = period_data[period] + + cost = _get_cost(p) + if cost is not None: + total_cost += cost + pd.cost += cost + + subs_count = subscriptions_counts.get(p.id, 0) + total_subs += subs_count + pd.subscriptions += subs_count + + # Get views from batch data + if p.post and p.post.id in views_map: + views_count = views_map[p.post.id][0] + total_views += views_count + pd.views += views_count + + output_total_subs = 0 if hide_subscriptions else total_subs + avg_cpf = None if hide_subscriptions else (total_cost / total_subs if total_subs > 0 and total_cost > 0 else None) + avg_cpm = (total_cost / total_views * 1000) if total_views > 0 and total_cost > 0 else None + + # Count unique placements + unique_placements = set() + for p, _ in filtered: + if p.placement: + unique_placements.add(p.placement.id) + placements_count = len(unique_placements) + + # Данные для графика + chart_data = [] + for period in sorted(period_data.keys()): + d = period_data[period] + + cost = d.cost + subs = 0 if hide_subscriptions else d.subscriptions + views = d.views + + cpf = None if hide_subscriptions else (cost / d.subscriptions if d.subscriptions > 0 and cost > 0 else None) + cpm = (cost / views * 1000) if views > 0 and cost > 0 else None + + chart_data.append( + dto.SpendingDataPoint( + period=period, + cost=cost, + subscriptions=subs, + views=views, + cpf=cpf, + cpm=cpm, + ) + ) + + return dto.GetSpendingAnalyticsOutput( + total_cost=total_cost, + total_subscriptions=output_total_subs, + total_views=total_views, + avg_cpf=avg_cpf, + avg_cpm=avg_cpm, + chart_data=chart_data, + placements_count=placements_count, + ) diff --git a/src/usecase/auth/attach_login_token_message.py b/src/usecase/auth/attach_login_token_message.py new file mode 100644 index 0000000..d6f09ba --- /dev/null +++ b/src/usecase/auth/attach_login_token_message.py @@ -0,0 +1,8 @@ +import typing + +if typing.TYPE_CHECKING: + from .. import Usecase + + +async def attach_login_token_message(self: 'Usecase', token: str, message_id: int) -> None: + await self.database.update_login_token_message_id(token=token, message_id=message_id) diff --git a/src/usecase/auth/create_telegram_login_token.py b/src/usecase/auth/create_telegram_login_token.py new file mode 100644 index 0000000..8f3c1f1 --- /dev/null +++ b/src/usecase/auth/create_telegram_login_token.py @@ -0,0 +1,34 @@ +import datetime +import secrets +import typing + +from tortoise import timezone + +from src import domain + +if typing.TYPE_CHECKING: + from .. import Usecase + + +async def create_telegram_login_token(self: 'Usecase', telegram_id: int) -> str: + telegram_user = await self.database.get_telegram_user(telegram_id=telegram_id) + if not telegram_user: + telegram_user = domain.TelegramUser(telegram_id=telegram_id) + await self.database.create_telegram_user(telegram_user) + + user = await self.database.get_user(telegram_id=telegram_id) + if not user: + user = domain.User(telegram_user=telegram_user) + await self.database.create_user(user) + + token = secrets.token_urlsafe(32) + expires_at = timezone.now() + datetime.timedelta(minutes=10) + + login_token = domain.LoginToken( + token=token, + user=user, + expires_at=expires_at, + ) + await self.database.create_login_token(login_token) + + return token diff --git a/src/usecase/auth/get_jwt_by_telegram_id.py b/src/usecase/auth/get_jwt_by_telegram_id.py new file mode 100644 index 0000000..531b8f8 --- /dev/null +++ b/src/usecase/auth/get_jwt_by_telegram_id.py @@ -0,0 +1,52 @@ +import typing + +from src import domain, dto + +if typing.TYPE_CHECKING: + from .. import Usecase + + +async def get_jwt_by_telegram_id( + self: 'Usecase', + telegram_id: int, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, +) -> dto.ValidateLoginTokenOutput: + telegram_user = await self.database.get_telegram_user(telegram_id=telegram_id) + if not telegram_user: + telegram_user = domain.TelegramUser( + telegram_id=telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + ) + await self.database.create_telegram_user(telegram_user) + else: + updated = False + if username is not None and telegram_user.username != username: + telegram_user.username = username + updated = True + if first_name is not None and telegram_user.first_name != first_name: + telegram_user.first_name = first_name + updated = True + if last_name is not None and telegram_user.last_name != last_name: + telegram_user.last_name = last_name + updated = True + if updated: + await self.database.update_telegram_user(telegram_user) + + user = await self.database.get_user(telegram_id=telegram_id) + if not user: + user = domain.User(telegram_user=telegram_user) + await self.database.create_user(user) + + access_token = self.jwt_encoder.encode_access_token( + user_id=user.id, + telegram_id=telegram_user.telegram_id, + username=telegram_user.username, + ) + + return dto.ValidateLoginTokenOutput( + access_token=access_token, + ) diff --git a/src/usecase/auth/get_me.py b/src/usecase/auth/get_me.py new file mode 100644 index 0000000..6d752b4 --- /dev/null +++ b/src/usecase/auth/get_me.py @@ -0,0 +1,27 @@ +import typing +import uuid + +from src import domain, dto + +if typing.TYPE_CHECKING: + from .. import Usecase + + +async def get_me(self: 'Usecase', user_id: uuid.UUID) -> dto.UserOutput: + user = await self.database.get_user(user_id=user_id) + + if not user: + raise domain.UserNotFound(user_id) + + if not user.telegram_user: + raise domain.UserNotFound(user_id) + + telegram_user = user.telegram_user + + return dto.UserOutput( + id=user.id, + telegram_id=telegram_user.telegram_id, + username=telegram_user.username, + first_name=telegram_user.first_name, + last_name=telegram_user.last_name, + ) diff --git a/src/usecase/auth/validate_login_token.py b/src/usecase/auth/validate_login_token.py new file mode 100644 index 0000000..de7fc8d --- /dev/null +++ b/src/usecase/auth/validate_login_token.py @@ -0,0 +1,52 @@ +import logging +import typing + +from tortoise import timezone + +from src import domain, dto + +if typing.TYPE_CHECKING: + from .. import Usecase + + +async def validate_login_token(self: 'Usecase', input: dto.ValidateLoginTokenInput) -> dto.ValidateLoginTokenOutput: + login_token = await self.database.get_login_token(input.token) + + if not login_token: + raise domain.LoginTokenNotFound() + + if login_token.used_at: + raise domain.LoginTokenAlreadyUsed() + + if login_token.expires_at < timezone.now(): + raise domain.LoginTokenExpired() + + user = await self.database.get_user(user_id=login_token.user_id) + if not user: + raise domain.UserNotFound(login_token.user_id) + + await self.database.mark_token_as_used(input.token) + + telegram_user = user.telegram_user + if telegram_user is None: + raise domain.UserNotFound(login_token.user_id) + + if login_token.message_id is not None: + try: + await self.telegram_bot.edit_message_text( + text='✅ Вы успешно авторизованы', + chat_id=telegram_user.telegram_id, + message_id=login_token.message_id, + ) + except Exception: + logging.getLogger(__name__).exception('Failed to update login message') + + access_token = self.jwt_encoder.encode_access_token( + user_id=user.id, + telegram_id=telegram_user.telegram_id, + username=telegram_user.username, + ) + + return dto.ValidateLoginTokenOutput( + access_token=access_token, + ) diff --git a/src/usecase/channel/attach_channel_to_workspace.py b/src/usecase/channel/attach_channel_to_workspace.py new file mode 100644 index 0000000..ab5b377 --- /dev/null +++ b/src/usecase/channel/attach_channel_to_workspace.py @@ -0,0 +1,55 @@ +import logging +from typing import TYPE_CHECKING + +from fastapi import HTTPException + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def attach_channel_to_workspace(self: 'Usecase', input: dto.AttachChannelToWorkspaceInput) -> dto.ProjectOutput: + """Привязать канал к workspace (вызывается из Golang бота после выбора workspace пользователем)""" + + channel = await self.database.get_channel(channel_id=input.channel_id) + if not channel: + raise HTTPException(status_code=404, detail='Channel not found') + + workspace = await self.database.get_workspace(input.workspace_id) + if not workspace: + raise HTTPException(status_code=404, detail='Workspace not found') + + # Проверяем что проект еще не существует + project = await self.database.get_project(workspace.id, channel_id=channel.id) + if project: + # Проект уже существует - просто активируем + project.status = domain.ProjectStatus.ACTIVE + await self.database.update_project(project) + log.info('Project %s reactivated in workspace %s', project.id, workspace.id) + else: + # Создаем новый проект + project = domain.Project( + workspace_id=workspace.id, + channel_id=channel.id, + status=domain.ProjectStatus.ACTIVE, + ) + await self.database.create_project(project) + log.info('Project created for channel %s in workspace %s', channel.id, workspace.id) + + return dto.ProjectOutput( + id=project.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + status=project.status, + purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + ), + ) diff --git a/src/usecase/channel/create_channels.py b/src/usecase/channel/create_channels.py new file mode 100644 index 0000000..50ed819 --- /dev/null +++ b/src/usecase/channel/create_channels.py @@ -0,0 +1,95 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def create_channels(self: 'Usecase', input: dto.CreateChannelsInput) -> dto.CreateChannelsOutput: + results: list[dto.CreateChannelResult] = [] + + for index, channel_input in enumerate(input.channels): + try: + if channel_input.username: + parser_response = await self.parser.fetch_telegram_channel(channel_input.username) + if not parser_response: + raise domain.TelegramChannelNotFound(channel_input.username) + else: + parser_response = await self.parser.resolve_telegram_channel_by_invite(channel_input.invite_link or '') + if not parser_response: + raise ValueError('Telegram channel not found by invite link') + + parsed_username = parser_response.username or None + is_private = channel_input.invite_link != '' + + channel = await self.database.get_channel(telegram_id=parser_response.telegram_id) + if not channel and parsed_username: + channel = await self.database.get_channel(username=parsed_username) + + status = 'created' + if channel: + status = 'updated' + updated = False + # Канал стал публичным (появился username) + if parsed_username is not None and channel.username != parsed_username: + channel.username = parsed_username + channel.invite_link = None # Очищаем invite_link у публичных каналов + updated = True + # Канал остаётся приватным или обновляется + elif parsed_username is None and channel_input.invite_link and channel.invite_link != channel_input.invite_link: + channel.invite_link = channel_input.invite_link + updated = True + + if parser_response.title is not None and channel.title != parser_response.title: + channel.title = parser_response.title + updated = True + if parser_response.telegram_id is not None and channel.telegram_id != parser_response.telegram_id: + channel.telegram_id = parser_response.telegram_id + updated = True + if parser_response.access_hash is not None and channel.access_hash != parser_response.access_hash: + channel.access_hash = parser_response.access_hash + updated = True + if parser_response.pts is not None and channel.pts != parser_response.pts: + channel.pts = parser_response.pts + updated = True + if updated: + await self.database.update_channel(channel) + else: + channel = domain.Channel( + username=parsed_username, + telegram_id=parser_response.telegram_id, + title=parser_response.title, + access_hash=parser_response.access_hash, + pts=0 if is_private else parser_response.pts, + # Для публичных каналов (есть username) invite_link не храним + invite_link=None if parsed_username else channel_input.invite_link, + ) + await self.database.create_channel(channel) + + results.append( + dto.CreateChannelResult( + index=index, + status=status, + channel=dto.ChannelOutput( + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + ), + ) + ) + except Exception as exc: + log.warning('Failed to create channel at index %s: %s', index, exc) + results.append( + dto.CreateChannelResult( + index=index, + status='failed', + error=str(exc), + ) + ) + + return dto.CreateChannelsOutput(results=results) diff --git a/src/usecase/channel/get_channel.py b/src/usecase/channel/get_channel.py new file mode 100644 index 0000000..7996921 --- /dev/null +++ b/src/usecase/channel/get_channel.py @@ -0,0 +1,22 @@ +from typing import TYPE_CHECKING + +from fastapi import HTTPException + +from src import dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def get_channel(self: 'Usecase', input: dto.GetChannelInput) -> dto.ChannelOutput: + channel = await self.database.get_channel(channel_id=input.channel_id) + + if not channel: + raise HTTPException(status_code=404, detail='Channel not found') + + return dto.ChannelOutput( + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + ) diff --git a/src/usecase/channel/get_channels.py b/src/usecase/channel/get_channels.py new file mode 100644 index 0000000..ba97f67 --- /dev/null +++ b/src/usecase/channel/get_channels.py @@ -0,0 +1,25 @@ +import logging +from typing import TYPE_CHECKING + +from src import dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> list[dto.ChannelOutput]: + channels = await self.database.search_channels(username_query=input.username) + + log.debug('Found %s channels for username query: %s', len(channels), input.username) + + return [ + dto.ChannelOutput( + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + ) + for channel in channels + ] diff --git a/src/usecase/creative/create_creative.py b/src/usecase/creative/create_creative.py new file mode 100644 index 0000000..fec25e6 --- /dev/null +++ b/src/usecase/creative/create_creative.py @@ -0,0 +1,102 @@ +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def create_creative( + self: 'Usecase', input: dto.CreateCreativeInput, project_id: uuid.UUID, user_id: uuid.UUID, workspace_id: uuid.UUID +) -> dto.CreativeOutput: + await self.ensure_workspace_permission( + workspace_id, user_id, domain.PermissionKey.CREATIVES_WRITE, for_project_id=project_id + ) + + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: + log.warning('User %s attempted to create creative for unavailable project %s', user_id, project_id) + raise domain.ProjectNotFound(project_id) + + creative_text = domain.replace_invite_link_with_tag(input.text) + media_items = input.media_items or [] + domain.validate_media_items([item.media_type for item in media_items]) + for item in media_items: + domain.validate_media_size(item.media_data) + + creative = domain.Creative( + name=input.name, + text=creative_text, + buttons=[button.model_dump() for button in input.buttons], + status=domain.CreativeStatus.ACTIVE, + tag=input.tag or domain.CreativeTag.TESTING, + project_id=project.id, + created_by_user_id=user_id, + ) + await self.database.create_creative(creative) + + created_media = await _replace_media_items(self, creative.id, workspace_id, media_items) + + return dto.CreativeOutput( + id=creative.id, + name=creative.name, + text=creative.text, + media_items=created_media, + buttons=creative.buttons, + project_id=project.id, + project_channel_title=project.channel.title, + created_at=creative.created_at, + status=creative.status, + tag=creative.tag, + placements_count=0, + ) + + +def _get_content_type(media_type: str | None) -> str: + """Map Telegram media type to MIME content type.""" + if not media_type: + return 'application/octet-stream' + + mapping = { + 'photo': 'image/jpeg', + 'video': 'video/mp4', + 'animation': 'image/gif', + } + return mapping.get(media_type, 'application/octet-stream') + + +async def _replace_media_items( + self: 'Usecase', + creative_id: uuid.UUID, + workspace_id: uuid.UUID, + media_items: list[dto.CreativeMediaInput], +) -> list[dto.CreativeMediaItem]: + created: list[dto.CreativeMediaItem] = [] + for position, item in enumerate(media_items): + media_s3_key: str | None = None + if item.media_data: + file_id = uuid.uuid4() + media_s3_key = f'creatives/{workspace_id}/{file_id}' + content_type = _get_content_type(item.media_type) + await self.s3.upload(media_s3_key, item.media_data, content_type) + log.info('Uploaded creative media to S3: %s', media_s3_key) + media = await domain.CreativeMedia.create( + creative_id=creative_id, + media_type=item.media_type, + media_file_id=item.media_file_id, + media_s3_key=media_s3_key, + position=position, + ) + created.append( + dto.CreativeMediaItem( + media_type=media.media_type, + media_file_id=media.media_file_id, + position=media.position, + s3_url=self.s3.public_url(media.media_s3_key) if media.media_s3_key else None, + ) + ) + return created diff --git a/src/usecase/creative/delete_creative.py b/src/usecase/creative/delete_creative.py new file mode 100644 index 0000000..c8a75d5 --- /dev/null +++ b/src/usecase/creative/delete_creative.py @@ -0,0 +1,44 @@ +import asyncio +import logging +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> None: + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.CREATIVES_WRITE + ) + + creative = await self.database.get_creative(input.workspace_id, input.creative_id) + if not creative: + log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id) + raise domain.CreativeNotFound(input.creative_id) + + context.ensure_project_permission(domain.PermissionKey.CREATIVES_WRITE, creative.project_id) + + has_placement_posts = await self.database.has_placement_posts_for_creative(creative.id) + if has_placement_posts: + log.warning('Creative %s is used in placement_posts and cannot be deleted', input.creative_id) + raise domain.CreativeInUse(input.creative_id) + + media_items = await creative.media_items.all() + media_keys = [item.media_s3_key for item in media_items if item.media_s3_key] + if media_keys: + + async def delete_old_media() -> None: + for key in media_keys: + try: + await self.s3.delete(key) + log.info('Deleted old creative media from S3: %s', key) + except Exception as e: + log.warning('Failed to delete old creative media from S3: %s', e) + + asyncio.create_task(delete_old_media()) + + await self.database.delete_creative(input.creative_id) diff --git a/src/usecase/creative/get_creative.py b/src/usecase/creative/get_creative.py new file mode 100644 index 0000000..b6926c3 --- /dev/null +++ b/src/usecase/creative/get_creative.py @@ -0,0 +1,50 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.CreativeOutput: + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.CREATIVES_READ + ) + + creative = await self.database.get_creative(input.workspace_id, input.creative_id) + if not creative: + raise domain.CreativeNotFound(input.creative_id) + + context.ensure_project_permission(domain.PermissionKey.CREATIVES_READ, creative.project_id) + + placements_count = await self.database.count_placement_posts_by_creative(creative.id) + media_rel = creative.media_items + if hasattr(media_rel, 'all'): + media_items = await media_rel.all().order_by('position') + else: + media_items = sorted(media_rel, key=lambda item: item.position) + + return dto.CreativeOutput( + id=creative.id, + name=creative.name, + text=creative.text, + media_items=[ + dto.CreativeMediaItem( + media_type=item.media_type, + media_file_id=item.media_file_id, + position=item.position, + s3_url=self.s3.public_url(item.media_s3_key) if item.media_s3_key else None, + ) + for item in media_items + ], + buttons=creative.buttons, + project_id=creative.project_id, + project_channel_title=creative.project.channel.title, + created_at=creative.created_at, + status=creative.status, + tag=creative.tag, + placements_count=placements_count, + ) diff --git a/src/usecase/creative/get_creatives.py b/src/usecase/creative/get_creatives.py new file mode 100644 index 0000000..5e36112 --- /dev/null +++ b/src/usecase/creative/get_creatives.py @@ -0,0 +1,60 @@ +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[dto.CreativeOutput]: + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.CREATIVES_READ + ) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.CREATIVES_READ) + + if input.project_id is not None: + context.ensure_project_permission(domain.PermissionKey.CREATIVES_READ, input.project_id) + allowed_project_ids = None + + creatives = await self.database.get_workspace_creatives( + input.workspace_id, + input.project_id, + input.include_archived, + allowed_project_ids=allowed_project_ids, + ) + + creative_ids = [c.id for c in creatives] + placements_counts = await self.database.count_placement_posts_by_creative_batch(creative_ids) + + results: list[dto.CreativeOutput] = [] + for creative in creatives: + media_rel = creative.media_items + if hasattr(media_rel, 'all'): + media_items = await media_rel.all().order_by('position') + else: + media_items = sorted(media_rel, key=lambda item: item.position) + results.append( + dto.CreativeOutput( + id=creative.id, + name=creative.name, + text=creative.text, + media_items=[ + dto.CreativeMediaItem( + media_type=item.media_type, + media_file_id=item.media_file_id, + position=item.position, + s3_url=self.s3.public_url(item.media_s3_key) if item.media_s3_key else None, + ) + for item in media_items + ], + buttons=creative.buttons, + project_id=creative.project_id, + project_channel_title=creative.project.channel.title, + created_at=creative.created_at, + status=creative.status, + tag=creative.tag, + placements_count=placements_counts.get(creative.id, 0), + ) + ) + return results diff --git a/src/usecase/creative/update_creative.py b/src/usecase/creative/update_creative.py new file mode 100644 index 0000000..19d34ab --- /dev/null +++ b/src/usecase/creative/update_creative.py @@ -0,0 +1,136 @@ +import asyncio +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import S3Storage, Usecase + +log = logging.getLogger(__name__) + + +async def update_creative( + self: 'Usecase', + creative_id: uuid.UUID, + input: dto.UpdateCreativeInput, + user_id: uuid.UUID, + workspace_id: uuid.UUID, +) -> dto.CreativeOutput: + context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.CREATIVES_WRITE) + + creative = await self.database.get_creative(workspace_id, creative_id) + if not creative: + log.warning('User %s attempted to update unavailable creative %s', user_id, creative_id) + raise domain.CreativeNotFound(creative_id) + + context.ensure_project_permission(domain.PermissionKey.CREATIVES_WRITE, creative.project_id) + + if input.name: + creative.name = input.name + if input.text: + creative.text = domain.replace_invite_link_with_tag(input.text) + media_items: list[dto.CreativeMediaInput] | None = None + if input.media_items is not None: + media_items = input.media_items + domain.validate_media_items([item.media_type for item in media_items]) + for item in media_items: + domain.validate_media_size(item.media_data) + if input.buttons is not None: + creative.buttons = [button.model_dump() for button in input.buttons] + if input.status: + creative.status = input.status + if input.tag: + creative.tag = input.tag + + await self.database.update_creative(creative) + + if media_items is not None: + await _replace_media_items(self, creative.id, workspace_id, media_items) + + placements_count = await self.database.count_placement_posts_by_creative(creative.id) + creative_media = await _get_media_items(creative, self.s3) + + return dto.CreativeOutput( + id=creative.id, + name=creative.name, + text=creative.text, + media_items=creative_media, + buttons=creative.buttons, + project_id=creative.project_id, + project_channel_title=creative.project.channel.title, + created_at=creative.created_at, + status=creative.status, + tag=creative.tag, + placements_count=placements_count, + ) + + +def _get_content_type(media_type: str | None) -> str: + if not media_type: + return 'application/octet-stream' + + mapping = { + 'photo': 'image/jpeg', + 'video': 'video/mp4', + 'animation': 'image/gif', + } + return mapping.get(media_type, 'application/octet-stream') + + +async def _replace_media_items( + self: 'Usecase', + creative_id: uuid.UUID, + workspace_id: uuid.UUID, + media_items: list[dto.CreativeMediaInput], +) -> None: + existing_items = await domain.CreativeMedia.filter(creative_id=creative_id).all() + if existing_items: + old_media_keys = [item.media_s3_key for item in existing_items if item.media_s3_key] + await domain.CreativeMedia.filter(creative_id=creative_id).delete() + + if old_media_keys: + + async def delete_old_media() -> None: + for key in old_media_keys: + try: + await self.s3.delete(key) + log.info('Deleted old creative media from S3: %s', key) + except Exception as e: + log.warning('Failed to delete old creative media from S3: %s', e) + + asyncio.create_task(delete_old_media()) + + for position, item in enumerate(media_items): + media_s3_key: str | None = None + if item.media_data: + file_id = uuid.uuid4() + media_s3_key = f'creatives/{workspace_id}/{file_id}' + content_type = _get_content_type(item.media_type) + await self.s3.upload(media_s3_key, item.media_data, content_type) + log.info('Uploaded new creative media to S3: %s', media_s3_key) + await domain.CreativeMedia.create( + creative_id=creative_id, + media_type=item.media_type, + media_file_id=item.media_file_id, + media_s3_key=media_s3_key, + position=position, + ) + + +async def _get_media_items(creative: domain.Creative, s3_storage: 'S3Storage') -> list[dto.CreativeMediaItem]: + media_rel = creative.media_items + if hasattr(media_rel, 'all'): + items = await media_rel.all().order_by('position') + else: + items = sorted(media_rel, key=lambda item: item.position) + return [ + dto.CreativeMediaItem( + media_type=item.media_type, + media_file_id=item.media_file_id, + position=item.position, + s3_url=s3_storage.public_url(item.media_s3_key) if item.media_s3_key else None, + ) + for item in items + ] diff --git a/src/usecase/placement/fetch_placement_post_cycle.py b/src/usecase/placement/fetch_placement_post_cycle.py new file mode 100644 index 0000000..35ed203 --- /dev/null +++ b/src/usecase/placement/fetch_placement_post_cycle.py @@ -0,0 +1,77 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None: + """Находит посты в каналах по invite_link из Placement и создает PlacementPost""" + log.debug('Starting fetch_placement_post_cycle') + + # Получаем все approved Placements с invite_link + placements = ( + await domain.Placement.filter( + status__in=[ + domain.PlacementStatus.NO_STATUS, + domain.PlacementStatus.WRITE, + domain.PlacementStatus.WAITING_RESPONSE, + domain.PlacementStatus.TERMS_APPROVAL, + domain.PlacementStatus.TO_PAY, + domain.PlacementStatus.PAID, + ], + invite_link__isnull=False, + ) + .prefetch_related('channel', 'project') + .all() + ) + + if not placements: + log.debug('No active placements found') + return + + created_count = 0 + + for placement in placements: + if placement.channel is None or placement.invite_link is None: + log.warning('Placement %s missing channel or invite_link, skipping', placement.id) + continue + if placement.creative_id is None: + continue + existing_for_placement = await domain.PlacementPost.filter(placement_id=placement.id).first() + if existing_for_placement: + log.debug('Placement %s already has placement_post %s, skipping', placement.id, existing_for_placement.id) + continue + + # Ищем посты в канале, содержащие invite_link из placement + posts = await domain.Post.filter( + channel_id=placement.channel_id, + text__contains=placement.invite_link, + deleted_from_channel_at__isnull=True, + ).all() + + for post in posts: + # Проверяем, не создана ли уже публикация для этого поста + existing = await domain.PlacementPost.filter(post_id=post.id).first() + if existing: + continue + + placement_post = domain.PlacementPost( + placement_id=placement.id, + post_id=post.id, + ) + + await self.database.create_placement_post(placement_post) + created_count += 1 + log.info( + 'Created placement_post %s for placement %s from post %s', + placement_post.id, + placement.id, + post.id, + ) + + log.debug('Fetch placement_post post cycle completed. Created %s placement_posts', created_count) diff --git a/src/usecase/placement/update_post_status_cycle.py b/src/usecase/placement/update_post_status_cycle.py new file mode 100644 index 0000000..35c766f --- /dev/null +++ b/src/usecase/placement/update_post_status_cycle.py @@ -0,0 +1,141 @@ +import datetime +import logging +from typing import TYPE_CHECKING + +from tortoise import timezone + +from src import domain +from src.domain.placement_post import PlacementPostStatus + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +# Статусы, которые не должны обновляться автоматически (финальные или ручные) +FINAL_STATUSES = { + PlacementPostStatus.COMPLETED_DELETED, + PlacementPostStatus.COMPLETED_NOT_DELETED, + PlacementPostStatus.CHECK_COMPLETED, +} + +# Статусы, при которых нужно проверять условия для автоматического обновления +AUTO_UPDATE_STATUSES = { + PlacementPostStatus.NO_STATUS, + PlacementPostStatus.SEND_POST, + PlacementPostStatus.POST_APPROVAL, + PlacementPostStatus.WAITING_SCHEDULE, + PlacementPostStatus.SCHEDULED, + PlacementPostStatus.POST_PUBLISHED, + PlacementPostStatus.CHECK_DELETED_EARLY, + PlacementPostStatus.CHECK_NOT_PUBLISHED, +} + + +async def update_post_status_cycle(self: 'Usecase', interval_seconds: int) -> None: + """Автоматически обновляет статусы PlacementPost на основе состояния постов""" + log.debug('Starting update_post_status_cycle') + + # Получаем все PlacementPost со статусами, которые могут быть обновлены + placement_posts = ( + await domain.PlacementPost.filter( + status__in=list(AUTO_UPDATE_STATUSES), + ) + .prefetch_related('placement', 'post', 'post__channel') + .all() + ) + + if not placement_posts: + log.debug('No placement_posts to update') + return + + updated_count = 0 + now = timezone.now() + + for placement_post in placement_posts: + placement = placement_post.placement + post = placement_post.post + + if not placement: + log.warning('PlacementPost %s missing placement', placement_post.id) + continue + + new_status = _determine_status(placement, placement_post, post, now) + + if new_status and new_status != placement_post.status: + old_status = placement_post.status + placement_post.status = new_status + await placement_post.save() + updated_count += 1 + log.info( + 'Updated PlacementPost %s status: %s -> %s', + placement_post.id, + old_status, + new_status, + ) + + log.debug('Update post status cycle completed. Updated %s placement_posts', updated_count) + + +def _determine_status( + placement: domain.Placement, + placement_post: domain.PlacementPost, + post: domain.Post | None, + now: datetime.datetime, +) -> PlacementPostStatus | None: + """Определяет новый статус PlacementPost на основе текущего состояния""" + + # Если поста нет и прошло время размещения - "Пост не вышел" + if post is None: + if placement.placement_at and placement.placement_at < now: + return PlacementPostStatus.CHECK_NOT_PUBLISHED + return None + + # Если пост есть, но не опубликован - оставляем как есть + if not post.published_at: + return None + + # Пост опубликован - проверяем условия для перехода статусов + published_at = post.published_at + deleted_at = post.deleted_from_channel_at + + # Вычисляем время в топе + if deleted_at: + time_on_top = int((deleted_at - published_at).total_seconds()) + else: + time_on_top = int((now - published_at).total_seconds()) + + # Получаем требуемую длительность из формата + required_duration = domain.get_feed_duration_seconds(placement) + + # Если формат "без удаления" - размещение отработало, если прошло 24 часа + if required_duration is None: + # По умолчанию считаем что "без удаления" = 24 часа минимум + required_duration = 24 * 3600 + + # Проверяем условия для автоматических статусов + + # Пост удалён раньше срока + if deleted_at and time_on_top < required_duration: + return PlacementPostStatus.CHECK_DELETED_EARLY + + # Размещение отработало - пост удалён + if deleted_at and time_on_top >= required_duration: + return PlacementPostStatus.COMPLETED_DELETED + + # Размещение отработало - пост не удалён + if not deleted_at and time_on_top >= required_duration: + return PlacementPostStatus.COMPLETED_NOT_DELETED + + # Пост вышел, но ещё не отработал + if placement_post.status in { + PlacementPostStatus.NO_STATUS, + PlacementPostStatus.SEND_POST, + PlacementPostStatus.POST_APPROVAL, + PlacementPostStatus.WAITING_SCHEDULE, + PlacementPostStatus.SCHEDULED, + }: + return PlacementPostStatus.POST_PUBLISHED + + return None diff --git a/src/usecase/project/archive_project.py b/src/usecase/project/archive_project.py new file mode 100644 index 0000000..e7304b7 --- /dev/null +++ b/src/usecase/project/archive_project.py @@ -0,0 +1,66 @@ +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def archive_project(self: 'Usecase', input: dto.ArchiveProjectInput) -> dto.ProjectOutput: + await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=input.project_id + ) + + async with self.database.transaction(): + await self.database.archive_project(input.workspace_id, input.project_id) + project = await self.database.get_project(input.workspace_id, project_id=input.project_id) + + if not project: + raise domain.ProjectNotFound() + + await project.fetch_related('channel') + + return dto.ProjectOutput( + id=project.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + status=project.status, + purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=project.channel.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + ), + ) + + +async def unarchive_project(self: 'Usecase', input: dto.ArchiveProjectInput) -> dto.ProjectOutput: + await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=input.project_id + ) + + async with self.database.transaction(): + await self.database.unarchive_project(input.workspace_id, input.project_id) + project = await self.database.get_project(input.workspace_id, project_id=input.project_id) + + if not project: + raise domain.ProjectNotFound() + + await project.fetch_related('channel') + + return dto.ProjectOutput( + id=project.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + status=project.status, + purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=project.channel.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + ), + ) diff --git a/src/usecase/project/delete_project.py b/src/usecase/project/delete_project.py new file mode 100644 index 0000000..3e28fed --- /dev/null +++ b/src/usecase/project/delete_project.py @@ -0,0 +1,16 @@ +import uuid +from typing import TYPE_CHECKING + +from src import domain + +if TYPE_CHECKING: + from .. import Usecase + + +async def delete_project(self: 'Usecase', workspace_id: uuid.UUID, project_id: uuid.UUID, user_id: uuid.UUID) -> None: + await self.ensure_workspace_permission( + workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=project_id + ) + + async with self.database.transaction(): + await self.database.delete_project(workspace_id, project_id) diff --git a/src/usecase/project/disconnect_project_by_tg_id.py b/src/usecase/project/disconnect_project_by_tg_id.py new file mode 100644 index 0000000..d9cfb17 --- /dev/null +++ b/src/usecase/project/disconnect_project_by_tg_id.py @@ -0,0 +1,29 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def disconnect_project_by_tg_id(self: 'Usecase', input: dto.DisconnectProjectByTgIdInput) -> None: + user = await self.database.get_user(telegram_id=input.user_telegram_id) + if not user: + log.warning(f'User with telegram_id {input.user_telegram_id} not found when disconnecting channel') + return + if user.telegram_user is None: + log.warning('User %s missing telegram profile when disconnecting channel', user.id) + return + + project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id) + if not project: + log.warning(f'Project channel {input.telegram_id} not found') + raise domain.ProjectNotFound() + + project.status = domain.ProjectStatus.ARCHIVED + await self.database.update_project(project) + + log.info('Project %s archived for channel %s', project.id, input.telegram_id) diff --git a/src/usecase/project/get_project.py b/src/usecase/project/get_project.py new file mode 100644 index 0000000..5c3824c --- /dev/null +++ b/src/usecase/project/get_project.py @@ -0,0 +1,30 @@ +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def get_project(self: 'Usecase', input: dto.GetProjectInput) -> dto.ProjectOutput: + project = await self.database.get_project(input.workspace_id, project_id=input.project_id) + + if not project: + raise domain.ProjectNotFound() + + await project.fetch_related('channel') + + return dto.ProjectOutput( + id=project.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + status=project.status, + purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=project.channel.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + ), + ) diff --git a/src/usecase/project/get_workspace_projects.py b/src/usecase/project/get_workspace_projects.py new file mode 100644 index 0000000..f6c2c42 --- /dev/null +++ b/src/usecase/project/get_workspace_projects.py @@ -0,0 +1,36 @@ +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def get_workspace_projects(self: 'Usecase', input: dto.GetWorkspaceProjectsInput) -> list[dto.ProjectOutput]: + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ + ) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PROJECTS_READ) + + projects = await self.database.get_workspace_projects( + input.workspace_id, allowed_project_ids=allowed_project_ids, include_archived=input.include_archived + ) + + return [ + dto.ProjectOutput( + id=project.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + status=project.status, + purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=project.channel.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + ), + ) + for project in projects + ] diff --git a/src/usecase/project/move_project_to_workspace.py b/src/usecase/project/move_project_to_workspace.py new file mode 100644 index 0000000..f90d2cf --- /dev/null +++ b/src/usecase/project/move_project_to_workspace.py @@ -0,0 +1,76 @@ +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def move_project_to_workspace( + self: 'Usecase', + user_id: uuid.UUID, + source_workspace_id: uuid.UUID, + project_id: uuid.UUID, + target_workspace_id: uuid.UUID, +) -> dto.ProjectOutput: + # Получаем project из source workspace + project = await self.database.get_project(source_workspace_id, project_id=project_id) + if not project: + raise domain.ProjectNotFound(project_id) + + await project.fetch_related('channel') + + # Проверяем что target workspace существует + target_workspace = await self.database.get_workspace(target_workspace_id) + if not target_workspace: + raise domain.WorkspaceNotFound(target_workspace_id) + + # Проверяем права владельца в source workspace + await self.ensure_workspace_permission(source_workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + # Проверяем права владельца в target workspace + await self.ensure_workspace_permission(target_workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + # Проверяем что канал не существует в target workspace (UNIQUE constraint) + if await self.database.check_channel_exists_in_workspace(project.channel.id, target_workspace_id): + raise domain.ProjectChannelConflict() + + # Выполняем перенос в транзакции + async with self.database.transaction(): + # Удаляем project-scoped permissions (они теряют смысл в новом workspace) + await domain.WorkspaceUserPermissionScope.filter(project_id=project_id).delete() + + # Обновляем workspace_id + project.workspace_id = target_workspace_id + await self.database.update_project(project) + + # Логирование операции + log.info( + 'Project moved to another workspace', + extra={ + 'project_id': str(project_id), + 'channel_id': str(project.channel.id), + 'source_workspace_id': str(source_workspace_id), + 'target_workspace_id': str(target_workspace_id), + 'user_id': str(user_id), + }, + ) + + return dto.ProjectOutput( + id=project.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + status=project.status, + purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=project.channel.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + ), + ) diff --git a/src/usecase/project/tg_add_project.py b/src/usecase/project/tg_add_project.py new file mode 100644 index 0000000..4fab33a --- /dev/null +++ b/src/usecase/project/tg_add_project.py @@ -0,0 +1,251 @@ +import logging +from typing import TYPE_CHECKING + +from aiogram.types import InlineKeyboardButton + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto.ProjectOutput | None: + permissions = input.bot_permissions + + if not permissions.is_admin: + log.warning(f'Bot is not admin in channel {input.telegram_id}. Attempted by user {input.user_telegram_id}') + await self.telegram_bot.send_message( + f'⚠️ Бот был добавлен в канал "{input.title}", но не является админом.\n\n' + 'Пожалуйста, сделайте бота администратором канала.', + input.user_telegram_id, + ) + raise domain.ChannelNoAdminRights() + + missing_permissions = [] + if not permissions.can_invite_users: + missing_permissions.append('Создание инвайт-ссылок') + # if not permissions.can_restrict_members: + # missing_permissions.append('Управление пользователями (видеть вступления)') + + if missing_permissions: + log.warning( + f'Bot lacks required permissions in channel {input.telegram_id}: {missing_permissions}. ' + f'Attempted by user {input.user_telegram_id}' + ) + permissions_text = '\n'.join(f'• {p}' for p in missing_permissions) + await self.telegram_bot.send_message( + f'⚠️ Бот был добавлен в канал "{input.title}", но не имеет необходимых прав.\n\n' + f'Отсутствующие права:\n{permissions_text}\n\n' + 'Пожалуйста, предоставьте эти права боту в настройках канала.', + input.user_telegram_id, + ) + raise domain.ChannelNoAdminRights() + + user = await self.database.get_user(telegram_id=input.user_telegram_id) + if not user: + log.warning(f'User {input.user_telegram_id} not found when trying to connect channel {input.telegram_id}') + await self.telegram_bot.send_message( + f'⚠️ Канал "{input.title}" не может быть подключен.\n\n' + 'Вы должны сначала авторизоваться в веб-панели перед подключением каналов.', + input.user_telegram_id, + ) + raise domain.UserNotFound() + if user.telegram_user is None: + log.warning('User %s missing telegram profile when connecting channel', user.id) + await self.telegram_bot.send_message( + '⚠️ Произошла ошибка при обработке вашего профиля. Пожалуйста, повторите авторизацию.', + input.user_telegram_id, + ) + raise domain.UserNotFound() + + if user.telegram_user is None: + raise domain.UserNotFound(user.id) + + telegram_user = user.telegram_user + + memberships = await self.database.get_user_workspaces(user.id) + workspaces: list[domain.Workspace] = [] + for membership in memberships: + workspace: domain.Workspace | None = membership.workspace + if not workspace: + workspace = await self.database.get_workspace(membership.workspace_id) + + if workspace: + workspaces.append(workspace) + + if not workspaces: + workspace = await self.get_or_create_personal_workspace(user) + workspaces = [workspace] + + invite_link = "" + parser_response = None + is_private = input.username is None + if is_private: + try: + invite_link = await self.telegram_bot.create_chat_invite_link(input.telegram_id) + parser_response = await self.parser.resolve_telegram_channel_by_invite(invite_link) + except Exception as exc: + log.warning('Failed to resolve private channel %s: %s', input.telegram_id, exc) + await self.telegram_bot.send_message( + f'⚠️ Канал "{input.title}" не может быть подключен.\n\n' + 'Не удалось создать или проверить инвайт-ссылку.', + input.user_telegram_id, + ) + return None + + def build_channel_title() -> str: + if parser_response and parser_response.title: + return parser_response.title + return input.title + + def build_channel_username() -> str | None: + if parser_response and parser_response.username: + return parser_response.username + return input.username + + def build_channel_telegram_id() -> int: + if parser_response and parser_response.telegram_id: + return parser_response.telegram_id + return input.telegram_id + + def update_channel_meta(channel: domain.Channel) -> bool: + updated = False + title = build_channel_title() + username = build_channel_username() + telegram_id = build_channel_telegram_id() + if channel.title != title: + channel.title = title + updated = True + if username is not None and channel.username != username: + channel.username = username + updated = True + if channel.telegram_id != telegram_id: + channel.telegram_id = telegram_id + updated = True + if ( + parser_response + and parser_response.access_hash is not None + and channel.access_hash != parser_response.access_hash + ): + channel.access_hash = parser_response.access_hash + updated = True + if parser_response and parser_response.pts is not None and channel.pts != parser_response.pts: + channel.pts = parser_response.pts + updated = True + if is_private and channel.pts != 0: + channel.pts = 0 + updated = True + if invite_link and channel.invite_link != invite_link: + channel.invite_link = invite_link + updated = True + return updated + + if len(workspaces) == 1: + workspace = workspaces[0] + + channel = await self.database.get_channel(telegram_id=input.telegram_id) + if channel: + if update_channel_meta(channel): + await self.database.update_channel(channel) + else: + username = build_channel_username() + if username is None and invite_link == "": + log.warning('Cannot create channel %s without username or invite link', input.telegram_id) + await self.telegram_bot.send_message( + f'⚠️ Канал "{input.title}" не может быть подключен.\n\n' + 'У канала отсутствует публичный username и не удалось получить invite link.', + input.user_telegram_id, + ) + return None + + channel = domain.Channel( + telegram_id=build_channel_telegram_id(), + title=build_channel_title(), + username=username, + access_hash=(parser_response.access_hash if parser_response else None), + pts=(0 if is_private else (parser_response.pts if parser_response else None)), + invite_link=invite_link or None, + ) + await self.database.create_channel(channel) + + project = await self.database.get_project(workspace.id, channel_id=channel.id, include_deleted=True) + if project: + project.status = domain.ProjectStatus.ACTIVE + project.deleted_at = None + await self.database.update_project(project) + else: + project = domain.Project( + workspace_id=workspace.id, + channel_id=channel.id, + status=domain.ProjectStatus.ACTIVE, + ) + await self.database.create_project(project) + + log.info( + 'Project for channel %s connected/updated successfully in workspace %s by user %s', + input.telegram_id, + workspace.id, + input.user_telegram_id, + ) + + await self.telegram_bot.send_message( + f'✅ Канал "{input.title}" добавлен в рабочее пространство "{workspace.name}".', input.user_telegram_id + ) + + return dto.ProjectOutput( + id=project.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + status=project.status, + purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + ), + ) + + # Если >1 workspace - создаем/обновляем канал и отправляем уведомление + channel = await self.database.get_channel(telegram_id=input.telegram_id) + if channel: + if update_channel_meta(channel): + await self.database.update_channel(channel) + else: + username = build_channel_username() + if username is None and invite_link == "": + log.warning('Cannot create channel %s without username or invite link', input.telegram_id) + await self.telegram_bot.send_message( + f'⚠️ Канал "{input.title}" не может быть подключен.\n\n' + 'У канала отсутствует публичный username и не удалось получить invite link.', + input.user_telegram_id, + ) + return None + + channel = domain.Channel( + telegram_id=build_channel_telegram_id(), + title=build_channel_title(), + username=username, + access_hash=(parser_response.access_hash if parser_response else None), + pts=(0 if is_private else (parser_response.pts if parser_response else None)), + invite_link=invite_link or None, + ) + await self.database.create_channel(channel) + + # Callback будет обработан Golang ботом который покажет экран выбора workspace + buttons = [[InlineKeyboardButton(text='Выбрать workspace', callback_data=f'pending_channel:{channel.id}')]] + + await self.telegram_bot.send_message_with_inline_keyboard( + f'Бот добавлен в канал "{input.title}".\n\n' + f'У вас {len(workspaces)} рабочих пространств. ' + 'Нажмите кнопку ниже чтобы выбрать, где создать проект.', + telegram_user.telegram_id, + buttons, + ) + + log.info('Pending channel notification sent to user %s for channel %s', telegram_user.telegram_id, channel.id) + + return None diff --git a/src/usecase/project/update_project_invite_link_type.py b/src/usecase/project/update_project_invite_link_type.py new file mode 100644 index 0000000..01dd1ab --- /dev/null +++ b/src/usecase/project/update_project_invite_link_type.py @@ -0,0 +1,48 @@ +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def update_project_invite_link_type( + self: 'Usecase', + workspace_id: uuid.UUID, + project_id: uuid.UUID, + purchase_invite_type_default: domain.InviteLinkType, + user_id: uuid.UUID, +) -> dto.ProjectOutput: + await self.ensure_workspace_permission( + workspace_id, + user_id, + domain.PermissionKey.PROJECTS_WRITE, + for_project_id=project_id, + ) + + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: + raise domain.ProjectNotFound(project_id) + + project.purchase_invite_type_default = purchase_invite_type_default + + await self.database.update_project(project) + + return dto.ProjectOutput( + id=project.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + status=project.status, + purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=project.channel.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + ), + ) diff --git a/src/usecase/project/update_project_permissions.py b/src/usecase/project/update_project_permissions.py new file mode 100644 index 0000000..3841387 --- /dev/null +++ b/src/usecase/project/update_project_permissions.py @@ -0,0 +1,61 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def update_project_permissions(self: 'Usecase', input: dto.UpdateProjectPermissionsInput) -> None: + missing_permissions = [] + if not input.permissions.can_invite_users: + missing_permissions.append('Создание инвайт-ссылок') + if not input.permissions.can_restrict_members: + missing_permissions.append('Управление пользователями') + + # Получаем user_id по telegram_id + user = await self.database.get_user(telegram_id=input.user_telegram_id) + if not user: + log.warning(f'User with telegram_id {input.user_telegram_id} not found when updating channel permissions') + return + if user.telegram_user is None: + log.warning('User %s missing telegram profile when updating permissions', user.id) + return + + project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id) + if not project: + log.warning(f'Project channel {input.telegram_id} not found when permissions changed') + raise domain.ProjectNotFound() + + if not missing_permissions: + if project.status != domain.ProjectStatus.ACTIVE: + project.status = domain.ProjectStatus.ACTIVE + await self.database.update_project(project) + + await self.telegram_bot.send_message( + f'✅ Канал "{input.chat_title}" был активирован.\n\nВсе необходимые права боту предоставлены!', + user.telegram_user.telegram_id, + ) + log.info(f'Project channel {input.telegram_id} reactivated - all permissions granted') + return + + if project.status == domain.ProjectStatus.ACTIVE: + project.status = domain.ProjectStatus.INACTIVE + await self.database.update_project(project) + + missed_permissions = '\n'.join(f'• {p}' for p in missing_permissions) + await self.telegram_bot.send_message( + ( + f'⚠️ Канал "{input.chat_title}" был деактивирован.\n\n' + f'Боту убрали необходимые права:\n{missed_permissions}' + ), + user.telegram_user.telegram_id, + ) + log.warning( + 'Project channel %s deactivated due to missing permissions: %s', + input.telegram_id, + missing_permissions, + ) diff --git a/src/usecase/purchase/build_placement_creative.py b/src/usecase/purchase/build_placement_creative.py new file mode 100644 index 0000000..9c05ee4 --- /dev/null +++ b/src/usecase/purchase/build_placement_creative.py @@ -0,0 +1,244 @@ +import logging +import re +import uuid +from typing import TYPE_CHECKING + +from aiogram.types import InlineKeyboardButton +from tortoise import timezone + +from src import domain, dto + +from .create_placements import generate_invite_link_name, uuid_to_short_id + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + +_INVITE_LINK_TAG = re.compile(r'(.*?)', re.IGNORECASE | re.DOTALL) +_INVITE_LINK_PLACEHOLDER = '{{invite_link}}' + + +def _inject_invite_link(text: str, invite_link: str) -> str: + if not text: + return text + + def _replace(match: re.Match[str]) -> str: + inner = match.group(1).strip() + if inner: + return f'{inner}' + return f'{invite_link}' + + return _INVITE_LINK_TAG.sub(_replace, text) + + +def _build_buttons(buttons: list[dict[str, str]], invite_link: str) -> list[dto.CreativeButton]: + result: list[dto.CreativeButton] = [] + for raw in buttons: + text = raw.get('text') + url = raw.get('url') + if not text or not url: + continue + if url == _INVITE_LINK_PLACEHOLDER: + url = invite_link + result.append(dto.CreativeButton(text=text, url=url)) + return result + + +def _format_channel_name(channel: domain.Channel) -> str: + if channel.title: + return channel.title + if channel.username: + return f'@{channel.username}' + return 'Без названия' + + +def _format_channel_link(channel: domain.Channel) -> str: + """Format channel as HTML link if possible, otherwise return plain text.""" + name = _format_channel_name(channel) + + # Try invite_link first + if channel.invite_link: + return f'{name}' + + # Try username + if channel.username: + return f'{name}' + + # No link available, return plain name + return name + + +def _build_info_message( + placement: domain.Placement, + project: domain.Project, +) -> str: + """Build informational message about placement.""" + lines = [] + + # Header + lines.append('Ссылка зашита с помощью @smartpost_tg_bot') + lines.append('') + + # Placement channel (always present) + placement_channel_link = _format_channel_link(placement.channel) + lines.append(f'Размещение в канале: {placement_channel_link}') + + # Project channel (always present) + project_channel_link = _format_channel_link(project.channel) + lines.append(f'Рекламируемый проект: {project_channel_link}') + + # Invite link type (always present) + link_type_text = 'Открытая' if placement.invite_link_type == domain.InviteLinkType.PUBLIC else 'С заявками' + lines.append(f'Тип ссылки: {link_type_text}') + + # Cost / CPM (optional) + if placement.cost_value is not None and placement.cost_value > 0: + if placement.cost_type == domain.CostType.CPM: + lines.append(f'Ставка CPM: {placement.cost_value:.0f} ₽') + else: + lines.append(f'Стоимость: {placement.cost_value:.0f} ₽') + + # Date and time (optional) + if placement.placement_at: + # Check if time is 00:00 (midnight) - then show only date + if placement.placement_at.hour == 0 and placement.placement_at.minute == 0: + date_str = placement.placement_at.strftime('%d.%m.%Y') + lines.append(f'Дата: {date_str}') + else: + datetime_str = placement.placement_at.strftime('%d.%m.%Y %H:%M') + lines.append(f'Дата и время: {datetime_str}') + + # Format (optional) — prefer display string from numeric fields + display_format = domain.format_display_string(placement.top_time_minutes, placement.feed_time_minutes) + if display_format: + lines.append(f'Формат: {display_format}') + elif placement.format: + lines.append(f'Формат: {placement.format}') + + # Add empty line before footer + lines.append('') + lines.append('Пост ниже 👇') + + return '\n'.join(lines) + + +def _build_keyboard_buttons(buttons: list[dto.CreativeButton]) -> list[list[InlineKeyboardButton]]: + return [[InlineKeyboardButton(text=btn.text, url=btn.url)] for btn in buttons] + + +async def build_placement_creative( + self: 'Usecase', + placement_id: uuid.UUID, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + user_id: uuid.UUID, +) -> dto.CreativePreviewOutput: + context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE) + + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: + raise domain.ProjectNotFound(project_id) + + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) + + placement = await self.database.get_placement(workspace_id, placement_id) + if not placement or placement.project_id != project.id: + raise domain.PlacementNotFound(placement_id) + + if placement.creative_id is None: + raise domain.CreativeNotFound() + + creative = await self.database.get_creative(workspace_id, placement.creative_id) + if not creative or creative.project_id != project.id: + raise domain.CreativeNotFound(placement.creative_id) + + if not placement.invite_link: + if project.channel.telegram_id is None: + raise domain.ChannelNotFound(project.channel.id) + + # Generate invite_link_name if not set + if not placement.invite_link_name: + short_id = uuid_to_short_id(placement.id) + placement.invite_link_name = generate_invite_link_name(short_id, placement.channel.title) + + requires_approval = placement.invite_link_type == domain.InviteLinkType.APPROVAL + invite_link = await self.telegram_bot.create_chat_invite_link( + project.channel.telegram_id, requires_approval, name=placement.invite_link_name + ) + placement.invite_link = invite_link + placement.invite_link_created_at = timezone.now() + await self.database.update_placement(placement) + + invite_link = placement.invite_link + if not invite_link: + raise domain.PlacementNotFound(placement.id) + + media_rel = creative.media_items + if hasattr(media_rel, 'all'): + media_items = await media_rel.all().order_by('position') + else: + media_items = sorted(media_rel, key=lambda item: item.position) + preview = dto.CreativePreviewOutput( + id=creative.id, + name=creative.name, + text=_inject_invite_link(creative.text, invite_link), + media_items=[ + dto.CreativeMediaItem( + media_type=item.media_type, + media_file_id=item.media_file_id, + position=item.position, + s3_url=self.s3.public_url(item.media_s3_key) if item.media_s3_key else None, + ) + for item in media_items + ], + buttons=_build_buttons(creative.buttons, invite_link), + ) + + user = await self.database.get_user(user_id=user_id) + if not user or not user.telegram_user: + raise domain.UserNotFound(user_id) + + chat_id = user.telegram_user.telegram_id + + # Build and send informational message + info_message = _build_info_message(placement, project) + info_message_id = await self.telegram_bot.send_message(info_message, chat_id, parse_mode='HTML') + + # Send creative as reply to informational message + keyboard_buttons = _build_keyboard_buttons(preview.buttons) + if preview.media_items: + if len(preview.media_items) == 1: + media_item = preview.media_items[0] + await self.telegram_bot.send_media_with_inline_keyboard( + text=preview.text, + chat_id=chat_id, + media_type=media_item.media_type, + media_file_id=media_item.media_file_id, + buttons=keyboard_buttons, + parse_mode='HTML', + reply_to_message_id=info_message_id, + ) + else: + await self.telegram_bot.send_media_group( + chat_id=chat_id, + media_items=preview.media_items, + caption=preview.text, + parse_mode='HTML', + reply_to_message_id=info_message_id, + ) + elif keyboard_buttons: + await self.telegram_bot.send_message_with_inline_keyboard( + preview.text, + chat_id, + keyboard_buttons, + parse_mode='HTML', + disable_preview=True, + reply_to_message_id=info_message_id, + ) + else: + await self.telegram_bot.send_message( + preview.text, chat_id, parse_mode='HTML', disable_preview=True, reply_to_message_id=info_message_id + ) + + return preview diff --git a/src/usecase/purchase/create_placements.py b/src/usecase/purchase/create_placements.py new file mode 100644 index 0000000..9dc3cdd --- /dev/null +++ b/src/usecase/purchase/create_placements.py @@ -0,0 +1,293 @@ +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + +# Base62 alphabet for encoding +BASE62_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + + +def uuid_to_short_id(placement_uuid: uuid.UUID) -> str: + """ + Конвертирует UUID в короткий ID (8 символов base62). + + Берёт первые 8 байт UUID (64 бита) и кодирует в base62. + """ + # Берём первые 8 байт UUID + uuid_bytes = placement_uuid.bytes[:8] + + # Конвертируем байты в integer + num = int.from_bytes(uuid_bytes, byteorder='big') + + # Кодируем в base62 + if num == 0: + return BASE62_ALPHABET[0] + + result = [] + base = len(BASE62_ALPHABET) + while num > 0: + num, remainder = divmod(num, base) + result.append(BASE62_ALPHABET[remainder]) + + # Разворачиваем и ограничиваем до 8 символов + short_id = ''.join(reversed(result))[:8] + return short_id.lower() + + +def generate_invite_link_name(short_id: str, project_channel_title: str | None) -> str: + """ + Генерирует название для invite link с умным сокращением. + + Формат: "{short_id} {channel_name}" + Приоритет: short_id всегда полный, channel_name сокращается. + + Args: + short_id: Короткий ID размещения + project_channel_title: Название канала проекта + + Returns: + Строка до 32 символов + """ + channel_name = project_channel_title or 'Unknown' + + # Базовый формат: "{short_id} {name}" + base_name = f'{short_id} {channel_name}' + + # Если помещается, возвращаем как есть + if len(base_name) <= 32: + return base_name + + # Считаем доступное место для канала + # Формат: "{short_id} " занимает len(short_id) + 1 символов + prefix_len = len(short_id) + 1 # например: "a3b9x2m " = 8 символов + max_channel_len = 32 - prefix_len + + # Если места слишком мало, возвращаем только short_id + if max_channel_len < 3: + return short_id + + # Сокращаем название канала (с многоточием если нужно) + truncated_channel = channel_name[: max_channel_len - 3] + if len(truncated_channel) < len(channel_name): + truncated_channel = truncated_channel + '...' + + return f'{short_id} {truncated_channel}' + + +def _build_cost_info(cost_type: domain.CostType | None, cost_value: float | None) -> dto.CostInfo | None: + if cost_type is None or cost_value is None: + return None + return dto.CostInfo(type=cost_type, value=cost_value) + + +def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetails | None: + details = dto.PlacementDetails( + placement_at=placement.placement_at, + payment_at=placement.payment_at, + cost=_build_cost_info(placement.cost_type, placement.cost_value), + cost_before_bargain=_build_cost_info(placement.cost_before_bargain_type, placement.cost_before_bargain), + placement_type=placement.placement_type, + format=placement.format, + top_time_minutes=placement.top_time_minutes, + feed_time_minutes=placement.feed_time_minutes, + comment=placement.comment, + ) + if details.model_dump(exclude_none=True): + return details + return None + + +def _sync_format_fields( + format_str: str | None, + top_time_minutes: int | None, + feed_time_minutes: int | None, +) -> tuple[str | None, int | None, int | None]: + """Синхронизирует format string и числовые поля. + + Если есть числовые поля — вычисляем display string. + Если только format string — парсим числовые поля. + """ + if top_time_minutes is not None or feed_time_minutes is not None: + display = domain.format_display_string(top_time_minutes, feed_time_minutes) + if display: + format_str = display + elif format_str: + parsed_top, parsed_feed = domain.parse_format_string(format_str) + if parsed_top is not None: + top_time_minutes = parsed_top + if parsed_feed is not None: + feed_time_minutes = parsed_feed + + return format_str, top_time_minutes, feed_time_minutes + + +def _build_placement_output( + placement: domain.Placement, + project: domain.Project | None = None, + creative_name: str | None = None, +) -> dto.PlacementOutput: + channel = placement.channel + if channel is None: + log.error('Placement %s has no channel prefetched', placement.id) + raise ValueError(f'Placement {placement.id} has no channel') + + # Generate short_id from UUID + short_id = uuid_to_short_id(placement.id) + + # Build project output if project is provided + project_output: dto.ProjectOutput | None = None + if project is not None: + project_channel = project.channel + if project_channel is None: + log.warning('Project %s has no channel prefetched, skipping project output', project.id) + else: + project_output = dto.ProjectOutput( + id=project.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + status=project.status, + purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=project.channel.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + ), + ) + log.debug('Built project output for project %s', project.id) + + return dto.PlacementOutput( + id=placement.id, + status=placement.status, + creative_id=placement.creative_id, + creative_name=creative_name, + comment=placement.comment, + invite_link=placement.invite_link, + invite_link_created_at=placement.invite_link_created_at, + invite_link_type=placement.invite_link_type, + channel=dto.ChannelOutput( + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + ), + project=project_output, + short_id=short_id, + details=_build_placement_details(placement), + created_at=placement.created_at, + ) + + +async def create_placements( + self: 'Usecase', + project_id: uuid.UUID, + workspace_id: uuid.UUID, + user_id: uuid.UUID, + input: dto.CreatePlacementsInput, +) -> dto.GetPlacementsOutput: + """Create multiple placements for different channels (bulk creation, бывший create_purchase)""" + context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE) + + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: + raise domain.ProjectNotFound(project_id) + + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) + + if project.channel.telegram_id is None: + raise domain.ChannelNotFound(project.channel.id) + + placements: list[domain.Placement] = [] + creatives_by_id: dict[uuid.UUID, domain.Creative] = {} + + for channel_input in input.channels: + channel = await self.database.get_channel(channel_id=channel_input.channel_id) + if not channel: + raise domain.ChannelNotFound(channel_input.channel_id) + + channel_details = channel_input.details + creative_id = ( + channel_details.creative_id if channel_details and channel_details.creative_id else None + ) or input.creative_id + + # Если creative_id передан, ищем и валидируем креатив + creative: domain.Creative | None = None + if creative_id: + creative = creatives_by_id.get(creative_id) + if not creative: + creative = await self.database.get_creative(workspace_id, creative_id) + if not creative or creative.project_id != project.id: + raise domain.CreativeNotFound(creative_id) + creatives_by_id[creative_id] = creative + + raw_format = channel_details.format if channel_details else None + raw_top = channel_details.top_time_minutes if channel_details else None + raw_feed = channel_details.feed_time_minutes if channel_details else None + synced_format, synced_top, synced_feed = _sync_format_fields(raw_format, raw_top, raw_feed) + + placement = domain.Placement( + project_id=project.id, + creative_id=creative.id if creative else None, + channel_id=channel.id, + invite_link=None, + invite_link_type=( + channel_details.invite_link_type if channel_details and channel_details.invite_link_type else None + ) + or project.purchase_invite_type_default, + status=channel_input.status or domain.PlacementStatus.NO_STATUS, + comment=channel_input.comment or (channel_details.comment if channel_details else None), + placement_at=channel_details.placement_at if channel_details else None, + payment_at=channel_details.payment_at if channel_details else None, + cost_type=channel_details.cost.type if channel_details and channel_details.cost else None, + cost_value=channel_details.cost.value if channel_details and channel_details.cost else None, + cost_before_bargain_type=( + channel_details.cost_before_bargain.type + if channel_details and channel_details.cost_before_bargain + else None + ), + cost_before_bargain=( + channel_details.cost_before_bargain.value + if channel_details and channel_details.cost_before_bargain + else None + ), + placement_type=channel_details.placement_type if channel_details else None, + format=synced_format, + top_time_minutes=synced_top, + feed_time_minutes=synced_feed, + ) + + await self.database.create_placement(placement) + placement.channel = channel + + # Generate invite_link_name after placement has UUID + short_id = uuid_to_short_id(placement.id) + placement.invite_link_name = generate_invite_link_name(short_id, channel.title) + await self.database.update_placement(placement) + + placements.append(placement) + + log.info( + 'Created %s placements for project %s (creatives %s)', + len(placements), + project.id, + list(creatives_by_id.keys()), + ) + + placement_outputs = [] + for placement in placements: + placement_output = _build_placement_output(placement, project=project) + placement_outputs.append( + dto.PlacementWithPostsOutput( + **placement_output.model_dump(), + placement_post=None, + ) + ) + + return dto.GetPlacementsOutput(placements=placement_outputs) diff --git a/src/usecase/purchase/delete_placement.py b/src/usecase/purchase/delete_placement.py new file mode 100644 index 0000000..bdee320 --- /dev/null +++ b/src/usecase/purchase/delete_placement.py @@ -0,0 +1,28 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) -> None: + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_WRITE + ) + + project = await self.database.get_project(input.workspace_id, project_id=input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) + + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) + + placement = await self.database.get_placement(input.workspace_id, input.placement_id) + if not placement or placement.project_id != project.id: + log.warning('User %s attempted to delete unavailable placement %s', input.user_id, input.placement_id) + raise domain.PlacementNotFound(input.placement_id) + + await self.database.delete_placement(placement.id) diff --git a/src/usecase/purchase/get_placement.py b/src/usecase/purchase/get_placement.py new file mode 100644 index 0000000..460f3cc --- /dev/null +++ b/src/usecase/purchase/get_placement.py @@ -0,0 +1,135 @@ +import logging +from typing import TYPE_CHECKING + +from tortoise import timezone + +from src import domain, dto +from src.usecase.purchase.create_placements import _build_placement_output + +log = logging.getLogger(__name__) + + +def _build_post_output(post: domain.Post) -> dto.PostOutput: + channel = post.channel + if not channel: + raise ValueError(f'Post {post.id} has no channel') + + return dto.PostOutput( + id=post.id, + message_id=post.message_id, + text=post.text, + url=post.url, + deleted_from_channel_at=post.deleted_from_channel_at, + created_at=post.created_at, + updated_at=post.updated_at, + ) + + +def _build_placement_post_output( + placement_post: domain.PlacementPost, + subscriptions_count: int, + views_count: int | None, + time_on_top: int | None = None, +) -> dto.PlacementPostOutput | None: + if not placement_post.post: + log.warning('PlacementPost %s missing post', placement_post.id) + return None + + try: + post_output = _build_post_output(placement_post.post) + except ValueError: + log.warning('PlacementPost %s post missing channel data', placement_post.id) + return None + + return dto.PlacementPostOutput( + id=placement_post.id, + status=placement_post.status, + subscriptions_count=subscriptions_count, + views_count=views_count, + created_at=placement_post.created_at, + time_on_top=time_on_top, + post=post_output, + ) + + +if TYPE_CHECKING: + from .. import Usecase + + +async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementWithPostsOutput: + """Get single placement by ID (user-managed)""" + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ + ) + + project = await self.database.get_project(input.workspace_id, project_id=input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) + + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id) + + placement = await self.database.get_placement(input.workspace_id, input.placement_id) + if not placement or placement.project_id != project.id: + raise domain.PlacementNotFound(input.placement_id) + + # Prefetch channel for output building + if placement.channel is None: + channel = await self.database.get_channel(channel_id=placement.channel_id) + placement.channel = channel + + # Prefetch project channel for output building + if project.channel is None: + project_channel = await self.database.get_channel(channel_id=project.channel_id) + project.channel = project_channel + + # Get creative name if exists + creative_name = None + if placement.creative_id: + creative = await self.database.get_creative(input.workspace_id, placement.creative_id) + if creative: + creative_name = creative.name + + placement_posts = await self.database.get_workspace_placement_posts( + input.workspace_id, + placement_id=placement.id, + include_archived=True, + ) + # Подсчёт подписок по placement_id (один Placement = одна ссылка = один счётчик подписок) + subscriptions_count = await self.database.count_subscriptions_by_placement(placement.id) + post_ids = [placement_post.post.id for placement_post in placement_posts if placement_post.post] + views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} + + placement_post_output = None + if placement_posts: + if len(placement_posts) > 1: + log.warning('Placement %s has %s placement_posts, returning latest', placement.id, len(placement_posts)) + for placement_post in placement_posts: + views_count = None + time_on_top = None + if placement_post.post: + views_count = views_map.get(placement_post.post.id, (None,))[0] + # Calculate time_on_top + post = placement_post.post + next_post = await self.database.get_next_post_after(post.channel_id, post.message_id) + if next_post and next_post.published_at and post.published_at: + time_on_top = int((next_post.published_at - post.published_at).total_seconds()) + elif post.published_at: + time_on_top = int((timezone.now() - post.published_at).total_seconds()) + placement_post_output = _build_placement_post_output( + placement_post, + subscriptions_count, + views_count, + time_on_top, + ) + if placement_post_output is not None: + break + + placement_output = _build_placement_output( + placement, + project=project, + creative_name=creative_name, + ) + return dto.PlacementWithPostsOutput( + **placement_output.model_dump(), + placement_post=placement_post_output, + ) diff --git a/src/usecase/purchase/get_placements.py b/src/usecase/purchase/get_placements.py new file mode 100644 index 0000000..c3d36bd --- /dev/null +++ b/src/usecase/purchase/get_placements.py @@ -0,0 +1,113 @@ +import logging +import uuid +from typing import TYPE_CHECKING + +from tortoise import timezone + +from src import domain, dto + +from .create_placements import _build_placement_output +from .get_placement import _build_placement_post_output + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput: + """Get all placements for a project (formerly get_purchases)""" + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ + ) + + project = await self.database.get_project(input.workspace_id, project_id=input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) + + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id) + + # Check analytics permissions to determine if subscription data should be hidden + hide_subscriptions = context.should_hide_subscriptions() + + # Prefetch project channel for output building + if project.channel is None: + project_channel = await self.database.get_channel(channel_id=project.channel_id) + project.channel = project_channel + + placements = await self.database.get_project_placements(input.workspace_id, project.id) + log.debug('Fetched %s placements for project %s', len(placements), project.id) + + placement_ids = [placement.id for placement in placements] + placement_posts = await self.database.get_placement_posts_by_placement_ids( + input.workspace_id, + placement_ids, + include_archived=True, + ) + # Подсчёт подписок по placement_id (один Placement = одна ссылка = один счётчик подписок) + subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids) + post_ids = [placement_post.post.id for placement_post in placement_posts if placement_post.post] + views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} + + # Collect (channel_id, message_id) pairs for batch next post lookup + channel_message_pairs = [ + (placement_post.post.channel_id, placement_post.post.message_id) + for placement_post in placement_posts + if placement_post.post + ] + next_posts_map = await self.database.get_next_posts_after_batch(channel_message_pairs) + + # Calculate time_on_top for each placement_post + time_on_top_map: dict[uuid.UUID, int] = {} + now = timezone.now() + for placement_post in placement_posts: + post = placement_post.post + if not post or not post.published_at: + continue + published_at = post.published_at + key = (post.channel_id, post.message_id) + next_post = next_posts_map.get(key) + if next_post and next_post.published_at: + time_on_top_map[placement_post.id] = int((next_post.published_at - published_at).total_seconds()) + else: + time_on_top_map[placement_post.id] = int((now - published_at).total_seconds()) + + placement_posts_by_placement_id: dict[uuid.UUID, list[dto.PlacementPostOutput]] = {} + for placement_post in placement_posts: + views_count = None + time_on_top = time_on_top_map.get(placement_post.id) + if placement_post.post: + views_count = views_map.get(placement_post.post.id, (None,))[0] + # Hide subscriptions if user doesn't have analytics_read permission + subscriptions_count = 0 if hide_subscriptions else subscriptions_counts.get(placement_post.placement_id, 0) + placement_post_output = _build_placement_post_output( + placement_post, + subscriptions_count, + views_count, + time_on_top, + ) + if placement_post_output is None: + continue + placement_posts_by_placement_id.setdefault(placement_post.placement_id, []).append(placement_post_output) + + placement_outputs = [] + for placement in placements: + placement_output = _build_placement_output(placement, project=project) + placement_post_output = None + placement_posts_for_placement = placement_posts_by_placement_id.get(placement.id, []) + if placement_posts_for_placement: + if len(placement_posts_for_placement) > 1: + log.warning( + 'Placement %s has %s placement_posts, returning latest', + placement.id, + len(placement_posts_for_placement), + ) + placement_post_output = placement_posts_for_placement[0] + placement_outputs.append( + dto.PlacementWithPostsOutput( + **placement_output.model_dump(), + placement_post=placement_post_output, + ) + ) + + return dto.GetPlacementsOutput(placements=placement_outputs) diff --git a/src/usecase/purchase/update_placement.py b/src/usecase/purchase/update_placement.py new file mode 100644 index 0000000..b6079dc --- /dev/null +++ b/src/usecase/purchase/update_placement.py @@ -0,0 +1,103 @@ +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def update_placement( + self: 'Usecase', + placement_id: uuid.UUID, + input: dto.UpdatePlacementInput, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + user_id: uuid.UUID, +) -> dto.PlacementWithPostsOutput: + context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE) + + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: + raise domain.ProjectNotFound(project_id) + + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) + + placement = await self.database.get_placement(workspace_id, placement_id) + if not placement or placement.project_id != project.id: + raise domain.PlacementNotFound(placement_id) + + fields_set = input.model_fields_set + + if 'creative_id' in fields_set: + if input.creative_id is None: + placement_posts_count = await self.database.count_placement_posts_by_placement(placement.id) + if placement_posts_count > 0: + log.warning('Placement %s has placement_posts and cannot remove creative', placement.id) + raise domain.PlacementHasPosts(placement.id) + placement.creative_id = None + else: + creative = await self.database.get_creative(workspace_id, input.creative_id) + if not creative or creative.project_id != project.id: + raise domain.CreativeNotFound(input.creative_id) + placement.creative_id = creative.id + + if 'status' in fields_set and input.status is not None: + placement.status = input.status + if 'comment' in fields_set: + placement.comment = input.comment + if 'placement_at' in fields_set: + placement.placement_at = input.placement_at + if 'payment_at' in fields_set: + placement.payment_at = input.payment_at + if 'cost' in fields_set: + if input.cost is None: + placement.cost_type = None + placement.cost_value = None + else: + placement.cost_type = input.cost.type + placement.cost_value = input.cost.value + if 'cost_before_bargain' in fields_set: + if input.cost_before_bargain is None: + placement.cost_before_bargain_type = None + placement.cost_before_bargain = None + else: + placement.cost_before_bargain_type = input.cost_before_bargain.type + placement.cost_before_bargain = input.cost_before_bargain.value + if 'placement_type' in fields_set: + placement.placement_type = input.placement_type + + # Синхронизация format / top_time_minutes / feed_time_minutes + format_changed = 'format' in fields_set + top_changed = 'top_time_minutes' in fields_set + feed_changed = 'feed_time_minutes' in fields_set + + if format_changed: + placement.format = input.format + if top_changed: + placement.top_time_minutes = input.top_time_minutes + if feed_changed: + placement.feed_time_minutes = input.feed_time_minutes + + if format_changed or top_changed or feed_changed: + from .create_placements import _sync_format_fields + + synced_format, synced_top, synced_feed = _sync_format_fields( + placement.format, placement.top_time_minutes, placement.feed_time_minutes + ) + placement.format = synced_format + placement.top_time_minutes = synced_top + placement.feed_time_minutes = synced_feed + + await self.database.update_placement(placement) + + placement_input = dto.GetPlacementInput( + user_id=user_id, + workspace_id=workspace_id, + project_id=project_id, + placement_id=placement.id, + ) + return await self.get_placement_user(input=placement_input) diff --git a/src/usecase/purchase/update_placement_post.py b/src/usecase/purchase/update_placement_post.py new file mode 100644 index 0000000..3ca457d --- /dev/null +++ b/src/usecase/purchase/update_placement_post.py @@ -0,0 +1,72 @@ +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + +# Статусы Placement, при которых статус PlacementPost не может быть изменён +LOCKED_PLACEMENT_STATUSES = { + domain.PlacementStatus.NO_STATUS, + domain.PlacementStatus.WRITE, + domain.PlacementStatus.CANCELED, + domain.PlacementStatus.PRICE_NOT_OK, + domain.PlacementStatus.NOT_RELEVANT, + domain.PlacementStatus.NO_RESPONSE, +} + + +async def update_placement_post( + self: 'Usecase', + placement_id: uuid.UUID, + placement_post_id: uuid.UUID, + input: dto.UpdatePlacementPostInput, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + user_id: uuid.UUID, +) -> dto.PlacementWithPostsOutput: + context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE) + + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: + raise domain.ProjectNotFound(project_id) + + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) + + placement = await self.database.get_placement(workspace_id, placement_id) + if not placement or placement.project_id != project.id: + raise domain.PlacementNotFound(placement_id) + + placement_post = await self.database.get_placement_post(workspace_id, placement_post_id) + if not placement_post or placement_post.placement_id != placement.id: + raise domain.PlacementPostNotFound(placement_post_id) + + fields_set = input.model_fields_set + + if 'status' in fields_set and input.status is not None: + # Проверяем, можно ли менять статус поста при текущем статусе Placement + if placement.status in LOCKED_PLACEMENT_STATUSES: + log.warning( + 'Cannot change PlacementPost status when Placement status is %s', + placement.status, + ) + # Статус поста должен оставаться "Без статуса" + if input.status != domain.PlacementPostStatus.NO_STATUS: + raise ValueError( + f'Статус поста недоступен для изменения при статусе взаимодействия "{placement.status}"' + ) + placement_post.status = input.status + + await placement_post.save() + + placement_input = dto.GetPlacementInput( + user_id=user_id, + workspace_id=workspace_id, + project_id=project_id, + placement_id=placement.id, + ) + return await self.get_placement_user(input=placement_input) diff --git a/src/usecase/subscription/handle_subscription.py b/src/usecase/subscription/handle_subscription.py new file mode 100644 index 0000000..7b2430b --- /dev/null +++ b/src/usecase/subscription/handle_subscription.py @@ -0,0 +1,93 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def handle_subscription( + self: 'Usecase', + user_telegram_id: int, + username: str | None, + invite_link: str, + first_name: str | None = None, + last_name: str | None = None, +) -> None: + placement_post = await self.database.get_placement_post_by_invite_link(invite_link) + if not placement_post or not placement_post.placement: + log.warning('PlacementPost not found for invite_link: %s', invite_link) + return + + placement = placement_post.placement + + subscriber = await self.database.get_telegram_user(telegram_id=user_telegram_id) + if not subscriber: + subscriber = domain.TelegramUser( + telegram_id=user_telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + ) + await self.database.create_telegram_user(subscriber) + else: + subscriber.username = username or subscriber.username + subscriber.first_name = first_name or subscriber.first_name + subscriber.last_name = last_name or subscriber.last_name + await self.database.update_telegram_user(subscriber) + + active_subscription = await self.database.get_active_subscription_by_subscriber_and_project( + subscriber.id, placement.project_id + ) + + if active_subscription: + # Пользователь уже подписан на канал через другой placement + # Это не должно случиться (Telegram не даст подписаться дважды), + # но если случилось - логируем и игнорируем + log.warning( + 'User %s (telegram_id: %s) already has active subscription to channel %s via placement %s, ' + 'ignoring new subscription attempt via placement %s', + subscriber.id, + user_telegram_id, + placement.project_id, + active_subscription.placement_id, + placement.id, + ) + return + + # Проверяем, была ли раньше подписка через ЭТОТ placement (для реактивации) + # Note: Subscription now links to placement directly, not placement_post + existing_sub = await self.database.get_subscription_by_subscriber_and_placement( + subscriber.id, placement.id + ) + + if existing_sub and existing_sub.status == domain.SubscriptionStatus.UNSUBSCRIBED: + existing_sub.status = domain.SubscriptionStatus.ACTIVE + existing_sub.unsubscribed_at = None + await self.database.update_subscription(existing_sub) + + log.info( + 'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same placement %s', + subscriber.id, + user_telegram_id, + placement.id, + ) + return + + subscription = domain.Subscription( + placement_id=placement.id, + telegram_user_id=subscriber.id, + invite_link=invite_link, + ) + await self.database.create_subscription(subscription) + + log.info( + 'Subscription created: subscriber %s (telegram_id: %s) subscribed via placement %s (invite_link: %s)', + subscriber.id, + user_telegram_id, + placement.id, + invite_link, + ) diff --git a/src/usecase/subscription/handle_unsubscription.py b/src/usecase/subscription/handle_unsubscription.py new file mode 100644 index 0000000..0f25a46 --- /dev/null +++ b/src/usecase/subscription/handle_unsubscription.py @@ -0,0 +1,46 @@ +import logging +from typing import TYPE_CHECKING + +from tortoise import timezone + +from src import domain + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def handle_unsubscription(self: 'Usecase', user_telegram_id: int, channel_telegram_id: int) -> None: + subscriber = await self.database.get_telegram_user(telegram_id=user_telegram_id) + if not subscriber: + log.warning('Subscriber not found for telegram_id: %s', user_telegram_id) + return + + project = await self.database.get_project_by_channel_telegram(channel_telegram_id) + if not project: + log.warning('Project not found for channel telegram_id: %s', channel_telegram_id) + return + + subscriptions = await self.database.get_active_subscriptions_by_subscriber_and_project(subscriber.id, project.id) + + if not subscriptions: + log.info( + 'No active subscriptions found for subscriber %s (telegram_id: %s) in channel %s', + subscriber.id, + user_telegram_id, + channel_telegram_id, + ) + return + + for subscription in subscriptions: + subscription.status = domain.SubscriptionStatus.UNSUBSCRIBED + subscription.unsubscribed_at = timezone.now() + await self.database.update_subscription(subscription) + + log.info( + 'Subscription marked as unsubscribed: subscriber %s (telegram_id: %s) unsubscribed from placement %s', + subscriber.id, + user_telegram_id, + subscription.placement_id, + ) diff --git a/src/usecase/views/get_views_history.py b/src/usecase/views/get_views_history.py new file mode 100644 index 0000000..c78b31c --- /dev/null +++ b/src/usecase/views/get_views_history.py @@ -0,0 +1,59 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> list[dto.PostViewsHistoryOutput]: + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ + ) + + placement = await self.database.get_placement(input.workspace_id, input.placement_id) + if not placement: + log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) + raise domain.PlacementNotFound(input.placement_id) + + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id) + + # Получаем PlacementPost для этого Placement (для получения связанного Post) + placement_posts = await self.database.get_workspace_placement_posts( + input.workspace_id, + placement_id=placement.id, + include_archived=True, + ) + + if not placement_posts: + return [] + + # Берём первый PlacementPost с постом + placement_post = None + for pp in placement_posts: + if pp.post: + placement_post = pp + break + + if not placement_post or not placement_post.post: + return [] + + histories = await self.database.get_views_history( + placement_post.post.id, + from_date=input.from_date, + to_date=input.to_date, + ) + + return [ + dto.PostViewsHistoryOutput( + id=history.id, + post_id=history.post_id, + views_count=history.views_count, + fetched_at=history.fetched_at, + created_at=history.created_at, + ) + for history in histories + ] diff --git a/src/usecase/workspace/accept_workspace_invite.py b/src/usecase/workspace/accept_workspace_invite.py new file mode 100644 index 0000000..8f29475 --- /dev/null +++ b/src/usecase/workspace/accept_workspace_invite.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def accept_workspace_invite( + self: Usecase, + invite_id: uuid.UUID, + user_id: uuid.UUID, +) -> dto.WorkspaceInviteOutput: + invite = await self.database.get_workspace_invite(invite_id) + if not invite or invite.user_id != user_id: + raise domain.WorkspaceInviteNotFound() + + if invite.status != domain.WorkspaceInviteStatus.PENDING: + raise domain.WorkspaceInviteAlreadyProcessed() + + async with self.database.transaction(): + invite.status = domain.WorkspaceInviteStatus.ACCEPTED + await self.database.update_workspace_invite(invite) + + membership = await self.database.get_workspace_membership(invite.workspace_id, user_id) + if membership: + if membership.status != domain.WorkspaceUserStatus.ACTIVE: + membership.status = domain.WorkspaceUserStatus.ACTIVE + await self.database.update_workspace_user(membership) + else: + await self.database.add_user_to_workspace(invite.workspace_id, user_id) + + return dto.WorkspaceInviteOutput.from_domain(invite) diff --git a/src/usecase/workspace/create_workspace.py b/src/usecase/workspace/create_workspace.py new file mode 100644 index 0000000..7974571 --- /dev/null +++ b/src/usecase/workspace/create_workspace.py @@ -0,0 +1,34 @@ +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def create_workspace( + self: 'Usecase', user_id: uuid.UUID, input: dto.CreateWorkspaceInput +) -> dto.CreateWorkspaceOutput: + user = await self.database.get_user(user_id=user_id) + if not user: + raise domain.UserNotFound(user_id) + + workspace = domain.Workspace( + name=input.name, + ) + + async with self.database.transaction(): + await self.database.create_workspace(workspace) + membership = await self.database.add_user_to_workspace(workspace.id, user_id) + await self.database.set_workspace_user_permissions( + membership.id, + global_permissions={domain.PermissionKey.ADMIN_FULL}, + scoped_permissions=[], + ) + + return dto.CreateWorkspaceOutput( + id=workspace.id, + name=workspace.name, + avatar_url=self.s3.public_url(workspace.avatar_s3_key) if workspace.avatar_s3_key else None, + ) diff --git a/src/usecase/workspace/create_workspace_invite.py b/src/usecase/workspace/create_workspace_invite.py new file mode 100644 index 0000000..8ef0855 --- /dev/null +++ b/src/usecase/workspace/create_workspace_invite.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from aiogram.types import InlineKeyboardButton + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +INVITE_ACCEPT_CALLBACK_PREFIX = 'workspace_invite_accept' + + +async def create_workspace_invite( + self: Usecase, + workspace_id: uuid.UUID, + user_id: uuid.UUID, + input: dto.CreateWorkspaceInviteInput, +) -> dto.WorkspaceInviteOutput: + context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + username = input.username + normalized_username = username.lower() + + invited_user = await self.database.get_user_by_username(normalized_username) + if not invited_user: + raise domain.UserByUsernameNotFound(username) + + existing_membership = await self.database.get_workspace_membership(workspace_id, invited_user.id) + if existing_membership and existing_membership.status != domain.WorkspaceUserStatus.BLOCKED: + raise domain.WorkspaceMemberAlreadyExists() + + existing_invite = await self.database.get_workspace_invite_by_user(workspace_id, invited_user.id) + if existing_invite: + if existing_invite.status == domain.WorkspaceInviteStatus.ACCEPTED: + raise domain.WorkspaceMemberAlreadyExists() + raise domain.WorkspaceInviteAlreadyExists() + + invite = domain.WorkspaceInvite( + workspace_id=workspace_id, + invited_by_id=user_id, + user_id=invited_user.id, + ) + + async with self.database.transaction(): + await self.database.create_workspace_invite(invite) + + invited_by_user = context.membership.user + if invited_by_user is None: + invited_by_user = await self.database.get_user(user_id=user_id) + if invited_by_user is None: + raise domain.UserNotFound(user_id) + + if invited_user.telegram_user is None: + raise domain.UserNotFound(invited_user.id) + if invited_by_user.telegram_user is None: + raise domain.UserNotFound(invited_by_user.id) + + invite.user = invited_user + invite.invited_by = invited_by_user + + invite_message = ( + f'✨ Вас пригласили в рабочее пространство «{context.workspace.name}».\n\n' + 'Нажмите кнопку ниже, чтобы принять приглашение.' + ) + + accept_button = InlineKeyboardButton( + text='Принять приглашение', + callback_data=f'{INVITE_ACCEPT_CALLBACK_PREFIX}:{invite.id}', + ) + await self.telegram_bot.send_message_with_inline_keyboard( + invite_message, + chat_id=invited_user.telegram_user.telegram_id, + buttons=[[accept_button]], + ) + + return dto.WorkspaceInviteOutput.from_domain(invite) diff --git a/src/usecase/workspace/delete_workspace.py b/src/usecase/workspace/delete_workspace.py new file mode 100644 index 0000000..7a65a51 --- /dev/null +++ b/src/usecase/workspace/delete_workspace.py @@ -0,0 +1,13 @@ +import uuid +from typing import TYPE_CHECKING + +from src import domain + +if TYPE_CHECKING: + from .. import Usecase + + +async def delete_workspace(self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID) -> None: + await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + await self.database.delete_workspace(workspace_id) diff --git a/src/usecase/workspace/delete_workspace_avatar.py b/src/usecase/workspace/delete_workspace_avatar.py new file mode 100644 index 0000000..c85a6e7 --- /dev/null +++ b/src/usecase/workspace/delete_workspace_avatar.py @@ -0,0 +1,42 @@ +import asyncio +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def delete_workspace_avatar( + self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID +) -> dto.WorkspaceMembershipOutput: + await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + membership = await self.database.get_workspace_membership(workspace_id, user_id) + if not membership or not membership.workspace: + raise domain.WorkspaceNotFound(workspace_id) + + workspace = membership.workspace + old_key = workspace.avatar_s3_key + if old_key: + workspace.avatar_s3_key = None + await self.database.update_workspace(workspace) + + async def delete_old_avatar() -> None: + try: + await self.s3.delete(old_key) + log.info('Deleted workspace avatar from S3: %s', old_key) + except Exception as exc: + log.warning('Failed to delete workspace avatar from S3: %s', exc) + + asyncio.create_task(delete_old_avatar()) + + return dto.WorkspaceMembershipOutput( + id=workspace.id, + name=workspace.name, + avatar_url=None, + ) diff --git a/src/usecase/workspace/get_workspace_invites.py b/src/usecase/workspace/get_workspace_invites.py new file mode 100644 index 0000000..572424e --- /dev/null +++ b/src/usecase/workspace/get_workspace_invites.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def get_workspace_invites( + self: Usecase, workspace_id: uuid.UUID, user_id: uuid.UUID +) -> list[dto.WorkspaceInviteOutput]: + await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + invites = await self.database.get_workspace_invites(workspace_id) + + return [dto.WorkspaceInviteOutput.from_domain(invite) for invite in invites] diff --git a/src/usecase/workspace/get_workspace_members.py b/src/usecase/workspace/get_workspace_members.py new file mode 100644 index 0000000..6b62846 --- /dev/null +++ b/src/usecase/workspace/get_workspace_members.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def get_current_member_permissions( + self: Usecase, workspace_id: uuid.UUID, user_id: uuid.UUID +) -> dto.WorkspaceMemberOutput: + """Get current user's membership and permissions in the workspace. + + This endpoint does NOT require ADMIN_FULL - any workspace member can access their own permissions. + """ + membership = await self.database.get_workspace_membership(workspace_id, user_id) + + if not membership: + raise domain.WorkspaceNotFound(workspace_id) + + return dto.WorkspaceMemberOutput.from_domain(membership) + + +async def get_workspace_members( + self: Usecase, workspace_id: uuid.UUID, user_id: uuid.UUID +) -> list[dto.WorkspaceMemberOutput]: + await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + members = await self.database.get_workspace_members(workspace_id) + + return [dto.WorkspaceMemberOutput.from_domain(member) for member in members] diff --git a/src/usecase/workspace/get_workspaces.py b/src/usecase/workspace/get_workspaces.py new file mode 100644 index 0000000..89195f8 --- /dev/null +++ b/src/usecase/workspace/get_workspaces.py @@ -0,0 +1,26 @@ +import uuid +from typing import TYPE_CHECKING + +from src import dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def get_workspaces(self: 'Usecase', user_id: uuid.UUID) -> list[dto.WorkspaceMembershipOutput]: + memberships = await self.database.get_user_workspaces(user_id) + + return [ + dto.WorkspaceMembershipOutput( + id=membership.workspace_id, + name=membership.workspace.name, + avatar_url=_build_avatar_url(self, membership.workspace.avatar_s3_key), + ) + for membership in memberships + ] + + +def _build_avatar_url(self: 'Usecase', avatar_key: str | None) -> str | None: + if not avatar_key: + return None + return self.s3.public_url(avatar_key) diff --git a/src/usecase/workspace/tg_accept_workspace_invite.py b/src/usecase/workspace/tg_accept_workspace_invite.py new file mode 100644 index 0000000..7c95f41 --- /dev/null +++ b/src/usecase/workspace/tg_accept_workspace_invite.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain +from src.usecase.workspace.create_workspace_invite import INVITE_ACCEPT_CALLBACK_PREFIX + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def tg_accept_workspace_invite( + self: Usecase, + telegram_id: int, + chat_id: int, + callback_data: str, + message_id: int, +) -> None: + user = await self.database.get_user(telegram_id=telegram_id) + if not user: + await self.telegram_bot.send_message('❌ Вы не авторизованы. Используйте /start для входа.', chat_id) + return + if user.telegram_user is None: + await self.telegram_bot.send_message('❌ Ваш Telegram профиль не найден. Авторизуйтесь заново.', chat_id) + return + + parts = callback_data.split(':', 1) + if len(parts) != 2 or parts[0] != INVITE_ACCEPT_CALLBACK_PREFIX: + log.warning('Unexpected callback data for workspace invite: %s', callback_data) + await self.telegram_bot.send_message('❌ Приглашение не найдено.', chat_id) + return + + try: + invite_id = uuid.UUID(parts[1]) + except ValueError: + log.warning('Invalid workspace invite id: %s', parts[1]) + await self.telegram_bot.send_message('❌ Приглашение больше недоступно.', chat_id) + return + + invite = await self.database.get_workspace_invite(invite_id) + if not invite or invite.user_id != user.id: + await self.telegram_bot.send_message('❌ Приглашение не найдено или вы не можете его принять.', chat_id) + if message_id: + await self.telegram_bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id) + return + + if invite.status != domain.WorkspaceInviteStatus.PENDING: + await self.telegram_bot.send_message('⚠️ Это приглашение уже обработано.', chat_id) + if message_id: + await self.telegram_bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id) + return + + async with self.database.transaction(): + invite.status = domain.WorkspaceInviteStatus.ACCEPTED + await self.database.update_workspace_invite(invite) + + membership = await self.database.get_workspace_membership(invite.workspace_id, user.id) + if membership: + if membership.status != domain.WorkspaceUserStatus.ACTIVE: + membership.status = domain.WorkspaceUserStatus.ACTIVE + await self.database.update_workspace_user(membership) + else: + await self.database.add_user_to_workspace(invite.workspace_id, user.id) + + workspace_name = invite.workspace.name if invite.workspace else 'рабочее пространство' + await self.telegram_bot.send_message( + f'✅ Вы присоединились к рабочему пространству «{workspace_name}».\n\n' + 'Администратор сможет выдать вам необходимые права в веб-интерфейсе.', + user.telegram_user.telegram_id, + ) + + if message_id: + await self.telegram_bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id) diff --git a/src/usecase/workspace/update_workspace.py b/src/usecase/workspace/update_workspace.py new file mode 100644 index 0000000..e2bdafd --- /dev/null +++ b/src/usecase/workspace/update_workspace.py @@ -0,0 +1,29 @@ +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def update_workspace( + self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID, input: dto.UpdateWorkspaceInput +) -> dto.WorkspaceMembershipOutput: + await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + membership = await self.database.get_workspace_membership(workspace_id, user_id) + if not membership: + raise domain.WorkspaceNotFound(workspace_id) + + workspace = membership.workspace + + if input.name is not None: + workspace.name = input.name + await self.database.update_workspace(workspace) + + return dto.WorkspaceMembershipOutput( + id=workspace.id, + name=workspace.name, + avatar_url=self.s3.public_url(workspace.avatar_s3_key) if workspace.avatar_s3_key else None, + ) diff --git a/src/usecase/workspace/update_workspace_avatar.py b/src/usecase/workspace/update_workspace_avatar.py new file mode 100644 index 0000000..3289800 --- /dev/null +++ b/src/usecase/workspace/update_workspace_avatar.py @@ -0,0 +1,53 @@ +import asyncio +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def update_workspace_avatar( + self: 'Usecase', + workspace_id: uuid.UUID, + user_id: uuid.UUID, + avatar_data: bytes, + content_type: str | None, +) -> dto.WorkspaceMembershipOutput: + await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + membership = await self.database.get_workspace_membership(workspace_id, user_id) + if not membership or not membership.workspace: + raise domain.WorkspaceNotFound(workspace_id) + + domain.validate_workspace_avatar_size(avatar_data) + + file_id = uuid.uuid4() + avatar_key = f'workspaces/{workspace_id}/avatars/{file_id}' + await self.s3.upload(avatar_key, avatar_data, content_type or 'application/octet-stream') + + workspace = membership.workspace + old_key = workspace.avatar_s3_key + workspace.avatar_s3_key = avatar_key + await self.database.update_workspace(workspace) + + if old_key: + + async def delete_old_avatar() -> None: + try: + await self.s3.delete(old_key) + log.info('Deleted old workspace avatar from S3: %s', old_key) + except Exception as exc: + log.warning('Failed to delete old workspace avatar from S3: %s', exc) + + asyncio.create_task(delete_old_avatar()) + + return dto.WorkspaceMembershipOutput( + id=workspace.id, + name=workspace.name, + avatar_url=self.s3.public_url(avatar_key), + ) diff --git a/src/usecase/workspace/update_workspace_member_permissions.py b/src/usecase/workspace/update_workspace_member_permissions.py new file mode 100644 index 0000000..596b90c --- /dev/null +++ b/src/usecase/workspace/update_workspace_member_permissions.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def update_workspace_member_permissions( + self: Usecase, + workspace_id: uuid.UUID, + workspace_user_id: uuid.UUID, + user_id: uuid.UUID, + input: dto.UpdateWorkspaceMemberPermissionsInput, +) -> dto.WorkspaceMemberOutput: + await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + member = await self.database.get_workspace_member(workspace_user_id) + if not member or member.workspace_id != workspace_id: + raise domain.WorkspaceAccessDenied(workspace_id) + + global_permissions: set[domain.PermissionKey] = set() + scoped_assignments: set[tuple[domain.PermissionKey, domain.PermissionScopeType, uuid.UUID]] = set() + + for permission in input.permissions: + if permission.scopes: + for scope in permission.scopes: + scoped_assignments.add((permission.key, scope.type, scope.id)) + else: + global_permissions.add(permission.key) + + await self.database.set_workspace_user_permissions( + member.id, + global_permissions=global_permissions, + scoped_permissions=list(scoped_assignments), + ) + + updated_member = await self.database.get_workspace_member(member.id) + if not updated_member: + raise domain.WorkspaceAccessDenied(workspace_id) + + return dto.WorkspaceMemberOutput.from_domain(updated_member) diff --git a/tests/test_format_parsing.py b/tests/test_format_parsing.py new file mode 100644 index 0000000..17e2cf2 --- /dev/null +++ b/tests/test_format_parsing.py @@ -0,0 +1,162 @@ +import pytest + +from src.domain.placement import format_display_string, parse_format_duration, parse_format_string + + +class TestParseFormatString: + """Тесты для parse_format_string — парсинг строки формата в (top_minutes, feed_minutes).""" + + def test_standard_format_1_24(self) -> None: + assert parse_format_string('1 / 24') == (60, 1440) + + def test_standard_format_no_spaces(self) -> None: + assert parse_format_string('1/48') == (60, 2880) + + def test_standard_format_1_72(self) -> None: + assert parse_format_string('1 / 72') == (60, 4320) + + def test_days_format_7(self) -> None: + assert parse_format_string('1 / (7 дней)') == (60, 10080) + + def test_days_format_30(self) -> None: + assert parse_format_string('1 / (30 дней)') == (60, 43200) + + def test_no_deletion(self) -> None: + assert parse_format_string('1 / (без удаления)') == (60, 0) + + def test_no_deletion_no_parens(self) -> None: + assert parse_format_string('1 / без удаления') == (60, 0) + + def test_top_2_hours(self) -> None: + assert parse_format_string('2 / 24') == (120, 1440) + + def test_top_2_no_space(self) -> None: + assert parse_format_string('2/48') == (120, 2880) + + def test_top_2_72(self) -> None: + assert parse_format_string('2/72') == (120, 4320) + + def test_top_minutes_value_30(self) -> None: + """Значение > 12 интерпретируется как минуты.""" + assert parse_format_string('30 / 24') == (30, 1440) + + def test_none_input(self) -> None: + assert parse_format_string(None) == (None, None) + + def test_empty_string(self) -> None: + assert parse_format_string('') == (None, None) + + def test_whitespace_string(self) -> None: + assert parse_format_string(' ') == (None, None) + + def test_unparseable_string(self) -> None: + assert parse_format_string('пост') == (None, None) + + def test_no_slash(self) -> None: + assert parse_format_string('24') == (None, None) + + def test_days_short_form(self) -> None: + assert parse_format_string('1 / 7д') == (60, 10080) + + def test_days_short_form_dn(self) -> None: + assert parse_format_string('1 / 7 дн') == (60, 10080) + + def test_with_unit_suffixes(self) -> None: + assert parse_format_string('1ч / 24ч') == (60, 1440) + + def test_with_unit_suffixes_days(self) -> None: + assert parse_format_string('1ч / 7д') == (60, 10080) + + +class TestFormatDisplayString: + """Тесты для format_display_string — формирование строки из числовых значений.""" + + def test_hours_hours(self) -> None: + assert format_display_string(60, 1440) == '1ч / 24ч' + + def test_minutes_hours(self) -> None: + assert format_display_string(30, 1440) == '30мин / 24ч' + + def test_no_deletion(self) -> None: + assert format_display_string(60, 0) == '1ч / без удаления' + + def test_days(self) -> None: + assert format_display_string(60, 10080) == '1ч / 7д' + + def test_none_none(self) -> None: + assert format_display_string(None, None) is None + + def test_2h_48h(self) -> None: + assert format_display_string(120, 2880) == '2ч / 48ч' + + def test_2h_30d(self) -> None: + assert format_display_string(120, 43200) == '2ч / 30д' + + def test_top_only(self) -> None: + assert format_display_string(60, None) == '1ч / ?' + + def test_feed_only(self) -> None: + assert format_display_string(None, 1440) == '? / 24ч' + + def test_36h(self) -> None: + assert format_display_string(60, 2160) == '1ч / 36ч' + + def test_72h(self) -> None: + assert format_display_string(60, 4320) == '1ч / 72ч' + + def test_90d(self) -> None: + assert format_display_string(60, 129600) == '1ч / 90д' + + +class TestParseFormatDuration: + """Тесты обратной совместимости parse_format_duration — возвращает feed time в секундах.""" + + def test_1_24(self) -> None: + assert parse_format_duration('1 / 24') == 86400 + + def test_1_48(self) -> None: + assert parse_format_duration('1/48') == 172800 + + def test_1_72(self) -> None: + assert parse_format_duration('1 / 72') == 259200 + + def test_7_days(self) -> None: + assert parse_format_duration('1 / (7 дней)') == 604800 + + def test_30_days(self) -> None: + assert parse_format_duration('1 / (30 дней)') == 2592000 + + def test_no_deletion(self) -> None: + assert parse_format_duration('1 / (без удаления)') is None + + def test_none(self) -> None: + assert parse_format_duration(None) is None + + def test_empty(self) -> None: + assert parse_format_duration('') is None + + def test_2_24(self) -> None: + assert parse_format_duration('2 / 24') == 86400 + + +class TestRoundtrip: + """Тесты round-trip: parse → format → parse.""" + + @pytest.mark.parametrize( + 'top,feed', + [ + (60, 1440), + (60, 2880), + (60, 10080), + (60, 0), + (120, 1440), + (120, 43200), + (30, 1440), + ], + ) + def test_roundtrip(self, top: int, feed: int) -> None: + display = format_display_string(top, feed) + assert display is not None + parsed_top, parsed_feed = parse_format_string(display) + assert parsed_top == top + assert parsed_feed == feed diff --git a/tg_bot/Dockerfile b/tg_bot/Dockerfile new file mode 100644 index 0000000..8e62791 --- /dev/null +++ b/tg_bot/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.25-alpine AS build + +WORKDIR /app/tg_bot + +# Modules layer +COPY tg_bot/go.mod tg_bot/go.sum ./ +COPY pkg /app/pkg +COPY shared/echotron /app/shared/echotron +RUN go mod download + +# Build layer +COPY tg_bot /app/tg_bot +RUN CGO_ENABLED=0 GOOS=linux go build -o /tg_bot . + +FROM alpine:latest AS run + +COPY --from=build /tg_bot /tg_bot + +CMD ["/tg_bot"] diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go new file mode 100644 index 0000000..1b0275d --- /dev/null +++ b/tg_bot/backend/backend_client.go @@ -0,0 +1,821 @@ +package backend + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +var ErrNotFound = errors.New("not found") + +type Config struct { + BaseURL string `envconfig:"BACKEND__BASE_URL" default:"http_v1://localhost:8000"` + LoginURL string `envconfig:"LOGIN_URL" required:"true"` +} + +type Client struct { + http *http.Client + baseURL string + loginURL string +} + +func New(cfg Config) *Client { + return &Client{ + http: &http.Client{ + Timeout: 60 * time.Second, + }, + baseURL: cfg.BaseURL, + loginURL: cfg.LoginURL, + } +} + +func withBearer(token string) func(*http.Request) { + return func(r *http.Request) { + r.Header.Set("Authorization", "Bearer "+token) + } +} + +func (c *Client) do(ctx context.Context, method string, path string, in any, out any, opts ...func(*http.Request)) error { + var body io.Reader + + if in != nil { + b, err := json.Marshal(in) + if err != nil { + return fmt.Errorf("json.Marshal: %w", err) + } + body = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+"/"+path, body) + if err != nil { + return fmt.Errorf("http_v1.NewRequest: %w", err) + } + + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + + for _, opt := range opts { + opt(req) + } + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("client.Do: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return ErrNotFound + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10)) + return fmt.Errorf("request failed: %s: %s", resp.Status, b) + } + + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("json.Decode: %w", err) + } + } + + return nil +} + +func (c *Client) LoginURL(token string) string { + return c.loginURL + token +} + +func (c *Client) CreateLoginToken(ctx context.Context, telegramID int64) (string, error) { + req := struct { + TelegramID int64 `json:"telegram_id"` + }{ + TelegramID: telegramID, + } + + var token string + err := c.do(ctx, http.MethodPost, "api/v1/internal/auth/login-token", req, &token) + + return token, err +} + +func (c *Client) AttachLoginTokenMessage(ctx context.Context, token string, messageID int) error { + req := struct { + Token string `json:"token"` + MessageID int `json:"message_id"` + }{ + Token: token, + MessageID: messageID, + } + + return c.do(ctx, http.MethodPost, "api/v1/internal/auth/login-token/message", req, nil) +} + +func (c *Client) GetJWTByTelegramID( + ctx context.Context, + telegramID int64, +) (string, error) { + q := url.Values{} + q.Set("telegram_id", fmt.Sprint(telegramID)) + + path := "api/v1/internal/auth/jwt?" + q.Encode() + + var resp struct { + AccessToken string `json:"access_token"` + } + + err := c.do(ctx, http.MethodGet, path, nil, &resp) + return resp.AccessToken, err +} + +func (c *Client) GetJWTByTelegramUser( + ctx context.Context, + telegramID int64, + username *string, + firstName *string, + lastName *string, +) (string, error) { + q := url.Values{} + q.Set("telegram_id", fmt.Sprint(telegramID)) + if username != nil && *username != "" { + q.Set("username", *username) + } + if firstName != nil && *firstName != "" { + q.Set("first_name", *firstName) + } + if lastName != nil && *lastName != "" { + q.Set("last_name", *lastName) + } + + path := "api/v1/internal/auth/jwt?" + q.Encode() + + var resp struct { + AccessToken string `json:"access_token"` + } + + err := c.do(ctx, http.MethodGet, path, nil, &resp) + return resp.AccessToken, err +} + +type Workspace struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Project struct { + ID string `json:"id"` + TelegramID int64 `json:"telegram_id"` + Title string `json:"title"` + Username *string `json:"username"` + Status string `json:"status"` + PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"` +} + +type Page struct { + Items []Project `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"size"` + Pages int `json:"pages"` +} + +func (c *Client) GetWorkspaces( + ctx context.Context, + jwt string, +) ([]Workspace, error) { + var resp struct { + Items []Workspace `json:"items"` + } + + err := c.do( + ctx, + http.MethodGet, + "api/v1/workspaces", + nil, + &resp, + withBearer(jwt), + ) + + return resp.Items, err +} + +func (c *Client) CreateWorkspace(ctx context.Context, jwt string, name string) (Workspace, error) { + req := struct { + Name string `json:"name"` + }{ + Name: name, + } + + var workspace Workspace + err := c.do(ctx, http.MethodPost, "api/v1/workspaces", req, &workspace, withBearer(jwt)) + + return workspace, err +} + +func (c *Client) GetProjects( + ctx context.Context, + jwt string, + workspaceID string, + page, size int, +) (*Page, error) { + q := url.Values{} + q.Set("page", fmt.Sprint(page)) + q.Set("size", fmt.Sprint(size)) + + path := fmt.Sprintf("api/v1/workspaces/%s/projects?%s", workspaceID, q.Encode()) + + var resp Page + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &resp, + withBearer(jwt), + ) + + return &resp, err +} + +func (c *Client) GetProject( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, +) (*Project, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s", workspaceID, projectID) + + var project Project + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &project, + withBearer(jwt), + ) + + return &project, err +} + +func (c *Client) UpdateProjectInviteLinkType( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, + inviteLinkType string, // "public" или "approval" +) (*Project, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/invite-link-type", workspaceID, projectID) + + payload := map[string]string{ + "purchase_invite_type_default": inviteLinkType, + } + + var project Project + err := c.do( + ctx, + http.MethodPatch, + path, + payload, + &project, + withBearer(jwt), + ) + + return &project, err +} + +func (c *Client) SendEvent(ctx context.Context, payload any) error { + return c.do( + ctx, + http.MethodPost, + "api/v1/internal/events", + payload, + nil, + ) +} + +func (c *Client) SearchChannels( + ctx context.Context, + jwt string, + username string, +) ([]Channel, error) { + path := fmt.Sprintf("api/v1/channels?username=%s", username) + + var response struct { + Items []Channel `json:"items"` + } + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &response, + withBearer(jwt), + ) + + return response.Items, err +} + +func (c *Client) AttachChannelToWorkspace( + ctx context.Context, + channelID string, + workspaceID string, + userTelegramID int64, +) (*Project, error) { + input := map[string]any{ + "channel_id": channelID, + "workspace_id": workspaceID, + "user_telegram_id": userTelegramID, + } + + var project Project + err := c.do( + ctx, + http.MethodPost, + "api/v1/internal/projects", + input, + &project, + ) + + return &project, err +} + +func (c *Client) AcceptWorkspaceInvite( + ctx context.Context, + jwt string, + inviteID string, +) error { + path := fmt.Sprintf("api/v1/invites/%s/accept", inviteID) + + return c.do( + ctx, + http.MethodPost, + path, + nil, + nil, + withBearer(jwt), + ) +} + +// ============================================================================ +// Creatives +// ============================================================================ + +type Creative struct { + ID string `json:"id"` + Name string `json:"name"` + Text string `json:"text"` + MediaItems []CreativeMediaItem `json:"media_items"` + Buttons []CreativeButton `json:"buttons"` + ProjectID string `json:"project_id"` + ProjectChannelTitle string `json:"project_channel_title"` + CreatedAt string `json:"created_at"` + Status string `json:"status"` + Tag string `json:"tag"` + PlacementsCount int `json:"placements_count"` +} + +type CreativeButton struct { + Text string `json:"text"` + URL string `json:"url"` +} + +type CreativeMediaItem struct { + MediaType string `json:"media_type"` + MediaFileID string `json:"media_file_id"` + Position int `json:"position"` + S3URL *string `json:"s3_url,omitempty"` +} + +type CreativeMediaInput struct { + MediaType string `json:"media_type"` + MediaFileID string `json:"media_file_id"` + MediaData []byte `json:"media_data,omitempty"` +} + +type CreativesPage struct { + Items []Creative `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"size"` + Pages int `json:"pages"` +} + +func (c *Client) GetCreatives( + ctx context.Context, + jwt string, + workspaceID string, + projectID *string, + includeArchived bool, + page, size int, +) (*CreativesPage, error) { + q := url.Values{} + if projectID != nil { + q.Set("project_id", *projectID) + } + q.Set("include_archived", fmt.Sprint(includeArchived)) + q.Set("page", fmt.Sprint(page)) + q.Set("size", fmt.Sprint(size)) + + path := fmt.Sprintf("api/v1/workspaces/%s/creatives?%s", workspaceID, q.Encode()) + + var resp CreativesPage + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &resp, + withBearer(jwt), + ) + + return &resp, err +} + +func (c *Client) GetCreative( + ctx context.Context, + jwt string, + workspaceID string, + creativeID string, +) (*Creative, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID) + + var creative Creative + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &creative, + withBearer(jwt), + ) + + return &creative, err +} + +type CreateCreativeInput struct { + Name string `json:"name"` + Text string `json:"text"` + MediaItems []CreativeMediaInput `json:"media_items,omitempty"` + Buttons []CreativeButton `json:"buttons,omitempty"` + Tag *string `json:"tag,omitempty"` +} + +func (c *Client) CreateCreative( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, + input CreateCreativeInput, +) (*Creative, error) { + q := url.Values{} + q.Set("project_id", projectID) + + path := fmt.Sprintf("api/v1/workspaces/%s/creatives?%s", workspaceID, q.Encode()) + + var creative Creative + err := c.do( + ctx, + http.MethodPost, + path, + input, + &creative, + withBearer(jwt), + ) + + return &creative, err +} + +type UpdateCreativeInput struct { + Name *string `json:"name,omitempty"` + Text *string `json:"text,omitempty"` + MediaItems *[]CreativeMediaInput `json:"media_items,omitempty"` + Buttons *[]CreativeButton `json:"buttons,omitempty"` + Status *string `json:"status,omitempty"` + Tag *string `json:"tag,omitempty"` +} + +func (c *Client) UpdateCreative( + ctx context.Context, + jwt string, + workspaceID string, + creativeID string, + input UpdateCreativeInput, +) (*Creative, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID) + + var creative Creative + err := c.do( + ctx, + http.MethodPatch, + path, + input, + &creative, + withBearer(jwt), + ) + + return &creative, err +} + +func (c *Client) DeleteCreative( + ctx context.Context, + jwt string, + workspaceID string, + creativeID string, +) error { + path := fmt.Sprintf("api/v1/workspaces/%s/creatives/%s", workspaceID, creativeID) + + return c.do( + ctx, + http.MethodDelete, + path, + nil, + nil, + withBearer(jwt), + ) +} + +// ============================================================================ +// Placements (Размещения) +// ============================================================================ + +type Channel struct { + ID string `json:"id"` + TelegramID *int64 `json:"telegram_id"` + Title *string `json:"title"` + Username *string `json:"username"` + InviteLink *string `json:"invite_link"` +} + +type CreateChannelInput struct { + Username *string `json:"username,omitempty"` + InviteLink *string `json:"invite_link,omitempty"` +} + +type CreateChannelsInput struct { + Channels []CreateChannelInput `json:"channels"` +} + +type CreateChannelResult struct { + Index int `json:"index"` + Status string `json:"status"` + Channel *Channel `json:"channel,omitempty"` + Error *string `json:"error,omitempty"` +} + +type ProjectOutput struct { + ID string `json:"id"` + TelegramID int64 `json:"telegram_id"` + Title string `json:"title"` + Username *string `json:"username"` + Status string `json:"status"` + PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"` + Channel Channel `json:"channel"` +} + +type CreateChannelsOutput struct { + Results []CreateChannelResult `json:"results"` +} + +type CostInfo struct { + Type string `json:"type"` + Value float64 `json:"value"` +} + +type PlacementDetails struct { + PlacementAt *string `json:"placement_at,omitempty"` + PaymentAt *string `json:"payment_at,omitempty"` + Cost *CostInfo `json:"cost,omitempty"` + CostBeforeBargain *CostInfo `json:"cost_before_bargain,omitempty"` + PlacementType *string `json:"placement_type,omitempty"` + Format *string `json:"format,omitempty"` + TopTimeMinutes *int `json:"top_time_minutes,omitempty"` + FeedTimeMinutes *int `json:"feed_time_minutes,omitempty"` + Comment *string `json:"comment,omitempty"` + CreativeID *string `json:"creative_id,omitempty"` + CreativeName *string `json:"creative_name,omitempty"` + InviteLinkType *string `json:"invite_link_type,omitempty"` +} + +type PlacementOutput struct { + ID string `json:"id"` + Status string `json:"status"` + CreativeID *string `json:"creative_id,omitempty"` + CreativeName *string `json:"creative_name,omitempty"` + Comment *string `json:"comment,omitempty"` + InviteLink *string `json:"invite_link,omitempty"` + InviteLinkType string `json:"invite_link_type"` + Channel Channel `json:"channel"` + Project *ProjectOutput `json:"project"` + ShortID string `json:"short_id"` + Details *PlacementDetails `json:"details,omitempty"` + PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"` + CreatedAt string `json:"created_at"` +} + +type PlacementPostOutput struct { + SubscriptionsCount int `json:"subscriptions_count"` + ViewsCount *int `json:"views_count,omitempty"` + CreatedAt string `json:"created_at"` + TimeOnTop *int `json:"time_on_top,omitempty"` + Post PostOutput `json:"post"` +} + +type PostOutput struct { + ID string `json:"id"` + MessageID int `json:"message_id"` + Text string `json:"text"` + URL *string `json:"url,omitempty"` + DeletedFromChannelAt *string `json:"deleted_from_channel_at,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type CreatePlacementChannelInput struct { + ChannelID string `json:"channel_id"` + Status *string `json:"status,omitempty"` + Comment *string `json:"comment,omitempty"` + Details *PlacementDetails `json:"details,omitempty"` +} + +type CreatePlacementsInput struct { + CreativeID *string `json:"creative_id,omitempty"` + Channels []CreatePlacementChannelInput `json:"channels"` +} + +type GetPlacementsOutput struct { + Placements []PlacementOutput `json:"placements"` +} + +type PlacementsPage struct { + Items []PlacementOutput `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"size"` + Pages int `json:"pages"` +} + +type CreativePreviewOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Text string `json:"text"` + MediaItems []CreativeMediaItem `json:"media_items"` + Buttons []CreativeButton `json:"buttons"` +} + +func (c *Client) CreatePlacements( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, + input CreatePlacementsInput, +) (*GetPlacementsOutput, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements", workspaceID, projectID) + + var placements GetPlacementsOutput + err := c.do( + ctx, + http.MethodPost, + path, + input, + &placements, + withBearer(jwt), + ) + + return &placements, err +} + +func (c *Client) CreateChannels( + ctx context.Context, + jwt string, + input CreateChannelsInput, +) (*CreateChannelsOutput, error) { + var response CreateChannelsOutput + err := c.do( + ctx, + http.MethodPost, + "api/v1/channels", + input, + &response, + withBearer(jwt), + ) + + return &response, err +} + +func (c *Client) GetPlacements( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, + page, size int, +) (*PlacementsPage, error) { + q := url.Values{} + q.Set("page", fmt.Sprint(page)) + q.Set("size", fmt.Sprint(size)) + + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements?%s", workspaceID, projectID, q.Encode()) + + var resp PlacementsPage + + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &resp, + withBearer(jwt), + ) + + return &resp, err +} + +func (c *Client) GetPlacement( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, + placementID string, +) (*PlacementOutput, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements/%s", workspaceID, projectID, placementID) + + var placement PlacementOutput + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &placement, + withBearer(jwt), + ) + + return &placement, err +} + +func (c *Client) BuildPlacementCreative( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, + placementID string, +) (*CreativePreviewOutput, error) { + path := fmt.Sprintf( + "api/v1/workspaces/%s/projects/%s/placements/%s/creative", + workspaceID, + projectID, + placementID, + ) + + var resp CreativePreviewOutput + err := c.do( + ctx, + http.MethodPost, + path, + nil, + &resp, + withBearer(jwt), + ) + + return &resp, err +} + +// ============================================================================ +// Workspace Members +// ============================================================================ + +type WorkspaceMember struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + UserID string `json:"user_id"` + Username string `json:"username"` +} + +func (c *Client) GetWorkspaceMembers( + ctx context.Context, + jwt string, + workspaceID string, +) ([]WorkspaceMember, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/members", workspaceID) + + var resp struct { + Items []WorkspaceMember `json:"items"` + } + + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &resp, + withBearer(jwt), + ) + + return resp.Items, err +} diff --git a/tg_bot/bot/bot.go b/tg_bot/bot/bot.go new file mode 100644 index 0000000..47d3332 --- /dev/null +++ b/tg_bot/bot/bot.go @@ -0,0 +1,427 @@ +package bot + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "sync" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" + "github.com/rs/zerolog/log" +) + +type exec struct { + handled bool // Событие обработано, дальше не идём + transitioned bool // Был SetState +} + +var botUsername string + +func SetUsername(username string) { botUsername = username } + +func Username() string { return botUsername } + +type lastRender struct { + textHash string + kbHash string + isMedia bool +} + +type Bot struct { + echotron.API + mu sync.Mutex + ChatID int64 + exec *exec + CurrentState State + Session *Session + LastMessageID int // ID последнего сообщения с inline кнопками + lastRender lastRender + commandRouter func(cmd string) State // Роутер для команд (например, /start, /help) + globalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений + Backend *backend.Client +} + +func NewBot(chatID int64, token string, commandRouter func(string) State, backendClient *backend.Client, globalCallbackRouter func(string) State) *Bot { + if commandRouter == nil { + panic("bot: commandRouter cannot be nil") + } + if globalCallbackRouter == nil { + panic("bot: globalCallbackRouter cannot be nil") + } + if commandRouter("/start") == nil { + panic("bot: commandRouter requires /start state answer") + } + if backendClient == nil { + panic("bot: backendClient cannot be nil") + } + + api := echotron.NewAPI(token) + + return &Bot{ + ChatID: chatID, + API: api, + Session: &Session{}, + commandRouter: commandRouter, + Backend: backendClient, + globalCallbackRouter: globalCallbackRouter, + } +} + +// GetOrCreateJWT получает JWT токен, передавая актуальные данные пользователя из update +func (b *Bot) GetOrCreateJWT(u *echotron.Update) error { + // Извлекаем данные пользователя из update + var user *echotron.User + if u.Message != nil && u.Message.From != nil { + user = u.Message.From + } else if u.CallbackQuery != nil && u.CallbackQuery.From != nil { + user = u.CallbackQuery.From + } + + var username, firstName, lastName *string + if user != nil { + if user.Username != "" { + username = &user.Username + } + if user.FirstName != "" { + firstName = &user.FirstName + } + if user.LastName != "" { + lastName = &user.LastName + } + } + + jwt, err := b.Backend.GetJWTByTelegramUser(context.Background(), b.ChatID, username, firstName, lastName) + if err != nil { + return err + } + + b.Session.JWT = jwt + + if firstName != nil { + b.Session.FirstName = *firstName + } + + return nil +} + +func (b *Bot) SetState(s State, mode RenderMode) { + log.Info().Msg(fmt.Sprintf("State transition: %T -> %T", b.CurrentState, s)) + + if b.CurrentState != nil { + b.CurrentState.Exit() + } + + b.CurrentState = s + + if b.exec != nil { + b.exec.transitioned = true + b.exec.handled = true + } + + s.Enter(b, mode) +} + +func (b *Bot) MarkHandled() { + if b.exec != nil { + b.exec.handled = true + } +} + +func isChannelChat(chatType string) bool { + return chatType == "channel" || chatType == "supergroup" +} + +func stringPtr(value string) *string { + if value == "" { + return nil + } + return &value +} + +func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode RenderMode) { + switch mode { + case EditMessage: + b.Edit(text, keyboard) + case NewMessage: + b.SendNew(text, keyboard) + default: + panic("unknown RenderMode") + } +} + +func (b *Bot) SetLastMessageIsMedia(isMedia bool) { + b.lastRender.isMedia = isMedia +} + +func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) { + res, err := b.GetFile(fileID) + if err != nil { + return nil, err + } + if res.Result == nil || res.Result.FilePath == "" { + return nil, fmt.Errorf("telegram file not available") + } + return b.DownloadFile(res.Result.FilePath) +} + +func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) { + if b.exec != nil { + defer func() { b.exec.handled = true }() + } + + if b.LastMessageID != 0 { + log.Info().Int("cleanup_msg_id", b.LastMessageID).Msg("Cleaning up keyboard before SendNew") + b.cleanupKeyboard(b.LastMessageID) + } + + res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{ + ReplyMarkup: keyboard, + ParseMode: echotron.HTML, + LinkPreviewOptions: echotron.LinkPreviewOptions{IsDisabled: true}, + }) + if err != nil { + log.Error().Err(err).Msg("SendMessage failed") + return + } + + if res.Result == nil { + return + } + + b.LastMessageID = res.Result.ID + b.lastRender.isMedia = false + b.lastRender.textHash = hashString(text) + b.lastRender.kbHash = keyboardHash(keyboard) + log.Info().Int("new_last_msg_id", b.LastMessageID).Msg("Updated LastMessageID in SendNew") +} + +func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) { + if b.exec != nil { + defer func() { b.exec.handled = true }() + } + + if b.LastMessageID == 0 { + b.SendNew(text, keyboard) + return + } + + newTextHash := hashString(text) + newKBHash := keyboardHash(keyboard) + + textChanged := newTextHash != b.lastRender.textHash + kbChanged := newKBHash != b.lastRender.kbHash + + if !textChanged && !kbChanged { + log.Info().Msg("Edit skipped: message not modified") + return + } + + var err error + msgID := echotron.NewMessageID(b.ChatID, b.LastMessageID) + + if b.lastRender.isMedia { + _, err = b.EditMessageCaption(msgID, &echotron.MessageCaptionOptions{ + Caption: text, + ParseMode: echotron.HTML, + ReplyMarkup: keyboard, + }) + } else { + _, err = b.EditMessageText(text, msgID, &echotron.MessageTextOptions{ + ReplyMarkup: keyboard, + ParseMode: echotron.HTML, + LinkPreviewOptions: echotron.LinkPreviewOptions{IsDisabled: true}, + }) + } + + if err == nil { + if textChanged { + b.lastRender.textHash = newTextHash + } + if kbChanged { + b.lastRender.kbHash = newKBHash + } + return + } + + switch { + case strings.Contains(err.Error(), "message is not modified"): + log.Err(err).Msg("EditMessageText ignored: message is not modified") + case strings.Contains(err.Error(), "no text in the message to edit"): + b.lastRender.isMedia = true + log.Err(err).Msg("EditMessageText ignored: message has no text") + default: + log.Error().Err(err).Msg("EditMessageText") + } + +} + +func keyboardHash(kb echotron.InlineKeyboardMarkup) string { + b, err := json.Marshal(kb) + if err != nil { + return "" + } + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func hashString(v string) string { + sum := sha256.Sum256([]byte(v)) + return hex.EncodeToString(sum[:]) +} + +func (b *Bot) Update(u *echotron.Update) { + b.mu.Lock() + defer b.mu.Unlock() + + log.Info().Int64("chat_id", b.ChatID).Msg("Update received") + + // 1. Системные события -> отправляем доменные события в backend + if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil { + go b.forwardSystemEvent(u) + return + } + + // 2. Игнорируем события не из private чатов + chatType := "" + switch { + case u.Message != nil: + chatType = u.Message.Chat.Type + case u.CallbackQuery != nil && u.CallbackQuery.Message != nil: + chatType = u.CallbackQuery.Message.Chat.Type + case u.EditedMessage != nil: + chatType = u.EditedMessage.Chat.Type + case u.ChannelPost != nil || u.EditedChannelPost != nil: + return // Каналы игнорируем сразу + } + if chatType != "" && chatType != "private" { + log.Debug().Str("chat_type", chatType).Msg("Ignoring non-private chat") + return + } + + // 3. Игнорируем edited messages + if u.EditedMessage != nil || u.EditedChannelPost != nil || u.EditedBusinessMessage != nil { + log.Debug().Msg("Ignoring edited message") + return + } + + err := b.GetOrCreateJWT(u) + if err != nil || b.Session.JWT == "" { + b.Edit( + "❌ Ошибка авторизации", + echotron.InlineKeyboardMarkup{InlineKeyboard: [][]echotron.InlineKeyboardButton{{{Text: "↻ Обновить", CallbackData: "refresh"}}}}, + ) + b.cleanupCallbackUI(u) + return + } + + e := &exec{} + b.exec = e + defer func() { b.exec = nil }() + + isStartCommand := u.Message != nil && (u.Message.Text == "/start" || + u.Message.Text == "/start login" || + strings.HasPrefix(u.Message.Text, "/start project_")) + + if b.CurrentState == nil && !isStartCommand { + b.Session.WasRestarted = true + b.SetState(b.commandRouter("/start"), EditMessage) + return + } + + // === Обработка входящего события === + + // 1. Команды (приоритетный маршрут) + if u.Message != nil { + if strings.HasPrefix(u.Message.Text, "/") { + b.SetState(b.commandRouter(u.Message.Text), NewMessage) + return + } + + b.CurrentState.HandleMessage(b, u) + if e.handled || e.transitioned { + return + } + } + + // 2. Callback запросы + if u.CallbackQuery != nil { + b.cleanupCallbackUI(u) + + switch u.CallbackQuery.Data { + case "empty": + return + case "refresh": + if b.CurrentState != nil { + b.CurrentState.Enter(b, EditMessage) + e.handled = true + } + return + } + + if newState := b.globalCallbackRouter(u.CallbackQuery.Data); newState != nil { + b.SetState(newState, NewMessage) + return + } + + b.CurrentState.HandleCallback(b, u) + if e.handled || e.transitioned { + return + } + } + + // 3. Универсальный обработчик состояния (если ничего не подошло) + b.CurrentState.Handle(b, u) + if e.handled || e.transitioned { + return + } + + // 4. Fallback - ререндер текущего экрана, чтобы не терять прогресс + if b.CurrentState != nil { + mode := NewMessage + if u.CallbackQuery != nil { + mode = EditMessage + } + b.CurrentState.Enter(b, mode) + if e.handled || e.transitioned { + return + } + } + + // 5. Крайний fallback - сброс в начальное состояние + b.SetState(b.commandRouter("/start"), NewMessage) +} + +func (b *Bot) cleanupKeyboard(messageID int) { + var emptyKeyboard = echotron.InlineKeyboardMarkup{ + InlineKeyboard: [][]echotron.InlineKeyboardButton{}, + } + _, err := b.EditMessageReplyMarkup( + echotron.NewMessageID(b.ChatID, messageID), + &echotron.MessageReplyMarkupOptions{ReplyMarkup: emptyKeyboard}, + ) + if err != nil { + log.Error().Err(err).Int("message_id", messageID).Msg("Failed to remove keyboard") + } +} + +func (b *Bot) cleanupCallbackUI(u *echotron.Update) { + _, err := b.AnswerCallbackQuery(u.CallbackQuery.ID, nil) + if err != nil { + log.Error().Err(err).Msg("b.AnswerCallbackQuery error") + } + + if u.CallbackQuery.Message != nil { + callbackMessageID := u.CallbackQuery.Message.ID + + // Если это сообщение последнее, не удаляем клавиатуру + if b.LastMessageID == callbackMessageID { + return + } + + b.cleanupKeyboard(callbackMessageID) + } +} diff --git a/tg_bot/bot/forward_system_event.go b/tg_bot/bot/forward_system_event.go new file mode 100644 index 0000000..a3808d6 --- /dev/null +++ b/tg_bot/bot/forward_system_event.go @@ -0,0 +1,196 @@ +package bot + +import ( + "context" + "fmt" + + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +func (b *Bot) forwardSystemEvent(u *echotron.Update) { + ctx := context.Background() + + switch { + case u.ChatJoinRequest != nil: + req := u.ChatJoinRequest + if !isChannelChat(req.Chat.Type) { + return + } + + inviteLink := "" + if req.InviteLink != nil { + inviteLink = req.InviteLink.InviteLink + } + if inviteLink == "" { + log.Debug().Msg("No invite link in chat join request") + return + } + + userID := req.From.ID + username := stringPtr(req.From.Username) + firstName := stringPtr(req.From.FirstName) + lastName := stringPtr(req.From.LastName) + if userID == 0 && req.UserChatID != 0 { + userID = req.UserChatID + } + if userID == 0 { + log.Error().Msg("No user id in chat join request") + return + } + + payload := map[string]any{ + "type": "subscription", + "user_telegram_id": userID, + "invite_link": inviteLink, + "username": username, + "first_name": firstName, + "last_name": lastName, + } + if err := b.Backend.SendEvent(ctx, payload); err != nil { + log.Error().Err(err).Msg("Failed to send subscription event") + } + + case u.ChatMember != nil: + event := u.ChatMember + if !isChannelChat(event.Chat.Type) { + return + } + + memberUser := event.NewChatMember.User + if memberUser == nil { + log.Error().Msg("No user in chat member update") + return + } + + oldStatus := event.OldChatMember.Status + newStatus := event.NewChatMember.Status + + wasNotMember := oldStatus == "left" || oldStatus == "kicked" + isNowMember := newStatus == "member" || newStatus == "administrator" || newStatus == "creator" + userJoined := wasNotMember && isNowMember + + wasMember := oldStatus == "member" || oldStatus == "administrator" || oldStatus == "creator" || oldStatus == "restricted" + isNowNotMember := newStatus == "left" || newStatus == "kicked" + userLeft := wasMember && isNowNotMember + + if userJoined { + inviteLink := "" + if event.InviteLink != nil { + inviteLink = event.InviteLink.InviteLink + } + if inviteLink == "" { + log.Debug().Msg("No invite link in chat member update") + return + } + + username := stringPtr(memberUser.Username) + firstName := stringPtr(memberUser.FirstName) + lastName := stringPtr(memberUser.LastName) + payload := map[string]any{ + "type": "subscription", + "user_telegram_id": memberUser.ID, + "invite_link": inviteLink, + "username": username, + "first_name": firstName, + "last_name": lastName, + } + if err := b.Backend.SendEvent(ctx, payload); err != nil { + log.Error().Err(err).Msg("Failed to send subscription event") + } + return + } + + if userLeft { + payload := map[string]any{ + "type": "unsubscription", + "user_telegram_id": memberUser.ID, + "channel_telegram_id": event.Chat.ID, + } + if err := b.Backend.SendEvent(ctx, payload); err != nil { + log.Error().Err(err).Msg("Failed to send unsubscription event") + } + } + + case u.MyChatMember != nil: + event := u.MyChatMember + if !isChannelChat(event.Chat.Type) { + return + } + + actorID := event.From.ID + if actorID == 0 { + log.Error().Msg("No user in my chat member update") + return + } + + oldStatus := event.OldChatMember.Status + newStatus := event.NewChatMember.Status + + wasMember := oldStatus == "administrator" || oldStatus == "member" + isNowNotMember := newStatus == "left" || newStatus == "kicked" + botRemoved := wasMember && isNowNotMember + + permissionsChanged := oldStatus == "administrator" && newStatus == "administrator" + + if botRemoved { + payload := map[string]any{ + "type": "bot_removed", + "telegram_id": event.Chat.ID, + "user_telegram_id": actorID, + } + if err := b.Backend.SendEvent(ctx, payload); err != nil { + log.Error().Err(err).Msg("Failed to send bot removed event") + } + return + } + + if permissionsChanged { + title := event.Chat.Title + if title == "" { + title = fmt.Sprintf("Channel %d", event.Chat.ID) + } + payload := map[string]any{ + "type": "bot_permissions", + "telegram_id": event.Chat.ID, + "chat_title": title, + "user_telegram_id": actorID, + "permissions": map[string]any{ + "is_admin": newStatus == "administrator", + "can_invite_users": event.NewChatMember.CanInviteUsers, + "can_restrict_members": event.NewChatMember.CanRestrictMembers, + }, + } + if err := b.Backend.SendEvent(ctx, payload); err != nil { + log.Error().Err(err).Msg("Failed to send bot permissions event") + } + return + } + + wasNotMember := oldStatus == "left" || oldStatus == "kicked" + isNowMember := newStatus == "administrator" || newStatus == "member" + botAdded := wasNotMember && isNowMember + + if botAdded { + title := event.Chat.Title + if title == "" { + title = fmt.Sprintf("Channel %d", event.Chat.ID) + } + payload := map[string]any{ + "type": "bot_added", + "telegram_id": event.Chat.ID, + "title": title, + "username": stringPtr(event.Chat.Username), + "user_telegram_id": actorID, + "bot_permissions": map[string]any{ + "is_admin": newStatus == "administrator", + "can_invite_users": event.NewChatMember.CanInviteUsers, + "can_restrict_members": event.NewChatMember.CanRestrictMembers, + }, + } + if err := b.Backend.SendEvent(ctx, payload); err != nil { + log.Error().Err(err).Msg("Failed to send bot added event") + } + } + } +} diff --git a/tg_bot/bot/state.go b/tg_bot/bot/state.go new file mode 100644 index 0000000..b5fec0d --- /dev/null +++ b/tg_bot/bot/state.go @@ -0,0 +1,25 @@ +package bot + +import "github.com/NicoNex/echotron/v3" + +type RenderMode int + +const ( + NewMessage RenderMode = iota + EditMessage +) + +type State interface { + Enter(*Bot, RenderMode) + HandleCallback(*Bot, *echotron.Update) + HandleMessage(*Bot, *echotron.Update) + Handle(*Bot, *echotron.Update) + Exit() // Вызывается при выходе из состояния для cleanup (например, отмены горутин) +} + +type Session struct { + FirstName string + JWT string + WorkspaceID string + WasRestarted bool +} diff --git a/tg_bot/go.mod b/tg_bot/go.mod new file mode 100644 index 0000000..ed58e83 --- /dev/null +++ b/tg_bot/go.mod @@ -0,0 +1,24 @@ +module github.com/TelegramExchange/tgex-backend/tg_bot + +go 1.24.4 + +require ( + github.com/NicoNex/echotron/v3 v3.43.0 + github.com/rs/zerolog v1.34.0 + golang.org/x/image v0.31.0 +) + +require ( + github.com/AlekSi/pointer v1.0.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/olebedev/when v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.29.0 // indirect + golang.org/x/time v0.5.0 // indirect +) + +replace github.com/TelegramExchange/pkg => ../pkg + +replace github.com/NicoNex/echotron/v3 => ../shared/echotron diff --git a/tg_bot/go.sum b/tg_bot/go.sum new file mode 100644 index 0000000..d400350 --- /dev/null +++ b/tg_bot/go.sum @@ -0,0 +1,31 @@ +github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE= +github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8= +github.com/NicoNex/echotron/v3 v3.43.0 h1:efE2spw3mfU0Ev20m0PqqvgMSm0xHzgSxlWAEmC9RC4= +github.com/NicoNex/echotron/v3 v3.43.0/go.mod h1:7LvjveJmezuUOeaoA3nzQduNlSPQYfq219Z+baKY04Q= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/olebedev/when v1.1.0 h1:dlpoRa7huImhNtEx4yl0WYfTHVEWmJmIWd7fEkTHayc= +github.com/olebedev/when v1.1.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= +golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/tg_bot/main.go b/tg_bot/main.go new file mode 100644 index 0000000..dca5d9c --- /dev/null +++ b/tg_bot/main.go @@ -0,0 +1,152 @@ +package main + +import ( + "os" + "regexp" + "strings" + "time" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/TelegramExchange/tgex-backend/tg_bot/screens" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +var botCommands = []echotron.BotCommand{ + {Command: "start", Description: "Главное меню"}, + {Command: "projects", Description: "Мои проекты"}, + {Command: "placements", Description: "Размещения"}, + {Command: "platform", Description: "Веб-платформа"}, + {Command: "help", Description: "Помощь"}, +} + +func main() { + initLogger() + + botToken := mustEnv("TELEGRAM__TOKEN") + + backendClient := backend.New(backend.Config{ + BaseURL: mustEnv("BACKEND__BASE_URL"), + LoginURL: mustEnv("LOGIN_URL"), + }) + + // Регистрируем команды бота в Telegram + api := echotron.NewAPI(botToken) + if me, err := api.GetMe(); err != nil { + log.Error().Err(err).Msg("Failed to get bot profile") + } else if me.Result != nil { + bot.SetUsername(me.Result.Username) + log.Info().Str("username", me.Result.Username).Msg("Telegram bot authorized") + } + + if _, err := api.SetMyCommands(nil, botCommands...); err != nil { + log.Error().Err(err).Msg("Failed to set bot commands") + } else { + log.Info().Msg("Bot commands registered successfully") + } + + var commandRouter = func(command string) bot.State { + createCreativeRe := regexp.MustCompile(`^/start project_(.+)_createcreative$`) + + switch { + case command == "/start": + return &screens.MainMenu{} + case command == "/start login": + return &screens.Login{} + case command == "/projects": + return &screens.MyProjects{BackState: &screens.MainMenu{}} + case command == "/placements": + return &screens.MyProjects{BackState: &screens.MainMenu{}, OpenPlacements: true} + case command == "/platform": + return &screens.PlatformLink{} + case command == "/help": + return &screens.Help{} + case createCreativeRe.MatchString(command): + projectID := createCreativeRe.FindStringSubmatch(command)[1] + return &screens.AddCreativeStart{Ctx: &screens.AddCreativeCtx{ + ProjectID: projectID, + BackState: &screens.MainMenu{}, + }} + } + panic("Unknown command: " + command) + } + + var handleGlobalCallback = func(callbackData string) bot.State { + id, ok := strings.CutPrefix(callbackData, "pending_channel:") + if ok { + return &screens.SelectWorkspace{ChannelID: id, BackState: &screens.MainMenu{}} + } + + id, ok = strings.CutPrefix(callbackData, "workspace_invite_accept:") + if ok { + return &screens.AcceptWorkspaceInvite{InviteID: id} + } + + return nil + } + + newBot := func(chatID int64) echotron.Bot { + return bot.NewBot(chatID, botToken, commandRouter, backendClient, handleGlobalCallback) + } + + dsp := echotron.NewDispatcher(botToken, newBot) + + updateOpts := echotron.UpdateOptions{ + AllowedUpdates: []echotron.UpdateType{ + echotron.MessageUpdate, + echotron.CallbackQueryUpdate, + echotron.MyChatMemberUpdate, + echotron.ChatMemberUpdate, + echotron.UpdateType("chat_join_request"), + }, + } + + echotron.SetChatRequestLimit(0, 0) + + for { + err := dsp.PollOptions(false, updateOpts) + if err != nil { + log.Error().Err(err).Msg("dsp.Poll failed, retrying in 5 seconds...") + time.Sleep(5 * time.Second) + continue + } + break + } +} + +func initLogger() { + zerolog.TimeFieldFormat = time.RFC3339 + + level := zerolog.InfoLevel + if parsedLevel, err := zerolog.ParseLevel(os.Getenv("LOGGER__LEVEL")); err == nil { + level = parsedLevel + } + zerolog.SetGlobalLevel(level) + + log.Logger = zerolog.New(os.Stdout).With().Timestamp().Logger().Level(level) + + prettyConsole := os.Getenv("LOGGER__PRETTY_CONSOLE") + if prettyConsole == "" { + prettyConsole = "true" + } + if prettyConsole == "true" { + log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"}). + With(). + Timestamp(). + Logger(). + Level(level) + } + + log.Info().Msg("Logger initialized") +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatal().Str("env", key).Msg("missing required environment variable") + } + + return v +} diff --git a/tg_bot/screens/accept_workspace_invite.go b/tg_bot/screens/accept_workspace_invite.go new file mode 100644 index 0000000..1c70009 --- /dev/null +++ b/tg_bot/screens/accept_workspace_invite.go @@ -0,0 +1,45 @@ +package screens + +import ( + "context" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" +) + +type AcceptWorkspaceInvite struct { + InviteID string +} + +const msgAcceptWorkspaceInviteSuccessfully = ` +✅ Вы присоединились к рабочему пространству! +Администратор сможет выдать вам необходимые права в веб-интерфейсе. +` + +func (s *AcceptWorkspaceInvite) Enter(b *bot.Bot, mode bot.RenderMode) { + jwt := b.Session.JWT + if jwt == "" { + b.SendNew("❌ Ошибка авторизации\n\nПопробуйте /start", emptyKeyboard) + return + } + + err := b.Backend.AcceptWorkspaceInvite(context.Background(), jwt, s.InviteID) + if err != nil { + b.SendNew("❌ Не удалось принять приглашение. Возможно, оно уже было использовано.", emptyKeyboard) + return + } + + kb := Keyboard(Row(Button("Главное меню", "main_menu"))) + + b.SendNew(msgAcceptWorkspaceInviteSuccessfully, kb) +} + +func (s *AcceptWorkspaceInvite) HandleCallback(b *bot.Bot, u *echotron.Update) {} + +func (s *AcceptWorkspaceInvite) HandleMessage(_ *bot.Bot, _ *echotron.Update) {} + +func (s *AcceptWorkspaceInvite) Handle(b *bot.Bot, _ *echotron.Update) { + b.SetState(&MainMenu{}, bot.EditMessage) +} + +func (s *AcceptWorkspaceInvite) Exit() {} diff --git a/tg_bot/screens/add_creative.go b/tg_bot/screens/add_creative.go new file mode 100644 index 0000000..802337a --- /dev/null +++ b/tg_bot/screens/add_creative.go @@ -0,0 +1,489 @@ +package screens + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" + "github.com/rs/zerolog/log" +) + +type AddCreativeCtx struct { + CreativeEditorFields + + ProjectID string + BackState bot.State + WaitMessageSent bool +} + +type AddCreativeStart struct{ Ctx *AddCreativeCtx } + +type AddCreativeEdit struct{ ctx *AddCreativeCtx } + +type AddCreativeInput struct{ ctx *AddCreativeCtx } + +const msgWaitCreative = ` ++ Добавить креатив + +📄 Пришлите креатив который хотите добавить + +• Обрабатываем любое сообщение Telegram +• Отправляйте сразу оформленный пост +• На следующем шаге можно добавить кнопки или изменить контент + +` +const msgConfirmCreativeFormat = ` +➕ Добавляем этот креатив? + +Название: %s + +Чтобы изменить название, отправь мне сообщение 👇: + +` +const msgDownloadMediaError = ` +❌ Не удалось загрузить медиа + +Попробуйте удалить медиа и добавить заново. + +` +const msgInviteLinkRequired = ` +❌ Ошибка валидации + +Текст должен содержать хотя бы одну инвайт-ссылку вашего канала! + +Формат ссылки: +https://t.me/+xxx + +Пример правильного текста: +Присоединяйтесь к нашему каналу! +https://t.me/+AbCdEfGhIjKlMn + +Нажмите "✎ Текст" ниже чтобы исправить. + +` +const msgInviteLinkTooMany = ` +❌ Слишком много ссылок + +Текст должен содержать ТОЛЬКО ОДНУ инвайт-ссылку. +У вас их несколько. Удалите лишние. + +Нажмите "✎ Текст" ниже чтобы исправить. + +` +const msgCreateError = ` +❌ Ошибка создания + +Не удалось создать креатив. +Попробуйте ещё раз или вернитесь назад. + +` +const msgCreativeCreated = ` +✅ Креатив успешно создан! + +📄 Название: %s + +` +const msgCreativeCreatedButtons = ` +🔘 Кнопок добавлено: %d + +` + +func filterCreateButtons(rows [][]echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton { + filtered := make([][]echotron.InlineKeyboardButton, 0, len(rows)) + for _, row := range rows { + skip := false + for _, btn := range row { + switch btn.CallbackData { + case "edit_text", "add_media", "delete_media": + skip = true + } + } + if !skip { + filtered = append(filtered, row) + } + } + return filtered +} + +func (s *AddCreativeStart) Enter(b *bot.Bot, mode bot.RenderMode) { + if s.Ctx.WaitMessageSent { + return + } + + kb := Keyboard(Row(Button("« Отмена", "cancel"))) + b.Render(msgWaitCreative, kb, mode) + s.Ctx.WaitMessageSent = true +} + +func (s *AddCreativeStart) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + if u.CallbackQuery.Data == "cancel" && s.Ctx.BackState != nil { + b.SetState(s.Ctx.BackState, bot.EditMessage) + } +} + +func (s *AddCreativeStart) HandleMessage(b *bot.Bot, u *echotron.Update) { + if u.Message == nil { + return + } + + s.Ctx.extractCreativeFromMessage(u.Message) + if s.Ctx.Name == nil { + name := s.Ctx.generateCreativeName() + s.Ctx.Name = &name + } + + if u.Message.MediaGroupID != "" { + groupID := u.Message.MediaGroupID + s.Ctx.scheduleMediaGroupAction(groupID, func() { + b.SetState(&AddCreativeEdit{ctx: s.Ctx}, bot.NewMessage) + }) + b.MarkHandled() + return + } + + b.SetState(&AddCreativeEdit{ctx: s.Ctx}, bot.NewMessage) +} + +func (s *AddCreativeStart) Handle(_ *bot.Bot, _ *echotron.Update) {} + +func (s *AddCreativeStart) Exit() {} + +func (s *AddCreativeEdit) Enter(b *bot.Bot, _ bot.RenderMode) { + s.ctx.showCreativeConfirmation(b) +} + +func (s *AddCreativeEdit) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "cancel": + if s.ctx.BackState != nil { + b.SetState(s.ctx.BackState, bot.EditMessage) + } + case data == "edit_text": + s.ctx.InputMode = inputModeText + b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) + case data == "add_button": + s.ctx.InputMode = inputModeButtonText + s.ctx.PendingButtonType = "invite" + b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) + case data == "add_media": + s.ctx.InputMode = inputModeMedia + b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) + case data == "delete_media": + s.ctx.MediaItems = nil + s.ctx.MediaChanged = true + s.ctx.UpdateCreativePreview(b) + s.Enter(b, bot.EditMessage) + case data == "edit_tag": + s.ctx.ShowTagPanel(b) + case data == "tag_testing": + tag := "testing" + s.ctx.Tag = &tag + s.Enter(b, bot.EditMessage) + case data == "tag_production": + tag := "production" + s.ctx.Tag = &tag + s.Enter(b, bot.EditMessage) + case data == "cancel_edit": + s.Enter(b, bot.EditMessage) + case data == "confirm_create": + s.ctx.createCreative(b) + case strings.HasPrefix(data, "delete_button:"): + indexStr := strings.TrimPrefix(data, "delete_button:") + index, err := strconv.Atoi(indexStr) + if err != nil || index < 0 || index >= len(s.ctx.Buttons) { + return + } + s.ctx.Buttons = append(s.ctx.Buttons[:index], s.ctx.Buttons[index+1:]...) + s.ctx.UpdateCreativePreview(b) + s.Enter(b, bot.EditMessage) + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *AddCreativeEdit) HandleMessage(b *bot.Bot, u *echotron.Update) { + if u.Message == nil { + return + } + + if u.Message.Text == "" { + if s.ctx.SetMediaFromMessage(u.Message) { + if u.Message.MediaGroupID != "" { + groupID := u.Message.MediaGroupID + s.ctx.scheduleMediaGroupAction(groupID, func() { + s.ctx.UpdateCreativePreview(b) + s.Enter(b, bot.EditMessage) + }) + b.MarkHandled() + return + } + s.ctx.UpdateCreativePreview(b) + s.Enter(b, bot.EditMessage) + } + return + } + + newName := u.Message.Text + runes := []rune(newName) + if len(runes) > 200 { + newName = string(runes[:200]) + } + + s.ctx.Name = &newName + s.ctx.DeleteUserMessage(b, u.Message.ID) + s.Enter(b, bot.EditMessage) +} + +func (s *AddCreativeEdit) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *AddCreativeEdit) Exit() {} + +func (s *AddCreativeInput) Enter(b *bot.Bot, _ bot.RenderMode) { + switch s.ctx.InputMode { + case inputModeText: + s.ctx.ShowTextEditPanel(b) + case inputModeButtonText: + s.ctx.ShowAddButtonPanel(b) + case inputModeButtonURL: + s.ctx.ShowButtonURLPanel(b) + case inputModeMedia: + s.ctx.ShowMediaPanel(b) + } +} + +func (s *AddCreativeInput) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + switch u.CallbackQuery.Data { + case "button_type_invite": + s.ctx.PendingButtonType = "invite" + s.ctx.ShowAddButtonPanel(b) + case "button_type_custom": + s.ctx.PendingButtonType = "custom" + s.ctx.ShowAddButtonPanel(b) + case "cancel_add_button": + s.ctx.CancelPendingButton(b) + b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) + case "cancel_edit", "cancel_media": + s.ctx.ClearInputMode() + b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) + } +} + +func (s *AddCreativeInput) HandleMessage(b *bot.Bot, u *echotron.Update) { + if u.Message == nil { + return + } + + switch s.ctx.InputMode { + case inputModeText: + if u.Message.Text == "" { + return + } + text := ui.FormatMessageHTML(u.Message) + s.ctx.Text = &text + s.ctx.DeleteUserMessage(b, u.Message.ID) + s.ctx.ClearInputMode() + b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) + case inputModeButtonText: + if u.Message.Text == "" { + return + } + buttonText := u.Message.Text + s.ctx.DeleteUserMessage(b, u.Message.ID) + + if s.ctx.PendingButtonType == "custom" { + s.ctx.AddCustomButtonPlaceholder(buttonText) + s.ctx.UpdateCreativePreview(b) + s.ctx.InputMode = inputModeButtonURL + b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) + return + } + + s.ctx.AddInviteButton(buttonText) + s.ctx.UpdateCreativePreview(b) + s.ctx.PendingButtonType = "" + s.ctx.ClearInputMode() + b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) + case inputModeButtonURL: + if u.Message.Text == "" { + return + } + url := strings.TrimSpace(u.Message.Text) + s.ctx.DeleteUserMessage(b, u.Message.ID) + + if !s.ctx.IsValidButtonURL(url, true) { + s.ctx.ShowInvalidButtonURLPanel(b) + return + } + + s.ctx.UpdateLastButtonURL(url) + s.ctx.UpdateCreativePreview(b) + s.ctx.PendingButtonType = "" + s.ctx.ClearInputMode() + b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) + case inputModeMedia: + if !s.ctx.SetMediaFromMessage(u.Message) { + return + } + if u.Message.MediaGroupID != "" { + groupID := u.Message.MediaGroupID + s.ctx.scheduleMediaGroupAction(groupID, func() { + s.ctx.UpdateCreativePreview(b) + s.ctx.ShowMediaPanel(b) + b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) + }) + b.MarkHandled() + return + } + s.ctx.UpdateCreativePreview(b) + s.ctx.ShowMediaPanel(b) + b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) + } +} + +func (s *AddCreativeInput) Handle(_ *bot.Bot, _ *echotron.Update) {} + +func (s *AddCreativeInput) Exit() {} + +func (s *AddCreativeCtx) extractCreativeFromMessage(message *echotron.Message) { + if message.Text != "" || message.Caption != "" { + text := ui.FormatMessageHTML(message) + if text != "" { + s.Text = &text + } + } + + s.SetMediaFromMessage(message) + + if message.ReplyMarkup != nil && len(message.ReplyMarkup.InlineKeyboard) > 0 { + for _, row := range message.ReplyMarkup.InlineKeyboard { + for _, button := range row { + if button.URL != "" { + s.Buttons = append(s.Buttons, InlineButton{ + Text: button.Text, + URL: button.URL, + }) + } + } + } + } +} + +func (s *AddCreativeCtx) generateCreativeName() string { + if s.Text != nil && *s.Text != "" { + re := regexp.MustCompile(`<[^>]*>`) + cleanText := re.ReplaceAllString(*s.Text, "") + + runes := []rune(strings.TrimSpace(cleanText)) + if len(runes) > 10 { + return string(runes[:10]) + "..." + } + if len(runes) > 0 { + return string(runes) + } + } + + return "Новый креатив" +} + +func (s *AddCreativeCtx) showCreativeConfirmation(b *bot.Bot) { + if s.CreativeMessageID == nil { + s.SendCreativePreview(b) + } else { + s.UpdateCreativePreview(b) + } + + escapedName := ui.EscapeHTML(*s.Name) + confirmText := fmt.Sprintf(msgConfirmCreativeFormat, escapedName) + buttons := filterCreateButtons(s.BuildEditorButtons("✓ Сохранить", "confirm_create", "Отмена", "cancel")) + s.ShowControlPanel(b, confirmText, buttons) +} + +func (s *AddCreativeCtx) createCreative(b *bot.Bot) { + var err error + var mediaItems []backend.CreativeMediaInput + if len(s.MediaItems) > 0 { + mediaItems, err = s.BuildMediaInputs(b) + if err != nil { + log.Error().Err(err).Msg("Failed to download creative media") + buttons := filterCreateButtons(s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel")) + s.ShowControlPanel(b, msgDownloadMediaError, buttons) + return + } + } + + buttons := make([]backend.CreativeButton, 0, len(s.Buttons)) + for _, button := range s.Buttons { + buttons = append(buttons, backend.CreativeButton{ + Text: button.Text, + URL: button.URL, + }) + } + + log.Info().Str("text", *s.Text).Msg("Creative text") + + tag := "testing" + if s.Tag != nil { + tag = *s.Tag + } + + input := backend.CreateCreativeInput{ + Name: *s.Name, + Text: *s.Text, + Buttons: buttons, + Tag: &tag, + } + if len(mediaItems) > 0 { + input.MediaItems = mediaItems + } + + creative, err := b.Backend.CreateCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID, input) + + if err != nil { + log.Error().Err(err).Msg("Failed to create creative") + + errMsg := err.Error() + var userMsg string + + if strings.Contains(errMsg, "Creative text must contain one invite link") { + userMsg = msgInviteLinkRequired + } else if strings.Contains(errMsg, "Creative text must contain only one invite link") { + userMsg = msgInviteLinkTooMany + } else { + userMsg = msgCreateError + } + + buttons := filterCreateButtons(s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel")) + s.ShowControlPanel(b, userMsg, buttons) + return + } + + successText := fmt.Sprintf(msgCreativeCreated, creative.Name) + if len(s.Buttons) > 0 { + successText += fmt.Sprintf(msgCreativeCreatedButtons, len(s.Buttons)) + } + + b.SendNew(successText, Keyboard()) + + if s.BackState != nil { + b.SetState(s.BackState, bot.NewMessage) + } +} diff --git a/tg_bot/screens/add_project.go b/tg_bot/screens/add_project.go new file mode 100644 index 0000000..e984410 --- /dev/null +++ b/tg_bot/screens/add_project.go @@ -0,0 +1,59 @@ +package screens + +import ( + "fmt" + "strings" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" +) + +type AddProject struct { + BackState bot.State +} + +const addProjectInstructionText = `Как добавить канал в проект + +Назначьте @%s администратором канала. + +
Необходимые права: + +➔ Управление сообщениями +➔ Добавление участников + +После добавления бота: + +• Если у вас 1 рабочее пространство — канал добавится автоматически +• Если у вас несколько рабочих пространств — вы получите уведомление с кнопкой выбора
` + +func (s *AddProject) Enter(b *bot.Bot, mode bot.RenderMode) { + botUsername := strings.TrimPrefix(bot.Username(), "@") + addBotURL := fmt.Sprintf("https://t.me/%s?startchannel&admin=invite_users+post_messages+edit_messages+delete_messages", botUsername) + + buttons := [][]echotron.InlineKeyboardButton{ + Row(Stylish(URLButton("Назначить администратором", addBotURL), echotron.DangerButtonStyle)), + Row(Button("← Назад", "back")), + } + + keyboard := Keyboard(buttons...) + b.Render(fmt.Sprintf(addProjectInstructionText, botUsername), keyboard, mode) +} + +func (s *AddProject) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + switch u.CallbackQuery.Data { + case "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + } +} + +func (s *AddProject) HandleMessage(_ *bot.Bot, _ *echotron.Update) {} + +func (s *AddProject) Handle(_ *bot.Bot, _ *echotron.Update) {} + +func (s *AddProject) Exit() {} diff --git a/tg_bot/screens/add_purchase.go b/tg_bot/screens/add_purchase.go new file mode 100644 index 0000000..a0f3720 --- /dev/null +++ b/tg_bot/screens/add_purchase.go @@ -0,0 +1,325 @@ +package screens + +import ( + "context" + "fmt" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" + "github.com/rs/zerolog/log" +) + +const addPurchaseCreativesPerPage = 5 +const addPurchaseProjectsPerPage = 6 + +type AddPurchase struct { + ProjectID string + ProjectTitle string + ProjectTelegramID int64 + ProjectUsername string + ProjectStatus string + ProjectDefaultLinkType string + CreativeID string + CreativeTitle string + ProjectPage int + CreativePage int + ActivePicker string // "" | "project_list" | "creative_list" + BackState bot.State + lastProjects map[string]projectInfo + lastCreatives map[string]creativeInfo +} + +type projectInfo struct { + Title string + TelegramID int64 + Username string + Status string + DefaultInviteType string +} + +type creativeInfo struct { + Title string +} + +func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) { + s.renderSelection(b, mode) +} + +func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode) { + text := "Создание закупа\n\n" + text += "Выберите проект и креатив.\n\n" + + var buttons [][]echotron.InlineKeyboardButton + + projectLabel := "Проект: не выбрано" + if s.ProjectTitle != "" { + projectLabel = fmt.Sprintf("Проект: %s", s.ProjectTitle) + } + creativeLabel := "Креатив: не выбрано" + if s.CreativeTitle != "" { + creativeLabel = fmt.Sprintf("Креатив: %s", s.CreativeTitle) + } + + if s.ActivePicker == "" { + buttons = append(buttons, Row( + Button(projectLabel, "pick_project"), + )) + buttons = append(buttons, Row( + Button(creativeLabel, "pick_creative"), + )) + } + + switch s.ActivePicker { + case "project_list": + page, err := b.Backend.GetProjects(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectPage+1, addPurchaseProjectsPerPage) + if err != nil { + log.Error().Err(err).Msg("Failed to get projects") + b.SendNew("❌ Не удалось загрузить проекты", Keyboard()) + return + } + + s.lastProjects = make(map[string]projectInfo) + for _, project := range page.Items { + username := "" + if project.Username != nil { + username = *project.Username + } + s.lastProjects[project.ID] = projectInfo{ + Title: project.Title, + TelegramID: project.TelegramID, + Username: username, + Status: project.Status, + DefaultInviteType: project.PurchaseInviteTypeDefault, + } + } + + if len(page.Items) == 0 { + text += "У вас нет проектов. Создайте проект и попробуйте снова.\n\n" + } else { + text += fmt.Sprintf("Проекты%s\n\n", ui.FormatPageInfo(s.ProjectPage, page.Pages)) + + buttons = append(buttons, ui.BuildGrid( + page.Items, + 2, + addPurchaseProjectsPerPage, + page.Pages, + func(project backend.Project) (string, string) { + return project.Title, "project:" + project.ID + }, + )...) + + if navRow := ui.BuildNavigationRow(ui.PaginationConfig{ + CurrentPage: s.ProjectPage, + TotalPages: page.Pages, + }); navRow != nil { + buttons = append(buttons, navRow) + } + } + + buttons = append(buttons, Row( + Button("← Назад", "back_to_selection"), + )) + + case "creative_list": + if s.ProjectID == "" { + text += "Сначала выберите проект, чтобы увидеть список креативов.\n\n" + buttons = append(buttons, Row( + Button("← Назад", "back_to_selection"), + )) + break + } + + page, err := b.Backend.GetCreatives(context.Background(), b.Session.JWT, b.Session.WorkspaceID, &s.ProjectID, false, s.CreativePage+1, addPurchaseCreativesPerPage) + if err != nil { + log.Error().Err(err).Msg("Failed to get creatives") + b.SendNew("❌ Не удалось загрузить креативы", Keyboard()) + return + } + + s.lastCreatives = make(map[string]creativeInfo) + for _, creative := range page.Items { + s.lastCreatives[creative.ID] = creativeInfo{ + Title: creative.Name, + } + } + + if len(page.Items) == 0 { + text += `Для создания закупа сначала нужно создать креатив. + +Вернитесь назад и создайте креатив в разделе Креативы` + + buttons = append(buttons, Row( + Button("🎨 Перейти к креативам", "go_to_creatives"), + )) + } else { + text += fmt.Sprintf("Креативы%s\n\n", ui.FormatPageInfo(s.CreativePage, page.Pages)) + + buttons = append(buttons, ui.BuildGrid( + page.Items, + 2, + addPurchaseCreativesPerPage, + page.Pages, + func(creative backend.Creative) (string, string) { + return creative.Name, "creative:" + creative.ID + }, + )...) + + if navRow := ui.BuildNavigationRow(ui.PaginationConfig{ + CurrentPage: s.CreativePage, + TotalPages: page.Pages, + }); navRow != nil { + buttons = append(buttons, navRow) + } + } + + buttons = append(buttons, Row( + Button("← Назад", "back_to_selection"), + )) + } + + if s.ActivePicker == "" { + if s.ProjectID != "" && s.CreativeID != "" { + buttons = append(buttons, Row( + Button("← Отмена", "back"), + Button("→ Далее", "next"), + )) + } else { + buttons = append(buttons, Row( + Button("← Отмена", "back"), + )) + } + } + + keyboard := Keyboard(buttons...) + b.Render(text, keyboard, mode) + + if s.ActivePicker == "" && s.ProjectTelegramID != 0 { + messageID := b.LastMessageID + updateProjectHeaderMedia(b, messageID, text, keyboard, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus) + } +} + +func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch data { + case "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + + case "pick_project": + s.ActivePicker = "project_list" + s.Enter(b, bot.EditMessage) + + case "pick_creative": + s.ActivePicker = "creative_list" + s.Enter(b, bot.EditMessage) + + case "back_to_selection": + s.ActivePicker = "" + s.Enter(b, bot.EditMessage) + + case "go_to_creatives": + b.SetState(&Creatives{ + CurrentPage: 0, + ProjectID: s.ProjectID, + ProjectTitle: s.ProjectTitle, + ProjectTelegramID: s.ProjectTelegramID, + ProjectUsername: s.ProjectUsername, + ProjectStatus: s.ProjectStatus, + BackState: s, + }, bot.EditMessage) + + case "prev": + if s.ActivePicker == "project_list" { + if s.ProjectPage > 0 { + s.ProjectPage-- + } + s.Enter(b, bot.EditMessage) + return + } + if s.ActivePicker == "creative_list" { + if s.CreativePage > 0 { + s.CreativePage-- + } + s.Enter(b, bot.EditMessage) + return + } + + case "next": + if s.ActivePicker == "project_list" { + s.ProjectPage++ + s.Enter(b, bot.EditMessage) + return + } + if s.ActivePicker == "creative_list" { + s.CreativePage++ + s.Enter(b, bot.EditMessage) + return + } + if s.ProjectID != "" && s.CreativeID != "" { + b.SetState(&SelectChannelsForPurchase{ + ProjectID: s.ProjectID, + ProjectTitle: s.ProjectTitle, + ProjectTelegramID: s.ProjectTelegramID, + ProjectUsername: s.ProjectUsername, + ProjectStatus: s.ProjectStatus, + ProjectDefaultLinkType: s.ProjectDefaultLinkType, + CreativeID: s.CreativeID, + CreativeTitle: s.CreativeTitle, + Channels: []PurchaseChannelInput{}, + Duplicates: []string{}, + ParsingErrors: []ParseError{}, + BackState: s.BackState, + }, bot.EditMessage) + } + + default: + if len(data) > 8 && data[:8] == "project:" { + projectID := data[8:] + if s.lastProjects != nil { + if info, ok := s.lastProjects[projectID]; ok { + s.ProjectTitle = info.Title + s.ProjectTelegramID = info.TelegramID + s.ProjectUsername = info.Username + s.ProjectStatus = info.Status + s.ProjectDefaultLinkType = info.DefaultInviteType + } + } + s.ProjectID = projectID + s.CreativeID = "" + s.CreativeTitle = "" + s.CreativePage = 0 + s.ActivePicker = "" + s.Enter(b, bot.EditMessage) + return + } + if len(data) > 9 && data[:9] == "creative:" { + creativeID := data[9:] + if s.lastCreatives != nil { + if info, ok := s.lastCreatives[creativeID]; ok { + s.CreativeTitle = info.Title + } + } + s.CreativeID = creativeID + s.ActivePicker = "" + s.Enter(b, bot.EditMessage) + return + } else { + s.Enter(b, bot.NewMessage) + } + } +} + +func (s *AddPurchase) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *AddPurchase) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *AddPurchase) Exit() {} diff --git a/tg_bot/screens/assets/fonts/JetBrainsMono-Bold.ttf b/tg_bot/screens/assets/fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..8c93043 Binary files /dev/null and b/tg_bot/screens/assets/fonts/JetBrainsMono-Bold.ttf differ diff --git a/tg_bot/screens/assets/fonts/JetBrainsMono-Regular.ttf b/tg_bot/screens/assets/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/tg_bot/screens/assets/fonts/JetBrainsMono-Regular.ttf differ diff --git a/tg_bot/screens/channel_parser.go b/tg_bot/screens/channel_parser.go new file mode 100644 index 0000000..a76525f --- /dev/null +++ b/tg_bot/screens/channel_parser.go @@ -0,0 +1,380 @@ +package screens + +import ( + "net/url" + "regexp" + "strings" +) + +// ChannelInput представляет результат парсинга ввода пользователя +type ChannelInput struct { + Input string // оригинальный ввод пользователя + Username string // извлеченный username (если применимо) + InviteLink string // извлеченный invite link (если применимо) + Source string // источник: telegram, tgstat, telemetr + Type string // тип: username, invite, deeplink, stat + Valid bool // валидный ли формат +} + +// Парсеры для разных форматов +var ( + // Username без @: 4-32 символа, начинается с буквы + channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{3,31}$`) + + // Invite link: t.me/+xxxxx или t.me/joinchat/xxxxx + inviteLinkRe = regexp.MustCompile(`^t\.me/\+[a-zA-Z0-9_-]+$`) + inviteLinkJoinRe = regexp.MustCompile(`^t\.me/joinchat/[a-zA-Z0-9_-]+$`) + + // TGStat: tgstat.ru/channel_name или tgstat.ru/channel/@username + tgstatRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?tgstat\.ru/(?:channel/)?@?([A-Za-z][A-Za-z0-9_.]{3,31})`) + + // Telemetr: telemetr.io/channel/username или telemetr.io/channel/@username + telemetrRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?telemetr\.io/(?:channel|channels)/@?([A-Za-z][A-Za-z0-9_.]{3,31})`) +) + +// ParseChannelInput парсит ввод пользователя и определяет тип канала +func ParseChannelInput(input string) ChannelInput { + input = strings.TrimSpace(input) + if input == "" { + return ChannelInput{Valid: false} + } + + result := ChannelInput{Input: input} + + // 1. Проверяем Telegram deeplink (tg://...) + if strings.HasPrefix(input, "tg://") { + return parseTelegramDeeplink(input) + } + + // 2. Проверяем invite ссылки (t.me/+xxx или t.me/joinchat/xxx) + if inviteLinkRe.MatchString(input) || inviteLinkJoinRe.MatchString(input) { + result.Type = "invite" + result.Source = "telegram" + result.InviteLink = normalizeInviteLink(input) + result.Valid = true + return result + } + + // 3. Проверяем URL форматы (http://, https://, t.me/, telegram.me/) + if isURLFormat(input) { + return parseURLFormat(input) + } + + // 4. Проверяем @username + if strings.HasPrefix(input, "@") { + username := strings.TrimPrefix(input, "@") + username = strings.TrimSpace(username) + if channelUsernameRe.MatchString(username) { + result.Username = username + result.Type = "username" + result.Source = "telegram" + result.Valid = true + return result + } + result.Valid = false + return result + } + + // 5. Проверяем username (без @) + if channelUsernameRe.MatchString(input) { + result.Username = input + result.Type = "username" + result.Source = "telegram" + result.Valid = true + return result + } + + // 6. Ничего не подошло + result.Valid = false + return result +} + +// parseTelegramDeeplink парсит tg:// ссылки +func parseTelegramDeeplink(input string) ChannelInput { + result := ChannelInput{Input: input, Source: "telegram", Type: "deeplink"} + + // tg://resolve?domain=username&post=123 + if strings.HasPrefix(input, "tg://resolve?domain=") { + parsed, err := url.Parse(input) + if err != nil { + result.Valid = false + return result + } + query := parsed.Query() + domain := query.Get("domain") + if domain != "" && channelUsernameRe.MatchString(domain) { + result.Username = domain + result.Type = "username" + result.Valid = true + return result + } + } + + // tg://join?invite=abcdef + if strings.HasPrefix(input, "tg://join?invite=") { + parsed, err := url.Parse(input) + if err != nil { + result.Valid = false + return result + } + query := parsed.Query() + invite := query.Get("invite") + if invite != "" { + result.InviteLink = "t.me/+" + invite + result.Type = "invite" + result.Valid = true + return result + } + } + + result.Valid = false + return result +} + +// isURLFormat проверяет, является ли ввод URL или ссылкой +func isURLFormat(input string) bool { + return strings.HasPrefix(input, "http://") || + strings.HasPrefix(input, "https://") || + strings.HasPrefix(input, "t.me/") || + strings.HasPrefix(input, "telegram.me/") || + strings.Contains(input, "t.me/") || + strings.Contains(input, "telegram.me/") +} + +// parseURLFormat парсит URL-форматы ссылок +func parseURLFormat(input string) ChannelInput { + result := ChannelInput{Input: input} + + // Нормализуем: убираем протокол + normalized := input + normalized = strings.TrimPrefix(normalized, "https://") + normalized = strings.TrimPrefix(normalized, "http://") + normalized = strings.TrimPrefix(normalized, "www.") + + // Проверяем TGStat + if matches := tgstatRe.FindStringSubmatch(input); len(matches) > 1 { + username := strings.TrimSuffix(matches[1], "_") + result.Username = username + result.Type = "stat" + result.Source = "tgstat" + result.Valid = true + return result + } + + // Проверяем Telemetr + if matches := telemetrRe.FindStringSubmatch(input); len(matches) > 1 { + username := strings.TrimSuffix(matches[1], "_") + result.Username = username + result.Type = "stat" + result.Source = "telemetr" + result.Valid = true + return result + } + + // Проверяем telegram.me или t.me + if strings.HasPrefix(normalized, "t.me/") || strings.HasPrefix(normalized, "telegram.me/") { + return parseTelegramLink(normalized) + } + + result.Valid = false + return result +} + +// parseTelegramLink парсит ссылки t.me/... и telegram.me/... +func parseTelegramLink(link string) ChannelInput { + result := ChannelInput{Source: "telegram"} + + // Убираем t.me/ или telegram.me/ + path := link + if strings.HasPrefix(path, "telegram.me/") { + path = strings.TrimPrefix(path, "telegram.me/") + } else { + path = strings.TrimPrefix(path, "t.me/") + } + + // Проверяем invite link: +xxxxx или joinchat/xxxxx + if strings.HasPrefix(path, "+") { + inviteHash := strings.TrimPrefix(path, "+") + inviteHash = extractBeforeChar(inviteHash, '?', '/') + if inviteHash != "" { + result.InviteLink = "t.me/+" + inviteHash + result.Type = "invite" + result.Valid = true + return result + } + } + + if strings.HasPrefix(path, "joinchat/") { + inviteHash := strings.TrimPrefix(path, "joinchat/") + inviteHash = extractBeforeChar(inviteHash, '?', '/') + if inviteHash != "" { + result.InviteLink = "t.me/joinchat/" + inviteHash + result.Type = "invite" + result.Valid = true + return result + } + } + + // Извлекаем username из t.me/username или t.me/username/12345 + parts := strings.SplitN(path, "/", 2) + if len(parts) > 0 { + username := parts[0] + // Убираем @ если есть + username = strings.TrimPrefix(username, "@") + + // Валидируем username + if channelUsernameRe.MatchString(username) { + result.Username = username + result.Type = "username" + result.Valid = true + return result + } + } + + result.Valid = false + return result +} + +// normalizeInviteLink нормализует invite ссылку +func normalizeInviteLink(link string) string { + link = strings.TrimSpace(link) + + // Добавляем протокол если нужно + if !strings.HasPrefix(link, "http://") && !strings.HasPrefix(link, "https://") && !strings.HasPrefix(link, "t.me/") { + if strings.HasPrefix(link, "t.me/") { + // Уже в нужном формате + return link + } + } + + // Нормализуем t.me/+xxxxx + if strings.HasPrefix(link, "t.me/+") { + return link + } + + // Нормализуем t.me/joinchat/xxxxx + if strings.HasPrefix(link, "t.me/joinchat/") { + return link + } + + return link +} + +// extractBeforeChar извлекает часть строки до первого встреченного символа +func extractBeforeChar(s string, chars ...rune) string { + for _, c := range chars { + if idx := strings.IndexRune(s, c); idx != -1 { + return s[:idx] + } + } + return s +} + +// SuggestFix предлагает исправление для некорректного ввода +func SuggestFix(input string) (string, bool) { + input = strings.TrimSpace(input) + if input == "" { + return "", false + } + + // 1. Исправляем опечатки в протоколе + if strings.HasPrefix(input, "htp://") { + fixed := strings.Replace(input, "htp://", "https://", 1) + if parsed := ParseChannelInput(fixed); parsed.Valid { + return fixed, true + } + } + if strings.HasPrefix(input, "ttps://") { + fixed := strings.Replace(input, "ttps://", "https://", 1) + if parsed := ParseChannelInput(fixed); parsed.Valid { + return fixed, true + } + } + if strings.HasPrefix(input, "ttp://") { + fixed := strings.Replace(input, "ttp://", "http://", 1) + if parsed := ParseChannelInput(fixed); parsed.Valid { + return fixed, true + } + } + + // 2. Исправляем tme/ → t.me/ + if strings.HasPrefix(input, "tme/") { + fixed := strings.Replace(input, "tme/", "t.me/", 1) + if parsed := ParseChannelInput(fixed); parsed.Valid { + return fixed, true + } + } + if strings.Contains(input, "tme/") { + fixed := strings.Replace(input, "tme/", "t.me/", 1) + if parsed := ParseChannelInput(fixed); parsed.Valid { + return fixed, true + } + } + + // 3. Исправляем @@ или @@ + if strings.HasPrefix(input, "@@") { + fixed := strings.TrimPrefix(input, "@") + if parsed := ParseChannelInput(fixed); parsed.Valid { + return fixed, true + } + } + + // 4. Пробуем добавить @ в начало username + if !strings.HasPrefix(input, "@") && !isURLFormat(input) { + fixed := "@" + input + if parsed := ParseChannelInput(fixed); parsed.Valid { + return fixed, true + } + } + + // 5. Пробуем убрать лишние символы в конце + cleaned := input + cleaned = strings.TrimSuffix(cleaned, ".") + cleaned = strings.TrimSuffix(cleaned, ",") + cleaned = strings.TrimSuffix(cleaned, ";") + cleaned = strings.TrimSpace(cleaned) + if cleaned != input { + if parsed := ParseChannelInput(cleaned); parsed.Valid { + return cleaned, true + } + } + + // 6. Пробуем извлечь username из сложной ссылки + if strings.Contains(input, "/") { + parts := strings.Split(input, "/") + for i := len(parts) - 1; i >= 0; i-- { + part := strings.TrimSpace(parts[i]) + part = strings.TrimPrefix(part, "@") + if channelUsernameRe.MatchString(part) { + return "@" + part, true + } + } + } + + return "", false +} + +// FormatChannelLabel форматирует метку канала для отображения +func FormatChannelLabel(input ChannelInput) string { + if input.Username != "" { + return "@" + input.Username + } + if input.InviteLink != "" { + return formatInviteLabel(input.InviteLink) + } + return input.Input +} + +// IsDuplicate проверяет, является ли канал дубликатом (case-insensitive для username) +func IsDuplicate(input ChannelInput, channels []PurchaseChannelInput) bool { + for _, ch := range channels { + // Case-insensitive сравнение username (@MyChannel == @mychannel) + if input.Username != "" && ch.Username != "" && strings.EqualFold(ch.Username, input.Username) { + return true + } + if input.InviteLink != "" && ch.InviteLink == input.InviteLink { + return true + } + } + return false +} diff --git a/tg_bot/screens/creative_details.go b/tg_bot/screens/creative_details.go new file mode 100644 index 0000000..cc078b0 --- /dev/null +++ b/tg_bot/screens/creative_details.go @@ -0,0 +1,384 @@ +package screens + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" + "github.com/rs/zerolog/log" +) + +type CreativeDetails struct { + CreativeEditorFields // Встраивание общих полей + + CreativeID string + ProjectID string + ProjectTitle string // Название проекта для сообщений + + BackState bot.State + + // View mode: открыты из режима просмотра + ViewMode bool // Открыты из CreativeView + ViewBackTo bot.State // Состояние для возврата (CreativeView) + + // Флаг подтверждения удаления + confirmingDelete bool +} + +func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) { + // Удаляем сообщение предыдущего экрана + if b.LastMessageID != 0 { + b.DeleteMessage(b.ChatID, b.LastMessageID) + b.LastMessageID = 0 + } + + creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID) + if err != nil { + log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative") + b.SendNew("❌ Не удалось загрузить креатив", Keyboard( + Row(Button("← Назад", "back")), + )) + return + } + + // Заполняем поля из загруженного креатива + s.Name = &creative.Name + if creative.Text != "" { + sanitizedText := ui.SanitizeHTML(creative.Text) + s.Text = &sanitizedText + log.Info().Str("creative_text", creative.Text).Msg("Set creative text from backend") + } else { + log.Info().Msg("Creative text is empty from backend") + } + s.MediaItems = nil + if len(creative.MediaItems) > 0 { + mediaItems := append([]backend.CreativeMediaItem(nil), creative.MediaItems...) + sort.Slice(mediaItems, func(i, j int) bool { + return mediaItems[i].Position < mediaItems[j].Position + }) + for _, item := range mediaItems { + s.MediaItems = append(s.MediaItems, MediaItem{ + MediaType: item.MediaType, + MediaFileID: item.MediaFileID, + }) + } + } + s.Buttons = nil + for _, btn := range creative.Buttons { + s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL}) + } + if creative.Tag != "" { + s.Tag = &creative.Tag + } + s.MediaChanged = false + s.ClearInputMode() + + log.Info(). + Str("creative_id", s.CreativeID). + Str("name", creative.Name). + Bool("has_text", creative.Text != ""). + Bool("s_text_is_nil", s.Text == nil). + Msg("Sending creative preview and panel") + + // Отправляем превью и панель управления + s.SendCreativePreview(b) + s.showManagementPanelWithDelete(b) +} + +func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + // Обрабатываем общие callbacks (edit_text, add_button, add_media, delete_button, delete_media) + switch { + case data == "cancel", data == "back": + // Удаляем сообщения текущего экрана перед переходом + s.CleanupMessages(b) + if s.ViewMode && s.ViewBackTo != nil { + b.SetState(s.ViewBackTo, bot.NewMessage) + } else if s.BackState != nil { + b.SetState(s.BackState, bot.NewMessage) + } + + case data == "edit_text": + s.InputMode = inputModeText + s.ShowTextEditPanel(b) + + case data == "add_button": + s.InputMode = inputModeButtonText + s.PendingButtonType = "invite" + s.ShowAddButtonPanel(b) + + case data == "add_media": + s.InputMode = inputModeMedia + s.ShowMediaPanel(b) + + case data == "delete_media": + s.MediaItems = nil + s.MediaChanged = true + s.UpdateCreativePreview(b) + s.showManagementPanelWithDelete(b) + + case data == "cancel_add_button": + // Отменяем добавление кнопки + s.CancelPendingButton(b) + s.showManagementPanelWithDelete(b) + + case data == "button_type_invite": + s.PendingButtonType = "invite" + s.InputMode = inputModeButtonText + s.ShowAddButtonPanel(b) + + case data == "button_type_custom": + s.PendingButtonType = "custom" + s.InputMode = inputModeButtonText + s.ShowAddButtonPanel(b) + + case data == "cancel_edit", data == "cancel_media": + s.ClearInputMode() + s.showManagementPanelWithDelete(b) + + case data == "confirm_save": + s.updateCreative(b) + + case data == "delete_creative": + if !s.confirmingDelete { + // Первое нажатие - просим подтверждение + s.confirmingDelete = true + s.showManagementPanelWithDelete(b) + } else { + // Второе нажатие - удаляем + s.deleteCreative(b) + } + + case data == "confirm_delete": + // Это только для безопасности, главная логика в delete_creative + s.deleteCreative(b) + + case strings.HasPrefix(data, "delete_button:"): + parts := strings.Split(data, ":") + if len(parts) == 2 { + var index int + if _, err := fmt.Sscanf(parts[1], "%d", &index); err == nil { + if index >= 0 && index < len(s.Buttons) { + s.Buttons = append(s.Buttons[:index], s.Buttons[index+1:]...) + s.UpdateCreativePreview(b) + s.showManagementPanelWithDelete(b) + } + } + } + + default: + // Сбрасываем флаг подтверждения удаления при любом другом действии + if s.confirmingDelete { + s.confirmingDelete = false + s.showManagementPanelWithDelete(b) + } + } +} + +func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) { + if u.Message == nil { + return + } + + switch s.InputMode { + case inputModeMedia: + if !s.SetMediaFromMessage(u.Message) { + return + } + if u.Message.MediaGroupID != "" { + groupID := u.Message.MediaGroupID + s.scheduleMediaGroupAction(groupID, func() { + s.UpdateCreativePreview(b) + s.ShowMediaPanel(b) + }) + b.MarkHandled() + return + } + s.UpdateCreativePreview(b) + s.ShowMediaPanel(b) + return + case inputModeButtonText: + if u.Message.Text == "" { + return + } + buttonText := u.Message.Text + s.DeleteUserMessage(b, u.Message.ID) + + // Создаём кнопку с placeholder URL + if s.PendingButtonType == "custom" { + s.AddCustomButtonPlaceholder(buttonText) + s.UpdateCreativePreview(b) + s.InputMode = inputModeButtonURL + s.ShowButtonURLPanel(b) + } else { + s.AddInviteButton(buttonText) + s.UpdateCreativePreview(b) + s.PendingButtonType = "" + s.ClearInputMode() + s.showManagementPanelWithDelete(b) + } + return + case inputModeButtonURL: + if u.Message.Text == "" { + return + } + url := strings.TrimSpace(u.Message.Text) + s.DeleteUserMessage(b, u.Message.ID) + + // Простая валидация URL + if !s.IsValidButtonURL(url, true) { + s.ShowInvalidButtonURLPanel(b) + return + } + + // Обновляем URL последней кнопки + s.UpdateLastButtonURL(url) + + s.ClearInputMode() + s.PendingButtonType = "" + + s.UpdateCreativePreview(b) + s.showManagementPanelWithDelete(b) + return + case inputModeText: + if u.Message.Text == "" { + return + } + text := ui.FormatMessageHTML(u.Message) + s.Text = &text + s.DeleteUserMessage(b, u.Message.ID) + + s.UpdateCreativePreview(b) + s.ClearInputMode() + s.showManagementPanelWithDelete(b) + return + } + + // Обрабатываем имя креатива (если нет других состояний) + if u.Message.Text != "" { + text := ui.FormatMessageHTML(u.Message) + s.Text = &text + s.DeleteUserMessage(b, u.Message.ID) + + s.UpdateCreativePreview(b) + s.showManagementPanelWithDelete(b) + } +} + +func (s *CreativeDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *CreativeDetails) Exit() {} + +func (s *CreativeDetails) showManagementPanelWithDelete(b *bot.Bot) { + var confirmText, confirmCallback string + + if s.confirmingDelete { + confirmText = "✖ Подтвердить удаление" + confirmCallback = "confirm_delete" + } else { + confirmText = "✓ Сохранить" + confirmCallback = "confirm_save" + } + + // Добавляем кнопку удаления креатива + deleteButton := Row(Button("⌦ Удалить креатив", "delete_creative")) + + s.ShowManagementPanel(b, confirmText, confirmCallback, "cancel", deleteButton) +} + +func (s *CreativeDetails) updateCreative(b *bot.Bot) { + // JWT уже создан в Bot.Update(), просто берем из сессии + jwt := b.Session.JWT + if jwt == "" { + log.Error().Msg("JWT is empty in session") + b.SendNew("❌ Ошибка авторизации\n\nПопробуйте /start", Keyboard()) + return + } + + var err error + + // Подготавливаем данные для обновления + input := backend.UpdateCreativeInput{ + Name: s.Name, + Text: s.Text, + Tag: s.Tag, + } + if s.MediaChanged { + if len(s.MediaItems) > 0 { + mediaItems, err := s.BuildMediaInputs(b) + if err != nil { + log.Error().Err(err).Msg("Failed to download creative media") + // Показываем ошибку через панель управления + s.ShowControlPanel(b, "❌ Не удалось загрузить медиа\n\nПопробуйте удалить медиа и добавить заново, или нажмите \"Сохранить\" без изменения медиа.", [][]echotron.InlineKeyboardButton{ + Row(Button("← Назад", "back")), + Row(Button("✓ Сохранить без медиа", "confirm_save")), + }) + return + } + input.MediaItems = &mediaItems + } else { + emptyItems := []backend.CreativeMediaInput{} + input.MediaItems = &emptyItems + } + } + + buttons := make([]backend.CreativeButton, 0, len(s.Buttons)) + for _, button := range s.Buttons { + buttons = append(buttons, backend.CreativeButton{ + Text: button.Text, + URL: button.URL, + }) + } + input.Buttons = &buttons + + // Отправляем запрос на обновление + _, err = b.Backend.UpdateCreative(context.Background(), jwt, b.Session.WorkspaceID, s.CreativeID, input) + + if err != nil { + log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative") + // Показываем ошибку через панель управления + s.ShowControlPanel(b, "❌ Не удалось сохранить изменения\n\nПроверьте подключение и попробуйте снова.", [][]echotron.InlineKeyboardButton{ + Row(Button("← Назад", "back")), + Row(Button("✓ Попробовать снова", "confirm_save")), + }) + return + } + + log.Info().Str("creative_id", s.CreativeID).Msg("Creative updated successfully") + + // Возвращаемся назад + s.CleanupMessages(b) + if s.ViewMode && s.ViewBackTo != nil { + b.SetState(s.ViewBackTo, bot.NewMessage) + } else if s.BackState != nil { + b.SetState(s.BackState, bot.NewMessage) + } +} + +func (s *CreativeDetails) deleteCreative(b *bot.Bot) { + err := b.Backend.DeleteCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID) + + if err != nil { + s.confirmingDelete = false // Сбрасываем флаг подтверждения + s.ShowControlPanel(b, "❌ Не удалось удалить креатив\n\nПроверьте подключение и попробуйте снова.", [][]echotron.InlineKeyboardButton{ + Row(Button("← Назад", "back")), + Row(Button("⌦ Попробовать снова", "delete_creative")), + }) + return + } + + s.CleanupMessages(b) + if s.BackState != nil { + b.SetState(s.BackState, bot.NewMessage) + } +} diff --git a/tg_bot/screens/creative_editor.go b/tg_bot/screens/creative_editor.go new file mode 100644 index 0000000..e620bf9 --- /dev/null +++ b/tg_bot/screens/creative_editor.go @@ -0,0 +1,777 @@ +package screens + +import ( + "fmt" + "regexp" + "strings" + "sync" + "time" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" + "github.com/rs/zerolog/log" +) + +// InlineButton представляет кнопку с URL +type InlineButton struct { + Text string + URL string +} + +type MediaItem struct { + MediaType string // "photo", "video", "animation" + MediaFileID string // File ID из Telegram +} + +// CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива +type CreativeEditorFields struct { + // Данные креатива + Name *string + Text *string + Buttons []InlineButton + Tag *string // "testing" or "production" + + // Медиа + MediaItems []MediaItem + MediaChanged bool + + // Режим ввода + InputMode string + + // Состояние для добавления кнопки + PendingButtonType string + + // ID сообщения с креативом (которое постоянно обновляется) + CreativeMessageID *int + // ID сообщения с панелью управления + ControlPanelMessageID *int + // ID сообщений дополнительного медиа (после первого) + ExtraMediaMessageIDs []int + + pendingMediaGroupMu sync.Mutex + pendingMediaGroupID string + pendingMediaGroupTimer *time.Timer + pendingMediaGroupGen int64 + + // Предыдущее состояние для определения изменения типа сообщения + previousMediaItems []MediaItem +} + +const inviteLinkPlaceholder = "{{invite_link}}" +const inviteLinkPreviewURL = "https://t.me/joinchat/nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра +const buttonURLPlaceholder = "https://example.com" + +var inviteLinkPattern = regexp.MustCompile(`https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+`) +var inviteLinkTagPattern = regexp.MustCompile(`\s*(.*?)\s*`) + +const ( + inputModeText = "text" + inputModeButtonText = "button_text" + inputModeButtonURL = "button_url" + inputModeMedia = "media" +) +const msgCreativeDefault = ` +💭 Я твой креатив + +У меня пока нет текста, добавь его ниже. + + +` +const msgCreativeManagement = ` +👆Превью креатива выше — так он будет выглядеть при отправке. + +Выберите, что изменить: + +` +const msgCreativePreviewBroken = "⚠️ Не удалось показать превью: креатив содержит некорректную разметку. Создайте его заново или отредактируйте текст." + +func buildCreativeKeyboard(buttons [][]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup { + keyboard := echotron.InlineKeyboardMarkup{ + InlineKeyboard: buttons, + } + if len(buttons) == 0 { + keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{} + } + return keyboard +} + +func copyMediaItems(items []MediaItem) []MediaItem { + if len(items) == 0 { + return nil + } + clone := make([]MediaItem, len(items)) + copy(clone, items) + return clone +} + +func mediaItemsEqual(a, b []MediaItem) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].MediaType != b[i].MediaType || a[i].MediaFileID != b[i].MediaFileID { + return false + } + } + return true +} + +func (e *CreativeEditorFields) primaryMedia() *MediaItem { + if len(e.MediaItems) == 0 { + return nil + } + return &e.MediaItems[0] +} + +func (e *CreativeEditorFields) hasMultipleMedia() bool { + return len(e.MediaItems) > 1 +} + +func (e *CreativeEditorFields) clearExtraMediaMessages(b *bot.Bot) { + for _, msgID := range e.ExtraMediaMessageIDs { + b.DeleteMessage(b.ChatID, msgID) + } + e.ExtraMediaMessageIDs = nil +} + +func (e *CreativeEditorFields) CleanupMessages(b *bot.Bot) { + if e.CreativeMessageID != nil { + b.DeleteMessage(b.ChatID, *e.CreativeMessageID) + e.CreativeMessageID = nil + } + e.clearExtraMediaMessages(b) + if e.ControlPanelMessageID != nil { + b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID) + e.ControlPanelMessageID = nil + } + b.LastMessageID = 0 +} + +const mediaGroupDebounce = 2 * time.Second + +func (e *CreativeEditorFields) scheduleMediaGroupAction(groupID string, action func()) { + e.pendingMediaGroupMu.Lock() + defer e.pendingMediaGroupMu.Unlock() + + if groupID == "" { + action() + return + } + + if e.pendingMediaGroupTimer != nil { + e.pendingMediaGroupTimer.Stop() + } + e.pendingMediaGroupID = groupID + e.pendingMediaGroupGen++ + gen := e.pendingMediaGroupGen + e.pendingMediaGroupTimer = time.AfterFunc(mediaGroupDebounce, func() { + e.pendingMediaGroupMu.Lock() + currentGroup := e.pendingMediaGroupID + currentGen := e.pendingMediaGroupGen + if currentGen == gen && currentGroup == groupID { + e.pendingMediaGroupID = "" + e.pendingMediaGroupTimer = nil + } + e.pendingMediaGroupMu.Unlock() + if currentGen == gen && currentGroup == groupID { + action() + } + }) +} + +func isTelegramParseError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "can't parse entities") || strings.Contains(msg, "Unexpected end tag") +} + +// GetCreativeText возвращает текст для превью креатива +func (e *CreativeEditorFields) GetCreativeText() string { + if e.Text == nil { + return msgCreativeDefault + } + + text := strings.TrimSpace(ui.SanitizeHTML(*e.Text)) + if text == "" { + return msgCreativeDefault + } + + // Заменяем плейсхолдеры tg-link на кликабельную preview-ссылку. + text = inviteLinkTagPattern.ReplaceAllStringFunc(text, func(match string) string { + sub := inviteLinkTagPattern.FindStringSubmatch(match) + inner := "" + if len(sub) > 1 { + inner = strings.TrimSpace(sub[1]) + } + if inner == "" { + inner = inviteLinkPreviewURL + } + return fmt.Sprintf(`%s`, inviteLinkPreviewURL, inner) + }) + + // Заменяем пригласительные ссылки на preview URL только для отображения. + text = inviteLinkPattern.ReplaceAllString(text, inviteLinkPreviewURL) + + // Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках). + text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL) + + return text +} + +// GetCreativeButtons возвращает кнопки для превью креатива +func (e *CreativeEditorFields) GetCreativeButtons() [][]echotron.InlineKeyboardButton { + var buttons [][]echotron.InlineKeyboardButton + + for _, btn := range e.Buttons { + url := btn.URL + if url == inviteLinkPlaceholder { + url = inviteLinkPreviewURL + } + buttons = append(buttons, []echotron.InlineKeyboardButton{ + { + Text: btn.Text, + URL: url, + }, + }) + } + + return buttons +} + +// SendCreativePreview отправляет превью креатива +func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) { + creativeText := e.GetCreativeText() + creativeButtons := e.GetCreativeButtons() + + // Создаем клавиатуру с пустым массивом (не nil!) + keyboard := buildCreativeKeyboard(creativeButtons) + + var msgID int + sendText := func() (int, error) { + res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{ + ReplyMarkup: keyboard, + ParseMode: echotron.HTML, + LinkPreviewOptions: echotron.LinkPreviewOptions{ + IsDisabled: true, + }, + }) + if err != nil { + return 0, err + } + if res.Result == nil { + return 0, fmt.Errorf("send message: empty result") + } + return res.Result.ID, nil + } + sendFallback := func() (int, error) { + res, err := b.SendMessage(msgCreativePreviewBroken, b.ChatID, &echotron.MessageOptions{ + ReplyMarkup: keyboard, + LinkPreviewOptions: echotron.LinkPreviewOptions{ + IsDisabled: true, + }, + }) + if err != nil { + return 0, err + } + if res.Result == nil { + return 0, fmt.Errorf("send message: empty result") + } + return res.Result.ID, nil + } + + // Если есть медиа, отправляем с медиа + if len(e.MediaItems) > 1 { + e.clearExtraMediaMessages(b) + group := make([]echotron.GroupableInputMedia, 0, len(e.MediaItems)) + for i, item := range e.MediaItems { + caption := "" + parseMode := echotron.ParseMode("") + if i == 0 { + caption = creativeText + parseMode = echotron.HTML + } + switch item.MediaType { + case "photo": + group = append(group, echotron.InputMediaPhoto{ + Type: echotron.MediaTypePhoto, + Media: echotron.NewInputFileID(item.MediaFileID), + Caption: caption, + ParseMode: parseMode, + }) + case "video": + group = append(group, echotron.InputMediaVideo{ + Type: echotron.MediaTypeVideo, + Media: echotron.NewInputFileID(item.MediaFileID), + Caption: caption, + ParseMode: parseMode, + }) + case "animation": + group = append(group, echotron.InputMediaVideo{ + Type: echotron.MediaTypeVideo, + Media: echotron.NewInputFileID(item.MediaFileID), + Caption: caption, + ParseMode: parseMode, + }) + default: + log.Warn().Str("media_type", item.MediaType).Msg("Unsupported media type for media group") + } + } + + if len(group) > 0 { + res, err := b.SendMediaGroup(b.ChatID, group, nil) + if err != nil { + log.Error().Err(err).Msg("Failed to send media group") + if id, err := sendText(); err == nil { + msgID = id + } else if isTelegramParseError(err) { + if id, err := sendFallback(); err == nil { + msgID = id + } + } + } else if len(res.Result) > 0 { + msgID = res.Result[0].ID + for i := 1; i < len(res.Result); i++ { + e.ExtraMediaMessageIDs = append(e.ExtraMediaMessageIDs, res.Result[i].ID) + } + } + } + } else if media := e.primaryMedia(); media != nil { + var res echotron.APIResponseMessage + var err error + + switch media.MediaType { + case "photo": + res, err = b.SendPhoto( + echotron.NewInputFileID(media.MediaFileID), + b.ChatID, + &echotron.PhotoOptions{ + Caption: creativeText, + ParseMode: echotron.HTML, + ReplyMarkup: keyboard, + }, + ) + case "video": + res, err = b.SendVideo( + echotron.NewInputFileID(media.MediaFileID), + b.ChatID, + &echotron.VideoOptions{ + Caption: creativeText, + ParseMode: echotron.HTML, + ReplyMarkup: keyboard, + }, + ) + case "animation": + res, err = b.SendAnimation( + echotron.NewInputFileID(media.MediaFileID), + b.ChatID, + &echotron.AnimationOptions{ + Caption: creativeText, + ParseMode: echotron.HTML, + ReplyMarkup: keyboard, + }, + ) + } + + if err != nil { + log.Error().Err(err).Str("media_type", media.MediaType).Msg("Failed to send media") + // Fallback to text message - используем прямой SendMessage + if id, err := sendText(); err == nil { + msgID = id + } else if isTelegramParseError(err) { + if id, err := sendFallback(); err == nil { + msgID = id + } + } + } else if res.Result != nil { + msgID = res.Result.ID + } + + } else { + // Отправляем обычное текстовое сообщение напрямую (не через SendNew) + // чтобы не затронуть LastMessageID и не затронуть cleanup + id, err := sendText() + if err != nil { + log.Error().Err(err).Msg("Failed to send creative preview") + if isTelegramParseError(err) { + id, err = sendFallback() + if err == nil { + msgID = id + } else { + log.Error().Err(err).Msg("Failed to send fallback creative preview") + return + } + } else { + return + } + } else { + msgID = id + } + } + + // ВАЖНО: Сохраняем копию значения, а не указатель на переменную! + e.CreativeMessageID = &msgID + e.previousMediaItems = copyMediaItems(e.MediaItems) + + // Устанавливаем LastMessageID чтобы ShowControlPanel мог корректно работать + // ВАЖНО: Это позволяет избежать конфликтов при навигации между экранами + b.LastMessageID = msgID +} + +// UpdateCreativePreview обновляет превью креатива +func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) { + if e.CreativeMessageID == nil { + log.Warn().Msg("UpdateCreativePreview: CreativeMessageID is nil") + // Если превью еще не создано, создаем его + e.SendCreativePreview(b) + return + } + + currentHasMedia := len(e.MediaItems) > 0 + previousHasMedia := len(e.previousMediaItems) > 0 + mediaItemsChanged := !mediaItemsEqual(e.MediaItems, e.previousMediaItems) + + // Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения + if currentHasMedia != previousHasMedia { + // Удаляем оба сообщения + if e.CreativeMessageID != nil { + b.DeleteMessage(b.ChatID, *e.CreativeMessageID) + e.CreativeMessageID = nil + } + e.clearExtraMediaMessages(b) + if e.ControlPanelMessageID != nil { + b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID) + e.ControlPanelMessageID = nil + } + + // Создаем превью заново (панель будет создана через ShowManagementPanel после этого) + e.SendCreativePreview(b) + return + } + + if mediaItemsChanged && (len(e.MediaItems) > 1 || len(e.previousMediaItems) > 1) { + if e.CreativeMessageID != nil { + b.DeleteMessage(b.ChatID, *e.CreativeMessageID) + e.CreativeMessageID = nil + } + e.clearExtraMediaMessages(b) + e.SendCreativePreview(b) + return + } + + // Тип не изменился - просто редактируем на месте + creativeText := e.GetCreativeText() + creativeButtons := e.GetCreativeButtons() + + keyboard := buildCreativeKeyboard(creativeButtons) + + msgID := echotron.NewMessageID(b.ChatID, *e.CreativeMessageID) + logEditError := func(msg string, err error) { + textPreview := creativeText + runes := []rune(textPreview) + if len(runes) > 200 { + textPreview = string(runes[:200]) + "..." + } + mediaType := "" + if media := e.primaryMedia(); media != nil { + mediaType = media.MediaType + } + log.Error(). + Err(err). + Int("creative_message_id", *e.CreativeMessageID). + Str("media_type", mediaType). + Int("text_len", len([]rune(creativeText))). + Str("text_preview", textPreview). + Msg(msg) + } + + // Если есть медиа - редактируем caption + updated := false + if media := e.primaryMedia(); media != nil { + var prevMedia MediaItem + if len(e.previousMediaItems) > 0 { + prevMedia = e.previousMediaItems[0] + } + mediaChanged := media.MediaFileID != prevMedia.MediaFileID || media.MediaType != prevMedia.MediaType + if mediaChanged { + var inputMedia echotron.InputMedia + switch media.MediaType { + case "photo": + inputMedia = echotron.InputMediaPhoto{ + Type: echotron.MediaTypePhoto, + Media: echotron.NewInputFileID(media.MediaFileID), + Caption: creativeText, + ParseMode: echotron.HTML, + } + case "video": + inputMedia = echotron.InputMediaVideo{ + Type: echotron.MediaTypeVideo, + Media: echotron.NewInputFileID(media.MediaFileID), + Caption: creativeText, + ParseMode: echotron.HTML, + } + case "animation": + inputMedia = echotron.InputMediaAnimation{ + Type: echotron.MediaTypeAnimation, + Media: echotron.NewInputFileID(media.MediaFileID), + Caption: creativeText, + ParseMode: echotron.HTML, + } + } + + if inputMedia != nil { + _, err := b.EditMessageMedia( + msgID, + inputMedia, + &echotron.MessageMediaOptions{ + ReplyMarkup: keyboard, + }, + ) + if err != nil { + logEditError("Failed to edit message media", err) + } else { + updated = true + } + } else { + log.Warn().Str("media_type", media.MediaType).Msg("Unsupported media type for edit") + } + } else { + _, err := b.EditMessageCaption( + msgID, + &echotron.MessageCaptionOptions{ + Caption: creativeText, + ParseMode: echotron.HTML, + ReplyMarkup: keyboard, + }, + ) + if err != nil { + logEditError("Failed to edit media caption", err) + } else { + updated = true + } + } + } else { + // Текстовое сообщение - редактируем текст + _, err := b.EditMessageText( + creativeText, + msgID, + &echotron.MessageTextOptions{ + ParseMode: echotron.HTML, + ReplyMarkup: keyboard, + LinkPreviewOptions: echotron.LinkPreviewOptions{ + IsDisabled: true, + }, + }, + ) + if err != nil { + logEditError("Failed to edit message text", err) + } else { + updated = true + } + } + + if updated { + if currentHasMedia { + e.previousMediaItems = copyMediaItems(e.MediaItems) + } else { + e.previousMediaItems = nil + } + } +} + +func (e *CreativeEditorFields) DeleteUserMessage(b *bot.Bot, messageID int) { + _, err := b.DeleteMessage(b.ChatID, messageID) + if err != nil { + log.Error().Err(err).Msg("Failed to delete user message") + } +} + +func (e *CreativeEditorFields) SetMediaFromMessage(message *echotron.Message) bool { + var item MediaItem + switch { + case message.Photo != nil && len(message.Photo) > 0: + photo := message.Photo[len(message.Photo)-1] + item.MediaType = "photo" + item.MediaFileID = photo.FileID + case message.Video != nil: + item.MediaType = "video" + item.MediaFileID = message.Video.FileID + case message.Animation != nil: + item.MediaType = "animation" + item.MediaFileID = message.Animation.FileID + default: + return false + } + + e.MediaItems = append(e.MediaItems, item) + e.MediaChanged = true + return true +} + +func (e *CreativeEditorFields) AddInviteButton(text string) { + e.Buttons = append(e.Buttons, InlineButton{ + Text: text, + URL: inviteLinkPlaceholder, + }) +} + +func (e *CreativeEditorFields) AddCustomButtonPlaceholder(text string) { + e.Buttons = append(e.Buttons, InlineButton{ + Text: text, + URL: buttonURLPlaceholder, + }) +} + +func (e *CreativeEditorFields) UpdateLastButtonURL(url string) { + if len(e.Buttons) > 0 { + e.Buttons[len(e.Buttons)-1].URL = url + } +} + +func (e *CreativeEditorFields) ClearInputMode() { + e.InputMode = "" + e.PendingButtonType = "" +} + +func (e *CreativeEditorFields) CancelPendingButton(b *bot.Bot) { + if e.InputMode == inputModeButtonURL && len(e.Buttons) > 0 { + lastButton := e.Buttons[len(e.Buttons)-1] + if lastButton.URL == buttonURLPlaceholder || lastButton.URL == inviteLinkPlaceholder { + e.Buttons = e.Buttons[:len(e.Buttons)-1] + e.UpdateCreativePreview(b) + } + } + + e.ClearInputMode() +} + +func (e *CreativeEditorFields) IsValidButtonURL(url string, allowHTTP bool) bool { + if allowHTTP { + return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") + } + return strings.HasPrefix(url, "https://") +} + +// ShowManagementPanel показывает панель управления креативом +// confirmButtonText - текст кнопки подтверждения (например "✓ Сохранить") +// confirmCallback - callback для кнопки подтверждения +// cancelCallback - callback для кнопки отмены +// extraButtons - дополнительные кнопки которые будут добавлены перед финальными кнопками +func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText, confirmCallback, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) { + text := msgCreativeManagement + + buttons := e.BuildEditorButtons(confirmButtonText, confirmCallback, "Назад", cancelCallback, extraButtons...) + e.ShowControlPanel(b, text, buttons) +} + +func (e *CreativeEditorFields) BuildEditorButtons(confirmText, confirmCallback, cancelText, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton { + var buttons [][]echotron.InlineKeyboardButton + + // Блок редактирования контента + buttons = append(buttons, Row( + Button("✎ Текст", "edit_text"), + Button("+ Медиа", "add_media"), + )) + addButtonLabel := "+ Добавить кнопку" + addButtonCallback := "add_button" + if e.hasMultipleMedia() { + addButtonLabel = "Кнопки недоступны для >1 медиа" + addButtonCallback = "empty" + } + buttons = append(buttons, Row(Button(addButtonLabel, addButtonCallback))) + + // Кнопка удаления медиа (если медиа добавлено) + if len(e.MediaItems) > 0 { + label := "⌫ Убрать медиа" + if len(e.MediaItems) > 1 { + label = fmt.Sprintf("⌫ Убрать медиа (%d)", len(e.MediaItems)) + } + buttons = append(buttons, Row(Button(label, "delete_media"))) + } + + // Кнопки удаления (если есть кнопки) + if len(e.Buttons) > 0 { + var row []echotron.InlineKeyboardButton + for i, btn := range e.Buttons { + row = append(row, Button( + fmt.Sprintf(`⌫ Убрать «%s»`, btn.Text), + fmt.Sprintf("delete_button:%d", i), + )) + + if len(row) == 2 { + buttons = append(buttons, row) + row = nil + } + } + + if len(row) > 0 { + buttons = append(buttons, row) + } + } + + // Дополнительные кнопки (например "Удалить креатив") + for _, extraRow := range extraButtons { + buttons = append(buttons, extraRow) + } + + // Финальные кнопки + cancelLabel := "← " + cancelText + if e.Text != nil { + buttons = append(buttons, Row( + Button(cancelLabel, cancelCallback), + Button(confirmText, confirmCallback), + )) + } else { + buttons = append(buttons, Row( + Button(cancelLabel, cancelCallback), + )) + } + + return buttons +} + +// ShowControlPanel редактирует панель управления с произвольным текстом и кнопками +func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons [][]echotron.InlineKeyboardButton) { + if e.ControlPanelMessageID == nil { + // Отправляем новую панель + keyboard := Keyboard(buttons...) + b.SendNew(text, keyboard) + // ВАЖНО: Сохраняем копию значения, а не указатель на переменную! + controlPanelMsgID := b.LastMessageID + e.ControlPanelMessageID = &controlPanelMsgID + } else { + // Редактируем существующую панель + // Временно заменяем LastMessageID на ID панели управления + oldLastMessageID := b.LastMessageID + b.LastMessageID = *e.ControlPanelMessageID + defer func() { b.LastMessageID = oldLastMessageID }() + + keyboard := Keyboard(buttons...) + b.Edit(text, keyboard) + } +} + +func (e *CreativeEditorFields) BuildMediaInputs(b *bot.Bot) ([]backend.CreativeMediaInput, error) { + if len(e.MediaItems) == 0 { + return nil, nil + } + mediaItems := make([]backend.CreativeMediaInput, 0, len(e.MediaItems)) + for _, item := range e.MediaItems { + if item.MediaFileID == "" { + continue + } + mediaData, err := b.DownloadFileBytes(item.MediaFileID) + if err != nil { + return nil, err + } + mediaItems = append(mediaItems, backend.CreativeMediaInput{ + MediaType: item.MediaType, + MediaFileID: item.MediaFileID, + MediaData: mediaData, + }) + } + return mediaItems, nil +} diff --git a/tg_bot/screens/creative_editor_ui.go b/tg_bot/screens/creative_editor_ui.go new file mode 100644 index 0000000..15c33a5 --- /dev/null +++ b/tg_bot/screens/creative_editor_ui.go @@ -0,0 +1,138 @@ +package screens + +import ( + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" +) + +const msgEditCreativeText = ` +✎ Введите текст креатива: + +⚠ Важно: Текст должен содержать ОДНУ инвайт-ссылку вашего канала + +Формат ссылки: +https://t.me/+xxx + +Пример: +Присоединяйтесь к нашему каналу! +https://t.me/+AbCdEfGhIjKlMn + +` +const msgAddButtonPrompt = ` ++ Добавить кнопку + +Введите текст кнопки: + +Например: +• Перейти на сайт +• Написать нам + +Ссылка на канал подставится автоматически при создании закупа. + +` +const msgAddMediaPrompt = ` ++ Добавить медиа + +Отправьте фото, видео или GIF + +Можно добавить несколько медиа — отправляйте по одному. +Нажмите «Отмена», когда закончите. + +` +const msgButtonURLPrompt = ` +✧ Введите URL для кнопки: + +URL должен начинаться с http:// или https:// + +Например: +https://example.com +https://t.me/yourchannel + +` +const msgInvalidURL = ` +❌ Неверный формат URL + +URL должен начинаться с http:// или https:// + +Попробуйте ещё раз. + +` + +const msgEditTag = ` +🏷 Выберите тег креатива: + +🧪 Тестовый — для A/B тестов и экспериментов +🚀 Рабочий — проверенный эффективный креатив + +` + +func (e *CreativeEditorFields) ShowTagPanel(b *bot.Bot) { + testingLabel := "🧪 Тестовый" + productionLabel := "🚀 Рабочий" + + if e.Tag != nil { + if *e.Tag == "testing" { + testingLabel = "✓ 🧪 Тестовый" + } else if *e.Tag == "production" { + productionLabel = "✓ 🚀 Рабочий" + } + } + + e.ShowControlPanel(b, msgEditTag, [][]echotron.InlineKeyboardButton{ + Row( + Button(testingLabel, "tag_testing"), + Button(productionLabel, "tag_production"), + ), + Row(Button("« Назад", "cancel_edit")), + }) +} + +func (e *CreativeEditorFields) GetTagLabel() string { + if e.Tag != nil && *e.Tag == "production" { + return "◉ Рабочий" + } + return "◉ Тестовый" +} + +func (e *CreativeEditorFields) ShowAddButtonPanel(b *bot.Bot) { + inviteLabel := "Ссылка на канал" + customLabel := "Своя ссылка" + if e.PendingButtonType == "invite" { + inviteLabel = "✓ Ссылка на канал" + } + if e.PendingButtonType == "custom" { + customLabel = "✓ Своя ссылка" + } + + e.ShowControlPanel(b, msgAddButtonPrompt, [][]echotron.InlineKeyboardButton{ + Row( + Button(inviteLabel, "button_type_invite"), + Button(customLabel, "button_type_custom"), + ), + Row(Button("« Отмена", "cancel_add_button")), + }) +} + +func (e *CreativeEditorFields) ShowTextEditPanel(b *bot.Bot) { + e.ShowControlPanel(b, msgEditCreativeText, [][]echotron.InlineKeyboardButton{ + Row(Button("« Отмена", "cancel_edit")), + }) +} + +func (e *CreativeEditorFields) ShowMediaPanel(b *bot.Bot) { + e.ShowControlPanel(b, msgAddMediaPrompt, [][]echotron.InlineKeyboardButton{ + Row(Button("« Отмена", "cancel_media")), + }) +} + +func (e *CreativeEditorFields) ShowButtonURLPanel(b *bot.Bot) { + e.ShowControlPanel(b, msgButtonURLPrompt, [][]echotron.InlineKeyboardButton{ + Row(Button("« Отмена", "cancel_add_button")), + }) +} + +func (e *CreativeEditorFields) ShowInvalidButtonURLPanel(b *bot.Bot) { + e.ShowControlPanel(b, msgInvalidURL, [][]echotron.InlineKeyboardButton{ + Row(Button("« Отмена", "cancel_add_button")), + }) +} diff --git a/tg_bot/screens/creative_view.go b/tg_bot/screens/creative_view.go new file mode 100644 index 0000000..e4c1160 --- /dev/null +++ b/tg_bot/screens/creative_view.go @@ -0,0 +1,250 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/rs/zerolog/log" +) + +// CreativeView - экран просмотра креатива (read-only mode) +// Показывает превью креатива и кнопки действий: Редактировать, Тег, Закупить +type CreativeView struct { + CreativeEditorFields // Встраивание общих полей для отображения превью + + CreativeID string + ProjectID string + + // Данные проекта для передачи в AddPurchase + ProjectTitle string + ProjectTelegramID int64 + ProjectUsername string + ProjectStatus string + + BackState bot.State + + renaming bool // режим ввода нового названия +} + +func msgCreativeView(name string) string { + return fmt.Sprintf("📋 %s\n\nВыберите действие:", name) +} + +func (s *CreativeView) Enter(b *bot.Bot, mode bot.RenderMode) { + // Удаляем сообщение предыдущего экрана (его клавиатуру не очищает SendCreativePreview) + if b.LastMessageID != 0 { + b.DeleteMessage(b.ChatID, b.LastMessageID) + b.LastMessageID = 0 + } + + // Сбрасываем старые ID — при re-enter всегда создаём свежие сообщения + s.CreativeMessageID = nil + s.ControlPanelMessageID = nil + + // Загружаем креатив + creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID) + if err != nil { + log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative") + b.SendNew("❌ Не удалось загрузить креатив", Keyboard( + Row(Button("← Назад", "back")), + )) + return + } + + // Загружаем проект для получения данных + project, err := b.Backend.GetProject(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID) + if err != nil { + log.Error().Err(err).Str("project_id", s.ProjectID).Msg("Failed to get project") + } else { + s.ProjectTitle = project.Title + s.ProjectTelegramID = project.TelegramID + s.ProjectStatus = project.Status + if project.Username != nil { + s.ProjectUsername = *project.Username + } + } + + // Заполняем поля креатива для отображения превью + s.Name = &creative.Name + if creative.Text != "" { + s.Text = &creative.Text + } + s.MediaItems = nil + if len(creative.MediaItems) > 0 { + for _, item := range creative.MediaItems { + s.MediaItems = append(s.MediaItems, MediaItem{ + MediaType: item.MediaType, + MediaFileID: item.MediaFileID, + }) + } + } + s.Buttons = nil + for _, btn := range creative.Buttons { + s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL}) + } + if creative.Tag != "" { + s.Tag = &creative.Tag + } + + // Отправляем превью креатива + s.SendCreativePreview(b) + + // Отправляем панель управления с кнопками действий + s.showActionPanel(b) +} + +func (s *CreativeView) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "back": + s.CleanupMessages(b) + if s.BackState != nil { + b.SetState(s.BackState, bot.NewMessage) + } + + case data == "rename": + s.renaming = true + s.ShowControlPanel(b, "✏️ Введите новое название креатива:", [][]echotron.InlineKeyboardButton{ + Row(Button("← Отмена", "cancel_rename")), + }) + + case data == "cancel_rename": + s.renaming = false + s.showActionPanel(b) + + case data == "edit": + s.CleanupMessages(b) + b.SetState(&CreativeDetails{ + CreativeID: s.CreativeID, + ProjectID: s.ProjectID, + ViewMode: true, + ViewBackTo: s, + ProjectTitle: s.ProjectTitle, + BackState: s.BackState, + }, bot.NewMessage) + + case data == "toggle_tag": + // Переключение тега (testing <-> production) + s.toggleTag(b) + + case data == "purchase_creative": + s.CleanupMessages(b) + creativeTitle := "" + if s.Name != nil { + creativeTitle = *s.Name + } + + b.SetState(&AddPurchase{ + ProjectID: s.ProjectID, + ProjectTitle: s.ProjectTitle, + ProjectTelegramID: s.ProjectTelegramID, + ProjectUsername: s.ProjectUsername, + ProjectStatus: s.ProjectStatus, + CreativeID: s.CreativeID, + CreativeTitle: creativeTitle, + BackState: s, + }, bot.NewMessage) + + default: + log.Warn().Str("callback", data).Msg("Unknown callback in CreativeView") + } +} + +func (s *CreativeView) HandleMessage(b *bot.Bot, u *echotron.Update) { + if !s.renaming || u.Message == nil || u.Message.Text == "" { + return + } + + newName := strings.TrimSpace(u.Message.Text) + s.DeleteUserMessage(b, u.Message.ID) + + input := backend.UpdateCreativeInput{ + Name: &newName, + } + _, err := b.Backend.UpdateCreative( + context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID, input, + ) + if err != nil { + log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to rename creative") + s.ShowControlPanel(b, "❌ Не удалось переименовать", [][]echotron.InlineKeyboardButton{ + Row(Button("← Назад", "cancel_rename")), + }) + return + } + + s.Name = &newName + s.renaming = false + s.showActionPanel(b) +} + +func (s *CreativeView) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *CreativeView) Exit() {} + +// showActionPanel показывает панель действий с кнопками +func (s *CreativeView) showActionPanel(b *bot.Bot) { + tagLabel := s.GetTagLabel() + + buttons := [][]echotron.InlineKeyboardButton{ + Row( + Button("✎ Переименовать", "rename"), + Button("✎ Редактировать", "edit"), + ), + Row(Button("+ Закупить", "purchase_creative")), + Row( + Button("← Назад", "back"), + Button(tagLabel, "toggle_tag"), + ), + } + + name := "Креатив" + if s.Name != nil && *s.Name != "" { + name = *s.Name + } + s.ShowControlPanel(b, msgCreativeView(name), buttons) +} + +// toggleTag переключает тег креатива (testing <-> production) +func (s *CreativeView) toggleTag(b *bot.Bot) { + newTag := "testing" + if s.Tag != nil && *s.Tag == "testing" { + newTag = "production" + } + + // Обновляем тег через backend + input := backend.UpdateCreativeInput{ + Tag: &newTag, + } + + _, err := b.Backend.UpdateCreative( + context.Background(), + b.Session.JWT, + b.Session.WorkspaceID, + s.CreativeID, + input, + ) + + if err != nil { + log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative tag") + s.ShowControlPanel(b, "❌ Не удалось изменить тег\n\nПопробуйте еще раз.", [][]echotron.InlineKeyboardButton{ + Row(Button("← Назад", "back")), + Row(Button("↻ Попробовать снова", "toggle_tag")), + }) + return + } + + // Обновляем локальное значение + s.Tag = &newTag + + // Обновляем панель (показывает новый тег) + s.showActionPanel(b) +} diff --git a/tg_bot/screens/creatives.go b/tg_bot/screens/creatives.go new file mode 100644 index 0000000..c49d93d --- /dev/null +++ b/tg_bot/screens/creatives.go @@ -0,0 +1,145 @@ +package screens + +import ( + "context" + "strings" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" + "github.com/rs/zerolog/log" +) + +type Creatives struct { + CurrentPage int + ProjectID string + ProjectTitle string + ProjectTelegramID int64 + ProjectUsername string + ProjectStatus string + BackState bot.State +} + +const creativesPerPage = 6 +const creativesPerRow = 2 + +const msgNoCreatives = ` +Креативы + +У вас пока нет креативов + +Креатив — это рекламный материал: + ‣ Текст объявления + ‣ Медиа контент (фото, видео) + ‣ Шаблон для автопостинга + ‣ Варианты для A/B тестирования + +Начните с добавления первого креатива +` + +const msgCreatives = ` +Креативы + +Управление рекламными материалами + +Выберите креатив +` + +func (s *Creatives) Enter(b *bot.Bot, mode bot.RenderMode) { + page, err := b.Backend.GetCreatives(context.Background(), b.Session.JWT, b.Session.WorkspaceID, &s.ProjectID, false, s.CurrentPage+1, creativesPerPage) + if err != nil { + log.Error().Err(err).Msg("Failed to get creatives") + b.SendNew("❌ Не удалось загрузить креативы", refreshKeyboard) + return + } + + text := msgCreatives + if len(page.Items) == 0 { + text = msgNoCreatives + } + + var kbRows [][]echotron.InlineKeyboardButton + + rowsGrid := ui.BuildGrid(page.Items, creativesPerRow, creativesPerPage, page.Pages, + func(creative backend.Creative) (string, string) { return creative.Name, "creative:" + creative.ID }, + ) + kbRows = append(kbRows, rowsGrid...) + + if navRow := ui.BuildNavigationRow(ui.PaginationConfig{ + CurrentPage: s.CurrentPage, + TotalPages: page.Pages, + MiddleButtons: Row(Button("+ Добавить", "add_creative")), + }); navRow != nil { + kbRows = append(kbRows, navRow) + } + + kbRows = append(kbRows, Row(Button("← Назад", "back"), Button("≡ Архив", "archive"))) + + kb := Keyboard(kbRows...) + b.Render(text, kb, mode) + + updateProjectHeaderMedia(b, b.LastMessageID, text, kb, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus) +} + +func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "prev": + if s.CurrentPage > 0 { + s.CurrentPage-- + } + s.Enter(b, bot.EditMessage) + + case data == "next": + s.CurrentPage++ + s.Enter(b, bot.EditMessage) + + case data == "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + + case data == "archive": + b.Edit("📦 Архив креативов\n\nЭта функция в разработке", Keyboard(Row(Button("← Назад", "back")))) + + case data == "add_creative": + b.SetState(&AddCreativeStart{Ctx: &AddCreativeCtx{ + ProjectID: s.ProjectID, + BackState: s, + }}, bot.EditMessage) + + case strings.HasPrefix(data, "creative:"): + creativeID, ok := strings.CutPrefix(data, "creative:") + if !ok || creativeID == "" { + return + } + + // Открываем CreativeView вместо CreativeDetails + b.SetState(&CreativeView{ + CreativeID: creativeID, + ProjectID: s.ProjectID, + // Данные проекта для передачи в AddPurchase + ProjectTitle: s.ProjectTitle, + ProjectTelegramID: s.ProjectTelegramID, + ProjectUsername: s.ProjectUsername, + ProjectStatus: s.ProjectStatus, + BackState: s, + }, bot.NewMessage) + + default: + s.Enter(b, bot.NewMessage) + } + return +} + +func (s *Creatives) HandleMessage(_ *bot.Bot, _ *echotron.Update) {} + +func (s *Creatives) Handle(_ *bot.Bot, _ *echotron.Update) {} + +func (s *Creatives) Exit() {} diff --git a/tg_bot/screens/help.go b/tg_bot/screens/help.go new file mode 100644 index 0000000..cd38e90 --- /dev/null +++ b/tg_bot/screens/help.go @@ -0,0 +1,54 @@ +package screens + +import ( + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" +) + +const msgHelp = ` +❓ Помощь + +Команды бота: +/start — Главное меню +/projects — Мои проекты +/placements — Размещения +/platform — Веб-платформа +/help — Эта справка + +Как работать с ботом: +1. Создайте проект и добавьте креативы +2. Выберите каналы для размещения +3. Оформите покупку через безопасную сделку +4. Получите автоматический отчёт + +Поддержка: +Если возникли вопросы, напишите в поддержку. +` + +type Help struct{} + +func (s *Help) Enter(b *bot.Bot, mode bot.RenderMode) { + keyboard := Keyboard( + Row(URLButton("Поддержка", "https://t.me/SmartpostSupport")), + Row(Button("↩ В главное меню", "main_menu")), + ) + + b.Render(msgHelp, keyboard, mode) +} + +func (s *Help) HandleCallback(b *bot.Bot, u *echotron.Update) { + switch u.CallbackQuery.Data { + case "main_menu": + b.SetState(&MainMenu{}, bot.EditMessage) + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *Help) HandleMessage(b *bot.Bot, u *echotron.Update) {} + +func (s *Help) Handle(b *bot.Bot, u *echotron.Update) { + b.SetState(&MainMenu{}, bot.NewMessage) +} + +func (s *Help) Exit() {} diff --git a/tg_bot/screens/helpers.go b/tg_bot/screens/helpers.go new file mode 100644 index 0000000..79d8abb --- /dev/null +++ b/tg_bot/screens/helpers.go @@ -0,0 +1,41 @@ +package screens + +import ( + "github.com/NicoNex/echotron/v3" +) + +var emptyKeyboard = Keyboard() +var refreshKeyboard = Keyboard(Row(Button("↻ Обновить", "refresh"))) + +func Button(text, data string) echotron.InlineKeyboardButton { + return echotron.InlineKeyboardButton{ + Text: text, + CallbackData: data, + } +} + +func Stylish(btn echotron.InlineKeyboardButton, style echotron.ButtonStyle) echotron.InlineKeyboardButton { + btn.Style = style + + return btn +} + +func URLButton(text, url string) echotron.InlineKeyboardButton { + return echotron.InlineKeyboardButton{ + Text: text, + URL: url, + } +} + +func Row(buttons ...echotron.InlineKeyboardButton) []echotron.InlineKeyboardButton { return buttons } + +func Keyboard(rows ...[]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup { + if len(rows) == 0 { + return echotron.InlineKeyboardMarkup{ + InlineKeyboard: [][]echotron.InlineKeyboardButton{}, + } + } + return echotron.InlineKeyboardMarkup{ + InlineKeyboard: rows, + } +} diff --git a/tg_bot/screens/login.go b/tg_bot/screens/login.go new file mode 100644 index 0000000..d4e45e8 --- /dev/null +++ b/tg_bot/screens/login.go @@ -0,0 +1,59 @@ +package screens + +import ( + "context" + "fmt" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/rs/zerolog/log" +) + +type Login struct{} + +const msgHelloLogin = ` +Привет, %s! +Нажми кнопку для входа на сайт. 👇 +` + +func (s *Login) Enter(b *bot.Bot, mode bot.RenderMode) { + token, err := b.Backend.CreateLoginToken(context.Background(), b.ChatID) + if err != nil { + b.SendNew("❌ Произошла ошибка при создании токена входа. Попробуйте позже.", emptyKeyboard) + return + } + + loginURL := b.Backend.LoginURL(token) + + usernameDisplay := "аноним" + if b.Session.FirstName != "" { + usernameDisplay = b.Session.FirstName + } + + kb := Keyboard(Row(URLButton("Войти на сайт", loginURL))) + + res, err := b.SendMessage(fmt.Sprintf(msgHelloLogin, usernameDisplay), b.ChatID, &echotron.MessageOptions{ + ReplyMarkup: kb, + ParseMode: echotron.HTML, + LinkPreviewOptions: echotron.LinkPreviewOptions{IsDisabled: true}, + }) + if err != nil { + log.Err(err).Msg("SendMessage error") + return + } + + if res.Result != nil { + if err := b.Backend.AttachLoginTokenMessage(context.Background(), token, res.Result.ID); err != nil { + log.Err(err).Msg("AttachLoginTokenMessage error") + } + } + + b.SetState(&MainMenu{}, bot.NewMessage) +} +func (s *Login) HandleCallback(b *bot.Bot, u *echotron.Update) {} + +func (s *Login) HandleMessage(b *bot.Bot, u *echotron.Update) {} + +func (s *Login) Handle(b *bot.Bot, u *echotron.Update) {} + +func (s *Login) Exit() {} diff --git a/tg_bot/screens/main_menu.go b/tg_bot/screens/main_menu.go new file mode 100644 index 0000000..8ecf105 --- /dev/null +++ b/tg_bot/screens/main_menu.go @@ -0,0 +1,100 @@ +package screens + +import ( + "context" + "fmt" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/rs/zerolog/log" +) + +type MainMenu struct{} + +const msgAbout = ` +Главное меню + +
+За что отвечает каждая кнопка в этом окне: + +• Рабочее пространство — Одно можно использовать для своих проектов, а другое, например, для проектов, где вы являетесь закупщиком. + +• Мои проекты — Управляйте вашими телеграм-каналами: добавляйте новые, привязывайте к ним креативы, создавайте закупы. + +• Размещения — Просматривайте статистику по всем сделанным размещениям или создавайте новые (бот автоматически сформирует для вас креатив и заменит в нём пригласительную ссылку). + +• Веб-платформа — Используйте функционал нашего сервиса на все 100%: планируйте размещения, просматривайте аналитику, следите за показателями проектов. + +• Помощь — Изучите инструкции, как пользоваться нашим сервисом или напишите в поддержку, если возникли какие-то вопросы. +
+ +Чтобы делать закупы, добавьте канал и креатив: +/addchannel — новый канал +` + +func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) { + msg := msgAbout + if b.Session.WasRestarted { + msg = "⚠️ Бот был перезапущен. Извиняемся за неудобства :(\n\n" + msgAbout + b.Session.WasRestarted = false + } + + loadWorkspace := func() string { + workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT) + if err != nil { + log.Error().Err(err).Msg("Failed to get workspaces for main menu") + return "ошибка" + } + if len(workspaces) == 0 { + workspace, err := b.Backend.CreateWorkspace(context.Background(), b.Session.JWT, "Личное") + if err != nil { + log.Error().Err(err).Msg("Failed to create workspace") + } + + workspaces = append(workspaces, workspace) + } + + selected := workspaces[0] // дефолт — первый + + if b.Session.WorkspaceID != "" { + for _, ws := range workspaces { + if ws.ID == b.Session.WorkspaceID { + selected = ws + break + } + } + } + + b.Session.WorkspaceID = selected.ID + return selected.Name + } + + workspaceName := loadWorkspace() + + keyboard := Keyboard( + Row(Button(fmt.Sprintf("Рабочее пространство: %s", workspaceName), "workspace_menu")), + Row(Button("Мои проекты", "my_projects"), URLButton("Поддержка", "https://t.me/SmartpostSupport")), + Row(Button("Размещения", "placements"), URLButton("Дашборд", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))), + ) + + b.Render(msg, keyboard, mode) +} + +func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) { + switch u.CallbackQuery.Data { + case "my_projects": + b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.EditMessage) + case "placements": + b.SetState(&MyProjects{BackState: &MainMenu{}, OpenPlacements: true}, bot.EditMessage) + case "workspace_menu": + b.SetState(&WorkspaceMenu{BackState: &MainMenu{}}, bot.EditMessage) + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *MainMenu) HandleMessage(b *bot.Bot, u *echotron.Update) {} + +func (s *MainMenu) Handle(b *bot.Bot, u *echotron.Update) { b.SetState(&MainMenu{}, bot.NewMessage) } + +func (s *MainMenu) Exit() {} diff --git a/tg_bot/screens/my_projects.go b/tg_bot/screens/my_projects.go new file mode 100644 index 0000000..4f7948a --- /dev/null +++ b/tg_bot/screens/my_projects.go @@ -0,0 +1,197 @@ +package screens + +import ( + "context" + "strings" + "time" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" + "github.com/rs/zerolog/log" +) + +const projectsPerPage = 6 +const projectsPerRow = 2 + +type MyProjects struct { + CurrentPage int + BackState bot.State + OpenPlacements bool + + cancel context.CancelFunc + lastProjectCount int +} + +const msgNoProjects = ` +Мои проекты + +У вас пока нет подключенных каналов. + +Что дает подключение канала: + ▸ Управление типом приглашений (публичные/с одобрением) + ▸ Автоматическая отправка креативов в канал + ▸ Интеграция с планом закупов + ▸ Статистика и аналитика подписчиков + ▸ Настройка уведомлений + +Начните с добавления первого проекта +` + +const msgProjects = ` +Мои проекты + +Управление вашими Telegram-каналами. + +Выберите канал +` + +func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) { + s.renderProjects(b, mode) + + s.startPolling(b) +} + +func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode) { + page, err := b.Backend.GetProjects(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CurrentPage+1, projectsPerPage) + if err != nil { + b.SendNew("❌ Не удалось загрузить проекты", refreshKeyboard) + return + } + + s.lastProjectCount = page.Total + + var kbRows [][]echotron.InlineKeyboardButton + + grid := ui.BuildGrid(page.Items, projectsPerRow, projectsPerPage, page.Pages, + func(project backend.Project) (string, string) { return project.Title, "project:" + project.ID }, + ) + kbRows = append(kbRows, grid...) + + if navRow := ui.BuildNavigationRow(ui.PaginationConfig{ + CurrentPage: s.CurrentPage, + TotalPages: page.Pages, + MiddleButtons: Row(Button("+ Добавить", "add_project")), + }); navRow != nil { + kbRows = append(kbRows, navRow) + } + + kbRows = append(kbRows, Row(Button("← Назад", "main_menu"), Button("≡ Архив", "archive"))) + + keyboard := Keyboard(kbRows...) + + text := msgProjects + if len(page.Items) == 0 { + text = msgNoProjects + } + + b.Render(text, keyboard, mode) +} + +func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "prev": + if s.CurrentPage > 0 { + s.CurrentPage-- + } + s.Enter(b, bot.EditMessage) + + case data == "next": + s.CurrentPage++ + s.Enter(b, bot.EditMessage) + + case data == "main_menu": + b.SetState(&MainMenu{}, bot.EditMessage) + + case data == "archive": + b.Edit("Эта функция в разработке", Keyboard(Row(Button("← Назад", "in_dev")))) + + case data == "add_project": + b.SetState(&AddProject{BackState: &MyProjects{}}, bot.EditMessage) + + case strings.HasPrefix(data, "project:"): + projectID, ok := strings.CutPrefix(data, "project:") + if !ok || projectID == "" { + return + } + project, err := b.Backend.GetProject(context.Background(), b.Session.JWT, b.Session.WorkspaceID, projectID) + if err != nil { + log.Error().Err(err).Str("project_id", projectID).Msg("Failed to get project") + return + } + + if s.OpenPlacements { + b.SetState(&Placements{ + ProjectID: project.ID, + ProjectTitle: project.Title, + BackState: &MyProjects{OpenPlacements: true}, + }, bot.EditMessage) + } else { + b.SetState(&ProjectDetails{ + Project: project, + BackState: &MyProjects{}, + }, bot.EditMessage) + } + + default: + s.Enter(b, bot.EditMessage) + } + return +} + +func (s *MyProjects) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *MyProjects) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *MyProjects) Exit() { + if s.cancel != nil { + log.Info().Msg("MyProjects: cancelling polling goroutine") + s.cancel() + } +} + +func (s *MyProjects) startPolling(b *bot.Bot) { + if s.cancel != nil { + s.cancel() + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + s.cancel = cancel + + go s.pollProjects(ctx, b) +} + +func (s *MyProjects) pollProjects(ctx context.Context, b *bot.Bot) { + interval := 3 * time.Second + timeToSlow := time.Now().Add(30 * time.Second) + + timer := time.NewTimer(interval) + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + return + + case <-timer.C: + if time.Now().After(timeToSlow) { + interval = 10 * time.Second + } + + page, err := b.Backend.GetProjects(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CurrentPage+1, projectsPerPage) + if err == nil && page.Total != s.lastProjectCount { + s.lastProjectCount = page.Total + s.renderProjects(b, bot.EditMessage) + } + + timer.Reset(interval) + } + } +} diff --git a/tg_bot/screens/placement_details.go b/tg_bot/screens/placement_details.go new file mode 100644 index 0000000..a2f57fa --- /dev/null +++ b/tg_bot/screens/placement_details.go @@ -0,0 +1,322 @@ +package screens + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" +) + +type PlacementDetails struct { + ProjectID string + PlacementID string + BackState bot.State +} + +func (s *PlacementDetails) Enter(b *bot.Bot, mode bot.RenderMode) { + s.renderDetails(b, mode) +} + +func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) { + placement, err := b.Backend.GetPlacement(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID, s.PlacementID) + if err != nil { + b.SendNew("❌ Не удалось загрузить размещение", Keyboard()) + return + } + + placementPost := placement.PlacementPost + + text := "Детали размещения\n\n" + + // Размещение в канале + text += "📺 Размещение в канале:\n" + text += formatChannelWithName(placement.Channel) + text += "\n" + + // Рекламируемый проект + if placement.Project != nil { + text += "🎯 Рекламируемый проект:\n" + text += formatProject(*placement.Project) + text += "\n" + } + + // Ссылка на пост + if placementPost != nil && placementPost.Post.URL != nil && *placementPost.Post.URL != "" { + text += fmt.Sprintf("🔗 Ссылка на пост: %s\n", *placementPost.Post.URL) + } + + // Пригласительная ссылка + if placement.InviteLink != nil && *placement.InviteLink != "" { + text += fmt.Sprintf("📩 Пригласительная ссылка: %s\n", *placement.InviteLink) + } + + // Тип ссылки + text += fmt.Sprintf("🔓 Тип ссылки: %s\n", formatInviteLinkType(placement.InviteLinkType)) + + // Short ID + if placement.ShortID != "" { + text += fmt.Sprintf("🔢 ID: %s\n", placement.ShortID) + } + + // Креатив + if placement.Details != nil && placement.Details.CreativeName != nil && *placement.Details.CreativeName != "" { + text += fmt.Sprintf("✨ Креатив: %s\n", *placement.Details.CreativeName) + } else if placement.CreativeName != nil && *placement.CreativeName != "" { + text += fmt.Sprintf("✨ Креатив: %s\n", *placement.CreativeName) + } + + text += "\n" + + // Финансовая информация + if placement.Details != nil { + // Стоимость + if placement.Details.Cost != nil { + costType := formatCostType(placement.Details.Cost.Type) + text += fmt.Sprintf("💰 Стоимость: %s %.0f₽\n", costType, placement.Details.Cost.Value) + } + + // Стоимость до торга + if placement.Details.CostBeforeBargain != nil { + costType := formatCostType(placement.Details.CostBeforeBargain.Type) + text += fmt.Sprintf("💸 До торга: %s %.0f₽\n", costType, placement.Details.CostBeforeBargain.Value) + } + + // Дата размещения + if placement.Details.PlacementAt != nil && *placement.Details.PlacementAt != "" { + text += fmt.Sprintf("📅 Дата размещения: %s\n", formatDateTime(*placement.Details.PlacementAt)) + } + + // Дата оплаты + if placement.Details.PaymentAt != nil && *placement.Details.PaymentAt != "" { + text += fmt.Sprintf("💳 Дата оплаты: %s\n", formatDateTime(*placement.Details.PaymentAt)) + } + + // Формат + if placement.Details.Format != nil && *placement.Details.Format != "" { + text += fmt.Sprintf("📐 Формат: %s\n", *placement.Details.Format) + } + + // Тип закупа + if placement.Details.PlacementType != nil { + text += fmt.Sprintf("🔄 Тип закупа: %s\n", formatPlacementType(*placement.Details.PlacementType)) + } + } + + text += "\n" + + // Статистика + if placementPost != nil { + // Кол-во подписчиков + if placementPost.SubscriptionsCount > 0 { + text += fmt.Sprintf("👥 Подписчики: %d\n", placementPost.SubscriptionsCount) + } + + // Просмотры поста + if placementPost.ViewsCount != nil { + text += fmt.Sprintf("👁️ Просмотры: %d\n", *placementPost.ViewsCount) + } + + // CPF (стоимость подписчика) + if placementPost.SubscriptionsCount > 0 && placement.Details != nil && placement.Details.Cost != nil { + cpf := calculateCPF(placement.Details.Cost.Value, placementPost.SubscriptionsCount) + text += fmt.Sprintf("💵 CPF: %.2f₽\n", cpf) + } + + // Конверсия + if placementPost.ViewsCount != nil && *placementPost.ViewsCount > 0 && placementPost.SubscriptionsCount > 0 { + conversion := calculateConversion(placementPost.SubscriptionsCount, *placementPost.ViewsCount) + text += fmt.Sprintf("📊 Конверсия: %.1f%%\n", conversion) + } + } + + // Комментарий + if placement.Details != nil && placement.Details.Comment != nil && *placement.Details.Comment != "" { + text += fmt.Sprintf("\n📝 Комментарий: %s\n", *placement.Details.Comment) + } + + keyboard := Keyboard(Row(Button("← Назад", "back"))) + b.Render(text, keyboard, mode) +} + +func formatChannelWithName(channel backend.Channel) string { + var name string + if channel.Title != nil && *channel.Title != "" { + name = *channel.Title + } else if channel.Username != nil && *channel.Username != "" { + name = "@" + *channel.Username + } else { + name = "Без названия" + } + + // Добавляем ссылку если есть username или invite_link + if channel.Username != nil && *channel.Username != "" { + return fmt.Sprintf(" %s (@%s)\n", name, *channel.Username) + } else if channel.InviteLink != nil && *channel.InviteLink != "" { + return fmt.Sprintf(" %s\n", name) + } + return fmt.Sprintf(" %s\n", name) +} + +func formatProject(project backend.ProjectOutput) string { + var name string + if project.Channel.Title != nil && *project.Channel.Title != "" { + name = *project.Channel.Title + } else if project.Channel.Username != nil && *project.Channel.Username != "" { + name = "@" + *project.Channel.Username + } else { + name = "Без названия" + } + + // Добавляем ссылку если есть username + if project.Channel.Username != nil && *project.Channel.Username != "" { + return fmt.Sprintf(" %s (@%s)\n", name, *project.Channel.Username) + } + return fmt.Sprintf(" %s\n", name) +} + +func calculateCPF(cost float64, subscriptions int) float64 { + if subscriptions == 0 { + return 0 + } + return cost / float64(subscriptions) +} + +func calculateConversion(subscriptions int, views int) float64 { + if views == 0 { + return 0 + } + return float64(subscriptions) / float64(views) * 100 +} + +func formatInviteLinkType(value string) string { + if value == "approval" { + return "с одобрением" + } + return "публичная" +} + +func formatPlacementStatus(status string) string { + normalized := strings.TrimSpace(strings.ToLower(status)) + switch normalized { + case "planned": + return "Планируется" + case "approved": + return "Согласовано" + case "rejected": + return "Отклонено" + case "in_progress": + return "В работе" + case "completed": + return "Размещено" + default: + return status + } +} + +func formatPlacementType(value string) string { + normalized := strings.TrimSpace(strings.ToLower(value)) + switch normalized { + case "self_promo": + return "Самопиар" + case "standard": + return "Стандарт" + default: + return value + } +} + +func formatCostType(value string) string { + switch strings.TrimSpace(strings.ToLower(value)) { + case "cpm": + return "СРМ" + default: + return "Фикс" + } +} + +func formatDateTime(value string) string { + if value == "" { + return value + } + var parsed time.Time + var err error + + // Try parsing with timezone + if parsed, err = time.Parse(time.RFC3339, value); err == nil { + return formatCompactDateTime(parsed.In(ui.MskLocation)) + } + if parsed, err = time.Parse(time.RFC3339Nano, value); err == nil { + return formatCompactDateTime(parsed.In(ui.MskLocation)) + } + if parsed, err = time.ParseInLocation("2006-01-02T15:04:05", value, ui.MskLocation); err == nil { + return formatCompactDateTime(parsed.In(ui.MskLocation)) + } + if parsed, err = time.ParseInLocation("2006-01-02", value, ui.MskLocation); err == nil { + return formatCompactDateTime(parsed.In(ui.MskLocation)) + } + return value +} + +func WeekdayName(day time.Weekday) string { + switch day { + case time.Monday: + return "Пн" + case time.Tuesday: + return "Вт" + case time.Wednesday: + return "Ср" + case time.Thursday: + return "Чт" + case time.Friday: + return "Пт" + case time.Saturday: + return "Сб" + case time.Sunday: + return "Вс" + default: + return "" + } +} + +func MonthShort(month time.Month) string { + months := []string{ + "янв", "фев", "мар", "апр", "май", "июн", + "июл", "авг", "сен", "окт", "ноя", "дек", + } + if int(month) < 1 || int(month) > len(months) { + return "" + } + return months[int(month)-1] +} + +func formatCompactDateTime(value time.Time) string { + weekday := WeekdayName(value.Weekday()) + month := MonthShort(value.Month()) + return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04")) +} + +func (s *PlacementDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + switch u.CallbackQuery.Data { + case "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *PlacementDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *PlacementDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *PlacementDetails) Exit() {} diff --git a/tg_bot/screens/placements.go b/tg_bot/screens/placements.go new file mode 100644 index 0000000..db1e9ac --- /dev/null +++ b/tg_bot/screens/placements.go @@ -0,0 +1,246 @@ +package screens + +import ( + "context" + "fmt" + "time" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" +) + +type Placements struct { + ProjectID string + ProjectTitle string + ProjectTelegramID int64 + ProjectUsername string + ProjectStatus string + BackState bot.State +} + +const msgPlacements = ` +Размещения + +Управление размещениями для проекта. +` + +func (s *Placements) Enter(b *bot.Bot, mode bot.RenderMode) { + var rows [][]echotron.InlineKeyboardButton + + rows = append(rows, Row(Button("+ Создать размещение", "add_purchase"))) + rows = append(rows, Row(Button("Список размещений", "placements_list"), URLButton("План закупов", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s/purchase-plans/%s", b.Session.WorkspaceID, s.ProjectID)))) + rows = append(rows, Row(Button("← Назад", "back"))) + + keyboard := Keyboard(rows...) + b.Render(msgPlacements, keyboard, mode) + + updateProjectHeaderMedia(b, b.LastMessageID, msgPlacements, keyboard, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus) +} + +func (s *Placements) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch data { + case "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + + case "add_purchase": + b.SetState(&AddPurchase{ + ProjectID: s.ProjectID, + ProjectTitle: s.ProjectTitle, + ProjectTelegramID: s.ProjectTelegramID, + ProjectUsername: s.ProjectUsername, + ProjectStatus: s.ProjectStatus, + ActivePicker: "", + BackState: s, + }, bot.EditMessage) + + case "placements_list": + b.SetState(&PlacementsList{ + ProjectID: s.ProjectID, + ProjectTitle: s.ProjectTitle, + ProjectTelegramID: s.ProjectTelegramID, + ProjectUsername: s.ProjectUsername, + ProjectStatus: s.ProjectStatus, + BackState: s, + }, bot.EditMessage) + + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *Placements) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *Placements) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *Placements) Exit() {} + +// PlacementsList - экран со списком размещений +const placementsPerPage = 6 +const placementsPerRow = 1 + +type PlacementsList struct { + ProjectID string + ProjectTitle string + ProjectTelegramID int64 + ProjectUsername string + ProjectStatus string + CurrentPage int + BackState bot.State +} + +func (s *PlacementsList) Enter(b *bot.Bot, mode bot.RenderMode) { + s.render(b, mode) +} + +func (s *PlacementsList) render(b *bot.Bot, mode bot.RenderMode) { + page, err := b.Backend.GetPlacements( + context.Background(), + b.Session.JWT, + b.Session.WorkspaceID, + s.ProjectID, + s.CurrentPage+1, + placementsPerPage, + ) + if err != nil { + b.SendNew("❌ Не удалось загрузить размещения", Keyboard()) + return + } + + text := fmt.Sprintf("Список размещений%s\n\n", ui.FormatPageInfo(s.CurrentPage, page.Pages)) + + var rows [][]echotron.InlineKeyboardButton + + if len(page.Items) == 0 { + text += `Список размещений пуст. + +Размещение — это план публикации рекламы в канале: + ‣ Выбор креатива + ‣ Канал размещения + ‣ Стоимость и формат + ‣ Статус выполнения + +Создайте первое размещение` + } else { + // Формируем кнопки для каждого размещения + buttons := make([]echotron.InlineKeyboardButton, 0, len(page.Items)) + for i, placement := range page.Items { + // Нумерация по порядку создания: первое размещение = #1 (на последней странице), + // последнее размещение = #N (вверху). + globalNum := page.Total - (s.CurrentPage * placementsPerPage) - i + + // Название канала + channelName := "Без названия" + if placement.Channel.Title != nil && *placement.Channel.Title != "" { + channelName = *placement.Channel.Title + } else if placement.Channel.Username != nil && *placement.Channel.Username != "" { + channelName = "@" + *placement.Channel.Username + } + + // Дата размещения + dateStr := "" + if placement.Details != nil && placement.Details.PlacementAt != nil && *placement.Details.PlacementAt != "" { + dateStr = formatDate(*placement.Details.PlacementAt) + } + + // Текст кнопки: #N • Канал • DD.MM + buttonText := fmt.Sprintf("#%d • %s", globalNum, channelName) + if dateStr != "" { + buttonText += fmt.Sprintf(" • %s", dateStr) + } + + buttons = append(buttons, echotron.InlineKeyboardButton{ + Text: buttonText, + CallbackData: fmt.Sprintf("placement:%s", placement.ID), + }) + } + + // Раскладываем по сетке + rows = ui.BuildPageRows(buttons, placementsPerRow, placementsPerPage, page.Pages) + } + + // Навигация (без MiddleButtons - без "Создать размещение") + if navRow := ui.BuildNavigationRow(ui.PaginationConfig{ + CurrentPage: s.CurrentPage, + TotalPages: page.Pages, + }); navRow != nil { + rows = append(rows, navRow) + } + + // Кнопка назад + rows = append(rows, Row(Button("← Назад", "back"))) + + keyboard := Keyboard(rows...) + b.Render(text, keyboard, mode) +} + +func (s *PlacementsList) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch data { + case "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + + case "prev": + if s.CurrentPage > 0 { + s.CurrentPage-- + } + s.Enter(b, bot.EditMessage) + + case "next": + s.CurrentPage++ + s.Enter(b, bot.EditMessage) + + default: + // Проверяем, не нажали ли на конкретное размещение + if len(data) > 10 && data[:10] == "placement:" { + placementID := data[10:] + b.SetState(&PlacementDetails{ + ProjectID: s.ProjectID, + PlacementID: placementID, + BackState: s, + }, bot.EditMessage) + } else { + s.Enter(b, bot.NewMessage) + } + } +} + +func (s *PlacementsList) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *PlacementsList) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *PlacementsList) Exit() {} + +// formatDate парсит ISO дату и форматирует как "27.01" +func formatDate(isoDate string) string { + // Пробуем парсить RFC3339 + t, err := time.Parse(time.RFC3339, isoDate) + if err != nil { + // Пробуем другие форматы + t, err = time.ParseInLocation("2006-01-02T15:04:05", isoDate, ui.MskLocation) + if err != nil { + // Иногда бэкенд может вернуть только дату без времени + t, err = time.ParseInLocation("2006-01-02", isoDate, ui.MskLocation) + if err != nil { + return "" + } + } + } + + return t.In(ui.MskLocation).Format("02.01") +} diff --git a/tg_bot/screens/platform_link.go b/tg_bot/screens/platform_link.go new file mode 100644 index 0000000..8534293 --- /dev/null +++ b/tg_bot/screens/platform_link.go @@ -0,0 +1,44 @@ +package screens + +import ( + "fmt" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" +) + +const msgPlatform = ` +🌐 Веб-платформа + +Управляйте проектами в браузере — удобно для работы с компьютера. + +Полный функционал доступен на сайте. +` + +type PlatformLink struct{} + +func (s *PlatformLink) Enter(b *bot.Bot, mode bot.RenderMode) { + keyboard := Keyboard( + Row(URLButton("Открыть платформу", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))), + Row(Button("↩ В главное меню", "main_menu")), + ) + + b.Render(msgPlatform, keyboard, mode) +} + +func (s *PlatformLink) HandleCallback(b *bot.Bot, u *echotron.Update) { + switch u.CallbackQuery.Data { + case "main_menu": + b.SetState(&MainMenu{}, bot.EditMessage) + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *PlatformLink) HandleMessage(b *bot.Bot, u *echotron.Update) {} + +func (s *PlatformLink) Handle(b *bot.Bot, u *echotron.Update) { + b.SetState(&MainMenu{}, bot.NewMessage) +} + +func (s *PlatformLink) Exit() {} diff --git a/tg_bot/screens/project_details.go b/tg_bot/screens/project_details.go new file mode 100644 index 0000000..54f902e --- /dev/null +++ b/tg_bot/screens/project_details.go @@ -0,0 +1,191 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/rs/zerolog/log" +) + +type ProjectDetails struct { + Project *backend.Project + BackState bot.State +} + +func (s *ProjectDetails) Enter(b *bot.Bot, mode bot.RenderMode) { + if s.Project == nil { + b.SendNew("❌ Проект не найден", Keyboard( + Row(Button("← Назад", "back")), + )) + return + } + + text := s.formatProjectDetails() + keyboard := s.buildKeyboard() + b.Render(text, keyboard, mode) + + messageID := b.LastMessageID + username := usernameFromProject(s.Project) + status := statusFromProject(s.Project) + updateProjectHeaderMedia(b, messageID, text, keyboard, s.Project.TelegramID, s.Project.Title, username, status) +} + +func (s *ProjectDetails) formatProjectDetails() string { + p := s.Project + + text := fmt.Sprintf("%s\n\n", p.Title) + + if p.Username != nil && *p.Username != "" { + text += fmt.Sprintf("▸ Tg username: @%s\n", *p.Username) + } else { + text += "▸ Tg username: не привязан\n" + } + + // Статус проекта + var statusSymbol, statusText string + switch p.Status { + case "active": + statusSymbol, statusText = "●", "Активный" + case "inactive": + statusSymbol, statusText = "○", "Неактивен" + case "archived": + statusSymbol, statusText = "■", "Архивный" + case "paused": + statusSymbol, statusText = "◐", "Приостановлен" + default: + statusSymbol, statusText = "○", p.Status + } + text += fmt.Sprintf("▸ Статус: %s %s\n", statusSymbol, statusText) + + text += "\nУправление проектом" + + return text +} + +func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup { + var buttons [][]echotron.InlineKeyboardButton + + buttons = append(buttons, Row( + Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)), + Button("Размещения", fmt.Sprintf("placements:%s", s.Project.ID)), + )) + + // Второй ряд: Тип вступления по умолчанию для закупов + var currentType string + if s.Project.PurchaseInviteTypeDefault == "public" { + currentType = "открытая" + } else { + currentType = "с заявками" + } + buttons = append(buttons, Row( + Button(fmt.Sprintf("Ссылка: %s", currentType), fmt.Sprintf("link_type:%s", s.Project.ID)), + )) + + // Нижний ряд: Назад и Архивировать + buttons = append(buttons, Row( + Button("← Назад", "back"), + Button("≡ Архивировать", fmt.Sprintf("archive:%s", s.Project.ID)), + )) + + return Keyboard(buttons...) +} + +func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "back": + if s.BackState != nil { + s.transitionWithNewMessage(b, s.BackState) + } + + case strings.HasPrefix(data, "creatives:"): + b.SetState(&Creatives{ + ProjectID: s.Project.ID, + ProjectTitle: s.Project.Title, + ProjectTelegramID: s.Project.TelegramID, + ProjectUsername: usernameFromProject(s.Project), + ProjectStatus: statusFromProject(s.Project), + BackState: s, + }, bot.EditMessage) + + case strings.HasPrefix(data, "placements:"): + b.SetState(&Placements{ + ProjectID: s.Project.ID, + ProjectTitle: s.Project.Title, + ProjectTelegramID: s.Project.TelegramID, + ProjectUsername: usernameFromProject(s.Project), + ProjectStatus: statusFromProject(s.Project), + BackState: s, + }, bot.EditMessage) + + case data == "back_to_project": + s.Enter(b, bot.EditMessage) + + case strings.HasPrefix(data, "link_type:"): + var newType string + if s.Project.PurchaseInviteTypeDefault == "public" { + newType = "approval" + } else { + newType = "public" + } + + updatedProject, err := b.Backend.UpdateProjectInviteLinkType(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.Project.ID, newType) + + if err != nil { + b.SendNew(fmt.Sprintf("❌ Ошибка при изменении типа ссылки: %v", err), Keyboard( + Row(Button("← Назад", "back_to_project")), + )) + return + } + + s.Project = updatedProject + s.Enter(b, bot.EditMessage) + + default: + // Для остальных кнопок показываем заглушку + b.Edit(fmt.Sprintf("🚧 Функция в разработке\n\nCallback: %s", data), Keyboard( + Row(Button("← Назад", "back_to_project")), + )) + } + return +} + +func (s *ProjectDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *ProjectDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *ProjectDetails) Exit() {} + +func (s *ProjectDetails) transitionWithNewMessage(b *bot.Bot, next bot.State) { + if b.LastMessageID != 0 { + if _, err := b.DeleteMessage(b.ChatID, b.LastMessageID); err != nil { + log.Error().Err(err).Msg("DeleteMessage failed") + } else { + b.LastMessageID = 0 + } + } + b.SetState(next, bot.NewMessage) +} + +func usernameFromProject(p *backend.Project) string { + if p == nil || p.Username == nil { + return "" + } + return *p.Username +} + +func statusFromProject(p *backend.Project) string { + if p == nil { + return "" + } + return p.Status +} diff --git a/tg_bot/screens/project_header.go b/tg_bot/screens/project_header.go new file mode 100644 index 0000000..99c3f08 --- /dev/null +++ b/tg_bot/screens/project_header.go @@ -0,0 +1,442 @@ +package screens + +import ( + "bytes" + _ "embed" + "fmt" + "image" + "image/color" + "image/draw" + "image/jpeg" + _ "image/png" + "strings" + "sync" + "time" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/rs/zerolog/log" + xdraw "golang.org/x/image/draw" + "golang.org/x/image/font" + "golang.org/x/image/font/opentype" + "golang.org/x/image/math/fixed" +) + +//go:embed assets/fonts/JetBrainsMono-Bold.ttf +var jetBrainsMonoBold []byte + +//go:embed assets/fonts/JetBrainsMono-Regular.ttf +var jetBrainsMonoRegular []byte + +const projectHeaderCacheTTL = 30 * time.Minute +const projectHeaderCacheVersion = "img-v1" +const projectHeaderCacheMaxBytes = 32 * 1024 * 1024 + +type projectHeaderCacheEntry struct { + bytes []byte + sizeBytes int + uniqueID string + fetchedAt time.Time +} + +var projectHeaderCache = struct { + mu sync.Mutex + items map[string]projectHeaderCacheEntry + total int +}{ + items: make(map[string]projectHeaderCacheEntry), +} + +var projectHeaderLastMediaKey = struct { + mu sync.Mutex + items map[string]string +}{ + items: make(map[string]string), +} + +func updateProjectHeaderMedia(b *bot.Bot, messageID int, caption string, keyboard echotron.InlineKeyboardMarkup, chatID int64, title string, username string, status string) { + if messageID == 0 || chatID == 0 || title == "" { + return + } + + cacheKey := projectHeaderCacheKey(chatID, username, status) + if cached, ok := getProjectHeaderFromCache(cacheKey); ok && time.Since(cached.fetchedAt) < projectHeaderCacheTTL { + mediaKey := cacheKey + ":" + cached.uniqueID + if shouldSkipMediaEdit(chatID, messageID, mediaKey) { + return + } + if err := editProjectHeaderMedia(b, messageID, caption, keyboard, cached.bytes, chatID); err == nil { + setLastMediaKey(chatID, messageID, mediaKey) + b.SetLastMessageIsMedia(true) + } + return + } + + chatInfo, err := b.GetChat(chatID) + if err != nil { + log.Error().Err(err).Int64("chat_id", chatID).Msg("GetChat failed") + return + } + if chatInfo.Result == nil || chatInfo.Result.Photo == nil || chatInfo.Result.Photo.SmallFileID == "" { + return + } + + uniqueID := chatInfo.Result.Photo.SmallFileUniqueID + if cached, ok := getProjectHeaderFromCache(cacheKey); ok && cached.uniqueID == uniqueID && len(cached.bytes) > 0 { + setProjectHeaderCache(cacheKey, cached.bytes, uniqueID) + mediaKey := cacheKey + ":" + uniqueID + if shouldSkipMediaEdit(chatID, messageID, mediaKey) { + return + } + if err := editProjectHeaderMedia(b, messageID, caption, keyboard, cached.bytes, chatID); err == nil { + setLastMediaKey(chatID, messageID, mediaKey) + b.SetLastMessageIsMedia(true) + } + return + } + + photoBytes, err := b.DownloadFileBytes(chatInfo.Result.Photo.SmallFileID) + if err != nil { + log.Error().Err(err).Int64("chat_id", chatID).Msg("Download chat photo failed") + return + } + + compositeBytes, err := buildProjectHeaderImage(photoBytes, title, username, status) + if err != nil { + log.Error().Err(err).Int64("chat_id", chatID).Msg("Build project header image failed") + return + } + setProjectHeaderCache(cacheKey, compositeBytes, uniqueID) + + mediaKey := cacheKey + ":" + uniqueID + if !shouldSkipMediaEdit(chatID, messageID, mediaKey) { + if err := editProjectHeaderMedia(b, messageID, caption, keyboard, compositeBytes, chatID); err == nil { + setLastMediaKey(chatID, messageID, mediaKey) + b.SetLastMessageIsMedia(true) + } + } +} + +func editProjectHeaderMedia(b *bot.Bot, messageID int, caption string, keyboard echotron.InlineKeyboardMarkup, compositeBytes []byte, chatID int64) error { + if len(compositeBytes) == 0 { + return nil + } + + media := echotron.InputMediaPhoto{ + Type: echotron.MediaTypePhoto, + Media: echotron.NewInputFileBytes("project_header.jpg", compositeBytes), + Caption: caption, + ParseMode: echotron.HTML, + } + + _, err := b.EditMessageMedia( + echotron.NewMessageID(b.ChatID, messageID), + media, + &echotron.MessageMediaOptions{ + ReplyMarkup: keyboard, + }, + ) + if err != nil { + if strings.Contains(err.Error(), "message is not modified") { + log.Info().Int64("chat_id", chatID).Msg("EditMessageMedia not modified") + return nil + } + log.Error().Err(err).Int64("chat_id", chatID).Msg("EditMessageMedia failed") + return err + } + return nil +} + +func shouldSkipMediaEdit(chatID int64, messageID int, mediaKey string) bool { + key := fmt.Sprintf("%d:%d", chatID, messageID) + projectHeaderLastMediaKey.mu.Lock() + defer projectHeaderLastMediaKey.mu.Unlock() + lastKey, ok := projectHeaderLastMediaKey.items[key] + return ok && lastKey == mediaKey +} + +func setLastMediaKey(chatID int64, messageID int, mediaKey string) { + key := fmt.Sprintf("%d:%d", chatID, messageID) + projectHeaderLastMediaKey.mu.Lock() + defer projectHeaderLastMediaKey.mu.Unlock() + projectHeaderLastMediaKey.items[key] = mediaKey +} + +func projectHeaderCacheKey(chatID int64, username string, status string) string { + return fmt.Sprintf("%s:%d:%s:%s", projectHeaderCacheVersion, chatID, strings.ToUpper(username), strings.ToUpper(status)) +} + +func getProjectHeaderFromCache(key string) (projectHeaderCacheEntry, bool) { + projectHeaderCache.mu.Lock() + defer projectHeaderCache.mu.Unlock() + entry, ok := projectHeaderCache.items[key] + return entry, ok +} + +func setProjectHeaderCache(key string, value []byte, uniqueID string) { + projectHeaderCache.mu.Lock() + defer projectHeaderCache.mu.Unlock() + if existing, ok := projectHeaderCache.items[key]; ok { + projectHeaderCache.total -= existing.sizeBytes + } + projectHeaderCache.items[key] = projectHeaderCacheEntry{ + bytes: value, + sizeBytes: len(value), + uniqueID: uniqueID, + fetchedAt: time.Now(), + } + projectHeaderCache.total += len(value) + projectHeaderCacheEvictIfNeeded() +} + +func projectHeaderCacheEvictIfNeeded() { + for projectHeaderCache.total > projectHeaderCacheMaxBytes && len(projectHeaderCache.items) > 0 { + var oldestKey string + var oldestTime time.Time + first := true + for key, entry := range projectHeaderCache.items { + if first || entry.fetchedAt.Before(oldestTime) { + oldestKey = key + oldestTime = entry.fetchedAt + first = false + } + } + if oldestKey == "" { + return + } + projectHeaderCache.total -= projectHeaderCache.items[oldestKey].sizeBytes + delete(projectHeaderCache.items, oldestKey) + } +} + +func buildProjectHeaderImage(photoBytes []byte, title string, username string, status string) ([]byte, error) { + const ( + canvasW = 720 + canvasH = 260 + ) + + srcImg, _, err := image.Decode(bytes.NewReader(photoBytes)) + if err != nil { + return nil, err + } + + canvas := image.NewRGBA(image.Rect(0, 0, canvasW, canvasH)) + drawGradient(canvas, color.RGBA{R: 6, G: 8, B: 16, A: 255}, color.RGBA{R: 18, G: 10, B: 28, A: 255}) + + avatarSize := 168 + avatarX := 24 + avatarY := (canvasH - avatarSize) / 2 + + cropped := cropCenterSquare(srcImg) + scaled := image.NewRGBA(image.Rect(0, 0, avatarSize, avatarSize)) + xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), cropped, cropped.Bounds(), xdraw.Over, nil) + + mask := circleMask(avatarSize) + draw.DrawMask( + canvas, + image.Rect(avatarX, avatarY, avatarX+avatarSize, avatarY+avatarSize), + scaled, + image.Point{}, + mask, + image.Point{}, + draw.Over, + ) + + textColor := image.NewUniform(color.RGBA{R: 245, G: 247, B: 250, A: 255}) + usernameColor := image.NewUniform(color.RGBA{R: 84, G: 156, B: 255, A: 255}) + textX := avatarX + avatarSize + 24 + textMaxWidth := canvasW - textX - 24 + + titleSize := fitFontSize(jetBrainsMonoBold, title, textMaxWidth, float64(canvasH)*0.22, 20) + titleFace, err := loadFontFace(jetBrainsMonoBold, titleSize) + if err != nil { + return nil, err + } + defer titleFace.Close() + + usernameText := formatUsername(username) + statusText, statusColor := statusInfo(status) + statusLine := usernameText + if statusText != "" { + if statusLine != "" { + statusLine += " | " + } + statusLine += statusText + } + + statusSize := titleSize * 0.6 + if statusSize < 14 { + statusSize = 14 + } + if statusLine != "" { + statusSize = fitFontSize(jetBrainsMonoRegular, statusLine, textMaxWidth, statusSize, 12) + } + statusFace, err := loadFontFace(jetBrainsMonoRegular, statusSize) + if err != nil { + return nil, err + } + defer statusFace.Close() + + titleMetrics := titleFace.Metrics() + statusMetrics := statusFace.Metrics() + lineGapRatio := 0.03 + lineGap := int(float64(canvasH) * lineGapRatio) + totalHeight := titleMetrics.Height.Ceil() + if statusLine != "" { + totalHeight += lineGap + statusMetrics.Height.Ceil() + } + verticalOffsetRatio := 0.02 + startY := (canvasH-totalHeight)/2 + titleMetrics.Ascent.Ceil() + int(float64(canvasH)*verticalOffsetRatio) + + drawText(canvas, titleFace, textColor, textX, startY, title) + if statusLine != "" { + statusY := startY + titleMetrics.Descent.Ceil() + lineGap + statusMetrics.Ascent.Ceil() + drawStatusLine(canvas, statusFace, textX, statusY, usernameText, statusText, usernameColor, textColor, statusColor) + } + var out bytes.Buffer + if err := jpeg.Encode(&out, canvas, &jpeg.Options{Quality: 85}); err != nil { + return nil, err + } + return out.Bytes(), nil +} + +func cropCenterSquare(img image.Image) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + size := w + if h < w { + size = h + } + x0 := b.Min.X + (w-size)/2 + y0 := b.Min.Y + (h-size)/2 + cropRect := image.Rect(x0, y0, x0+size, y0+size) + + if sub, ok := img.(interface { + SubImage(r image.Rectangle) image.Image + }); ok { + return sub.SubImage(cropRect) + } + + dst := image.NewRGBA(image.Rect(0, 0, size, size)) + draw.Draw(dst, dst.Bounds(), img, cropRect.Min, draw.Src) + return dst +} + +func circleMask(diameter int) *image.Alpha { + mask := image.NewAlpha(image.Rect(0, 0, diameter, diameter)) + r := float64(diameter) / 2 + cx := r + cy := r + for y := 0; y < diameter; y++ { + for x := 0; x < diameter; x++ { + dx := float64(x) + 0.5 - cx + dy := float64(y) + 0.5 - cy + if dx*dx+dy*dy <= r*r { + mask.SetAlpha(x, y, color.Alpha{A: 255}) + } + } + } + return mask +} +func drawGradient(img *image.RGBA, top, bottom color.RGBA) { + b := img.Bounds() + h := b.Dy() + w := b.Dx() + for y := 0; y < h; y++ { + t := float64(y) / float64(h-1) + r := uint8(float64(top.R)*(1-t) + float64(bottom.R)*t) + g := uint8(float64(top.G)*(1-t) + float64(bottom.G)*t) + bb := uint8(float64(top.B)*(1-t) + float64(bottom.B)*t) + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{R: r, G: g, B: bb, A: 255}) + } + } +} + +func loadFontFace(fontData []byte, size float64) (font.Face, error) { + ft, err := opentype.Parse(fontData) + if err != nil { + return nil, err + } + return opentype.NewFace(ft, &opentype.FaceOptions{ + Size: size, + DPI: 72, + Hinting: font.HintingFull, + }) +} + +func fitFontSize(fontData []byte, text string, maxWidth int, startSize float64, minSize float64) float64 { + size := startSize + for size >= minSize { + face, err := loadFontFace(fontData, size) + if err != nil { + return size + } + width := font.MeasureString(face, text).Ceil() + face.Close() + if width <= maxWidth { + return size + } + size -= 2 + } + return minSize +} + +func drawText(dst *image.RGBA, face font.Face, src image.Image, x int, y int, text string) { + d := &font.Drawer{ + Dst: dst, + Src: src, + Face: face, + Dot: fixed.P(x, y), + } + d.DrawString(text) +} + +func formatUsername(username string) string { + username = strings.TrimSpace(username) + if username == "" { + return "" + } + if strings.HasPrefix(username, "@") { + return username + } + return "@" + username +} + +func statusInfo(status string) (string, color.RGBA) { + switch status { + case "active": + return "Активный", color.RGBA{R: 66, G: 211, B: 114, A: 255} + case "inactive": + return "Неактивен", color.RGBA{R: 160, G: 170, B: 180, A: 255} + case "archived": + return "Архивный", color.RGBA{R: 180, G: 180, B: 180, A: 255} + case "paused": + return "Приостановлен", color.RGBA{R: 245, G: 179, B: 66, A: 255} + default: + if strings.TrimSpace(status) == "" { + return "", color.RGBA{} + } + return status, color.RGBA{R: 160, G: 170, B: 180, A: 255} + } +} + +func drawStatusLine(dst *image.RGBA, face font.Face, x int, y int, usernameText string, statusText string, usernameColor image.Image, textColor image.Image, statusColor color.RGBA) { + drawX := x + if usernameText != "" { + drawText(dst, face, usernameColor, drawX, y, usernameText) + drawX += font.MeasureString(face, usernameText).Ceil() + } + if statusText != "" { + separator := " | " + if usernameText != "" { + drawText(dst, face, textColor, drawX, y, separator) + drawX += font.MeasureString(face, separator).Ceil() + } + statusSymbol := "●" + statusColorImg := image.NewUniform(statusColor) + drawText(dst, face, statusColorImg, drawX, y, statusSymbol) + drawX += font.MeasureString(face, statusSymbol).Ceil() + 6 + drawText(dst, face, textColor, drawX, y, statusText) + } +} diff --git a/tg_bot/screens/purchase_optional_details.go b/tg_bot/screens/purchase_optional_details.go new file mode 100644 index 0000000..96faf3a --- /dev/null +++ b/tg_bot/screens/purchase_optional_details.go @@ -0,0 +1,2722 @@ +package screens + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + ui2 "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" + "github.com/rs/zerolog/log" +) + +type PurchaseOptionalDetails struct { + ProjectID string + ProjectTitle string + ProjectDefaultLinkType string + CreativeID string + CreativeTitle string + Channels []PurchaseChannelInput + PlacementDateTime *time.Time + PaymentDate *time.Time + CostType string + CostValue *float64 + CostBeforeType string + CostBeforeBargain *CostEntry + PurchaseType string + Format string + TopTimeMinutes *int + FeedTimeMinutes *int // nil = не указан, 0 = без удаления + TopTimeByChannel map[string]*int + FeedTimeByChannel map[string]*int + CustomFormatTopUnit string // "hours" | "minutes" + CustomFormatFeedUnit string // "hours" | "days" + CustomFormatTopValue *int // промежуточное значение (в минутах) + Comment string + InviteLinkType string + InputMode string + CurrentParam string + CurrentChannel string + ParamPage int + ReturnMode string + PlacementMode string + PaymentDateMode string + CostMode string + CostBeforeMode string + PurchaseTypeMode string + CommentMode string + FormatMode string + InviteLinkTypeMode string + PlacementByChannel map[string]*time.Time + PaymentDateByChannel map[string]*time.Time + CostByChannel map[string]CostEntry + CostBeforeByChannel map[string]CostEntry + PurchaseTypeByChannel map[string]string + CommentByChannel map[string]string + FormatByChannel map[string]string + InviteLinkTypeByChannel map[string]string + PlacementCopy *time.Time + PaymentDateCopy *time.Time + CostCopy *CostEntry + CostBeforeCopy *CostEntry + PurchaseTypeCopy *string + CommentCopy *string + FormatCopy *string + InviteLinkTypeCopy *string + ChannelEditMode string // "edit" или "copy" + BackState bot.State +} + +type CostEntry struct { + Type string + Value *float64 +} + +func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) { + s.ensureDefaults() + if s.InputMode != "" { + if s.InputMode == "mode_select" { + s.renderModeSelect(b, mode) + return + } + if s.renderParamScreens(b, mode) { + return + } + s.renderInputPrompt(b, mode) + return + } + + text := "Страница создания закупа и необязательные составляющие" + + text += "\n\n" + text += s.formatOptionalSummary() + + var rows [][]echotron.InlineKeyboardButton + + // Кнопка переключения режима (только если несколько каналов) - на первой строке + if len(s.Channels) > 1 { + rows = append(rows, Row(s.globalModeButton())) + } + + rows = append(rows, Row( + s.placementButton(), + s.paymentButton(), + )) + rows = append(rows, Row( + s.typeButton(), + s.commentButton(), + )) + rows = append(rows, Row( + s.costButton(), + s.costBeforeButton(), + )) + rows = append(rows, Row( + s.formatButton(), + s.inviteLinkTypeButton(), + )) + + rows = append(rows, Row(Button("Назад", "back"), Button("Далее", "next"))) + + keyboard := Keyboard(rows...) + + b.Render(text, keyboard, mode) +} + +func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + s.ensureDefaults() + + switch u.CallbackQuery.Data { + case "opt_datetime": + // Открываем редактор в текущем режиме + if s.PlacementMode == "per_channel" && len(s.Channels) > 1 { + s.InputMode = "placement_channels" + s.Enter(b, bot.EditMessage) + } else { + s.setParamMode("placement", "common") + s.openCommonEditor(b, "placement") + } + + case "opt_payment_date": + // Открываем редактор в текущем режиме + if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 { + s.InputMode = "payment_date_channels" + s.Enter(b, bot.EditMessage) + } else { + s.setParamMode("payment_date", "common") + b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{ + Title: "Дата оплаты", + Key: "payment_date", + IncludeTime: false, + AllowPast: true, + Selected: s.PaymentDate, + BackState: s, + }), bot.EditMessage) + } + + case "opt_cost": + // Открываем редактор в текущем режиме + if s.CostMode == "per_channel" && len(s.Channels) > 1 { + s.InputMode = "cost_channels" + s.Enter(b, bot.EditMessage) + } else { + s.setParamMode("cost", "common") + s.openCommonEditor(b, "cost") + } + + case "opt_type": + // Открываем редактор в текущем режиме + if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 { + s.InputMode = "purchase_type_channels" + s.Enter(b, bot.EditMessage) + } else { + s.InputMode = "type" + s.Enter(b, bot.EditMessage) + } + + case "opt_format": + // Открываем редактор в текущем режиме + if s.FormatMode == "per_channel" && len(s.Channels) > 1 { + s.InputMode = "format_channels" + s.Enter(b, bot.EditMessage) + } else { + s.setParamMode("format", "common") + s.openCommonEditor(b, "format") + } + + case "opt_invite_link_type": + // Открываем редактор в текущем режиме + if s.InviteLinkTypeMode == "per_channel" && len(s.Channels) > 1 { + s.InputMode = "invite_link_type_channels" + s.Enter(b, bot.EditMessage) + } else { + s.InputMode = "invite_link_type" + s.Enter(b, bot.EditMessage) + } + + case "opt_comment": + // Открываем редактор в текущем режиме + if s.CommentMode == "per_channel" && len(s.Channels) > 1 { + s.InputMode = "comment_channels" + s.Enter(b, bot.EditMessage) + } else { + s.InputMode = "comment" + s.Enter(b, bot.EditMessage) + } + + case "delete_comment": + s.Comment = "" + s.Enter(b, bot.EditMessage) + + case "format_custom": + s.InputMode = "format_custom" + s.CustomFormatTopUnit = "hours" + s.CustomFormatFeedUnit = "hours" + s.CustomFormatTopValue = nil + s.Enter(b, bot.EditMessage) + + case "cost_type_toggle": + s.toggleCostType() + s.Enter(b, bot.EditMessage) + + case "cost_before_type_toggle": + s.toggleCostBeforeType() + s.Enter(b, bot.EditMessage) + + case "cost_value": + s.InputMode = "cost_value" + s.Enter(b, bot.EditMessage) + + case "cost_before": + // Открываем редактор в текущем режиме + if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 { + s.InputMode = "cost_before_channels" + s.Enter(b, bot.EditMessage) + } else { + s.setParamMode("cost_before", "common") + s.openCommonEditor(b, "cost_before") + } + + case "delete_cost_before": + s.CostBeforeBargain = nil + s.Enter(b, bot.EditMessage) + + case "back_to_optional": + s.InputMode = "" + s.ReturnMode = "" + s.CurrentChannel = "" + s.CurrentParam = "" + s.Enter(b, bot.EditMessage) + case "back_to_return": + if s.ReturnMode != "" { + s.InputMode = s.ReturnMode + s.ReturnMode = "" + } else { + s.InputMode = "" + } + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + + case "back": + // Сохраняем текущее состояние перед возвратом назад + b.SetState(&SelectChannelsForPurchase{ + ProjectID: s.ProjectID, + ProjectTitle: s.ProjectTitle, + ProjectDefaultLinkType: s.ProjectDefaultLinkType, + CreativeID: s.CreativeID, + CreativeTitle: s.CreativeTitle, + Channels: s.Channels, + Duplicates: []string{}, + ParsingErrors: []ParseError{}, + OptionalDetailsState: s, // Сохраняем текущее состояние + BackState: s.BackState, + }, bot.EditMessage) + case "next": + jwt := b.Session.JWT + if jwt == "" { + log.Error().Msg("JWT is empty in session") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard()) + return + } + s.createPurchase(b, jwt) + case "done": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + default: + if u.CallbackQuery.Data == "noop" { + // Пустая кнопка - ничего не делаем + return + } + if s.handleParamCallback(b, u.CallbackQuery.Data) { + return + } + if u.CallbackQuery.Data == "toggle_global_mode" { + // Открываем меню выбора параметра для переключения режима + s.InputMode = "mode_select" + s.Enter(b, bot.EditMessage) + return + } + if strings.HasPrefix(u.CallbackQuery.Data, "channel_mode:") { + // Переключаем режим редактирования каналов + mode := strings.TrimPrefix(u.CallbackQuery.Data, "channel_mode:") + s.ChannelEditMode = mode + s.Enter(b, bot.EditMessage) + return + } + if strings.HasPrefix(u.CallbackQuery.Data, "toggle_param_mode:") { + // Переключаем режим конкретного параметра + param := strings.TrimPrefix(u.CallbackQuery.Data, "toggle_param_mode:") + currentMode := s.paramMode(param) + if currentMode == "common" { + s.setParamMode(param, "per_channel") + // Копируем значение из общего во все каналы + s.copyCommonValueToChannels(param) + } else { + s.setParamMode(param, "common") + } + // Остаемся в меню выбора режима + s.Enter(b, bot.EditMessage) + return + } + if strings.HasPrefix(u.CallbackQuery.Data, "type:") { + value := strings.TrimPrefix(u.CallbackQuery.Data, "type:") + // Нормализуем значение для БД + var normalized string + switch value { + case "mutual_pr": + normalized = "взаимный пиар" + case "standard": + normalized = "стандарт" + } + if s.CurrentChannel != "" { + s.PurchaseTypeByChannel[s.CurrentChannel] = normalized + } else { + s.PurchaseType = normalized + } + if s.ReturnMode != "" { + s.InputMode = s.ReturnMode + s.ReturnMode = "" + } else { + s.InputMode = "" + } + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + return + } + if strings.HasPrefix(u.CallbackQuery.Data, "format_preset:") { + parts := strings.Split(strings.TrimPrefix(u.CallbackQuery.Data, "format_preset:"), ":") + if len(parts) == 2 { + topMin, err1 := strconv.Atoi(parts[0]) + feedMin, err2 := strconv.Atoi(parts[1]) + if err1 == nil && err2 == nil { + s.setFormatPreset(topMin, feedMin) + } + } + if s.ReturnMode != "" { + s.InputMode = s.ReturnMode + s.ReturnMode = "" + } else { + s.InputMode = "" + } + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + return + } + if strings.HasPrefix(u.CallbackQuery.Data, "format_custom_top:") { + valStr := strings.TrimPrefix(u.CallbackQuery.Data, "format_custom_top:") + minutes, err := strconv.Atoi(valStr) + if err == nil && minutes > 0 { + s.CustomFormatTopValue = &minutes + s.InputMode = "format_custom_feed" + } + s.Enter(b, bot.EditMessage) + return + } + if strings.HasPrefix(u.CallbackQuery.Data, "format_custom_feed:") { + valStr := strings.TrimPrefix(u.CallbackQuery.Data, "format_custom_feed:") + feedMinutes, err := strconv.Atoi(valStr) + if err == nil && s.CustomFormatTopValue != nil { + s.setFormatPreset(*s.CustomFormatTopValue, feedMinutes) + s.CustomFormatTopValue = nil + if s.ReturnMode != "" { + s.InputMode = s.ReturnMode + s.ReturnMode = "" + } else { + s.InputMode = "" + } + s.CurrentChannel = "" + } + s.Enter(b, bot.EditMessage) + return + } + if u.CallbackQuery.Data == "format_custom_top_toggle_unit" { + if s.CustomFormatTopUnit == "hours" { + s.CustomFormatTopUnit = "minutes" + } else { + s.CustomFormatTopUnit = "hours" + } + s.Enter(b, bot.EditMessage) + return + } + if u.CallbackQuery.Data == "format_custom_feed_toggle_unit" { + if s.CustomFormatFeedUnit == "hours" { + s.CustomFormatFeedUnit = "days" + } else { + s.CustomFormatFeedUnit = "hours" + } + s.Enter(b, bot.EditMessage) + return + } + if u.CallbackQuery.Data == "format_custom_back_to_select" { + s.InputMode = "format_select" + s.Enter(b, bot.EditMessage) + return + } + if u.CallbackQuery.Data == "format_custom_back_to_top" { + s.InputMode = "format_custom" + s.Enter(b, bot.EditMessage) + return + } + if strings.HasPrefix(u.CallbackQuery.Data, "format:") { + value := strings.TrimPrefix(u.CallbackQuery.Data, "format:") + s.setFormatValue(value) + if s.ReturnMode != "" { + s.InputMode = s.ReturnMode + s.ReturnMode = "" + } else { + s.InputMode = "" + } + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + return + } + if strings.HasPrefix(u.CallbackQuery.Data, "invite_link_type:") { + value := strings.TrimPrefix(u.CallbackQuery.Data, "invite_link_type:") + s.setInviteLinkTypeValue(value) + if s.ReturnMode != "" { + s.InputMode = s.ReturnMode + s.ReturnMode = "" + } else { + s.InputMode = "" + } + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + return + } + s.Enter(b, bot.EditMessage) + } +} + +func (s *PurchaseOptionalDetails) HandleMessage(b *bot.Bot, u *echotron.Update) { + if s.InputMode == "" || u.Message == nil || u.Message.Text == "" { + return + } + s.ensureDefaults() + + text := strings.TrimSpace(u.Message.Text) + if text == "" { + return + } + + switch s.InputMode { + case "format_custom": + value, err := strconv.Atoi(text) + if err != nil || value <= 0 { + return + } + // Конвертируем в минуты по текущей единице + if s.CustomFormatTopUnit == "hours" { + value = value * 60 + } + s.CustomFormatTopValue = &value + s.InputMode = "format_custom_feed" + s.Enter(b, bot.NewMessage) + return + case "format_custom_feed": + value, err := strconv.Atoi(text) + if err != nil || value <= 0 { + return + } + // Конвертируем в минуты по текущей единице + if s.CustomFormatFeedUnit == "days" { + value = value * 24 * 60 + } else { + value = value * 60 + } + if s.CustomFormatTopValue != nil { + s.setFormatPreset(*s.CustomFormatTopValue, value) + s.CustomFormatTopValue = nil + } + case "comment": + if s.CurrentChannel != "" { + s.CommentByChannel[s.CurrentChannel] = text + } else { + s.Comment = text + } + case "cost_value": + value, err := strconv.ParseFloat(strings.ReplaceAll(text, ",", "."), 64) + if err != nil { + s.renderInputPrompt(b, bot.EditMessage) + return + } + s.setCostValue(value) + case "cost_before_value": + value, err := strconv.ParseFloat(strings.ReplaceAll(text, ",", "."), 64) + if err != nil { + s.renderInputPrompt(b, bot.EditMessage) + return + } + s.setCostBeforeValue(value) + } + + if s.ReturnMode != "" { + s.InputMode = s.ReturnMode + s.ReturnMode = "" + } else { + s.InputMode = "" + } + s.CurrentChannel = "" + s.Enter(b, bot.NewMessage) +} + +func (s *PurchaseOptionalDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *PurchaseOptionalDetails) Exit() {} + +func (s *PurchaseOptionalDetails) SetDateTimeSelection(key string, value time.Time) { + switch key { + case "placement_datetime": + s.PlacementDateTime = &value + case "payment_date": + s.PaymentDate = &value + default: + if strings.HasPrefix(key, "placement_datetime:") { + username := strings.TrimPrefix(key, "placement_datetime:") + s.ensureDefaults() + s.PlacementByChannel[username] = &value + } + if strings.HasPrefix(key, "payment_date:") { + username := strings.TrimPrefix(key, "payment_date:") + s.ensureDefaults() + s.PaymentDateByChannel[username] = &value + } + } +} + +func (s *PurchaseOptionalDetails) formatOptionalSummary() string { + var lines []string + + if s.hasPlacementValue() { + lines = append(lines, fmt.Sprintf("Дата размещения: %s%s", s.formatPlacementSummary(), s.formatPlacementDetails())) + } + if s.hasPaymentDateValue() { + lines = append(lines, fmt.Sprintf("Дата оплаты: %s%s", s.formatPaymentDateSummary(), s.formatPaymentDateDetails())) + } + if s.hasCostValue() { + lines = append(lines, fmt.Sprintf("Стоимость: %s%s", s.formatCostSummary(), s.formatCostDetails())) + } + if s.hasCostBeforeValue() { + lines = append(lines, fmt.Sprintf("Стоимость до торга: %s%s", s.formatCostBeforeSummary(), s.formatCostBeforeDetails())) + } + if s.hasPurchaseTypeValue() { + lines = append(lines, fmt.Sprintf("Тип закупа: %s%s", s.formatPurchaseTypeSummary(), s.formatPurchaseTypeDetails())) + } + if s.hasFormatValue() { + lines = append(lines, fmt.Sprintf("Формат: %s%s", s.formatFormatSummary(), s.formatFormatDetails())) + } + if s.hasInviteLinkTypeValue() { + lines = append(lines, fmt.Sprintf("Тип ссылки: %s%s", s.formatInviteLinkTypeSummary(), s.formatInviteLinkTypeDetails())) + } + if s.hasCommentValue() { + lines = append(lines, fmt.Sprintf("Комментарий: %s%s", s.formatCommentSummary(), s.formatCommentDetails())) + } + + if len(lines) == 0 { + return "Добавьте параметры ниже" + } + + return strings.Join(lines, "\n\n") +} + +func (s *PurchaseOptionalDetails) formatDateTime(value *time.Time) string { + if value == nil { + return "—" + } + local := value.In(ui2.MskLocation) + return fmt.Sprintf("%s %02d %s %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()), local.Format("15:04")) +} + +func (s *PurchaseOptionalDetails) formatDate(value *time.Time) string { + if value == nil { + return "—" + } + local := value.In(ui2.MskLocation) + return fmt.Sprintf("%s %02d %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month())) +} + +func (s *PurchaseOptionalDetails) formatText(value string) string { + if value == "" { + return "—" + } + return value +} + +func (s *PurchaseOptionalDetails) formatCostValue() string { + if s.CostValue == nil { + return "—" + } + return fmt.Sprintf("%s %.0f₽", s.costTypeLabel(), *s.CostValue) +} + +func (s *PurchaseOptionalDetails) formatCostBefore() string { + if s.CostBeforeBargain == nil || s.CostBeforeBargain.Value == nil { + return "—" + } + label := s.costBeforeTypeLabelForEntry(*s.CostBeforeBargain) + return fmt.Sprintf("%s %.0f₽", label, *s.CostBeforeBargain.Value) +} + +func (s *PurchaseOptionalDetails) costTypeLabel() string { + if s.CostType == "cpm" { + return "СРМ" + } + return "Фикс" +} + +func (s *PurchaseOptionalDetails) costBeforeTypeLabel() string { + costType := s.CostBeforeType + if costType == "" && s.CostBeforeBargain != nil { + costType = s.CostBeforeBargain.Type + } + if costType == "cpm" { + return "СРМ" + } + return "Фикс" +} + +func (s *PurchaseOptionalDetails) placementButton() echotron.InlineKeyboardButton { + icon := "+" + if s.hasPlacementValue() { + icon = "✎" + } + modeIcon := "" + if len(s.Channels) > 1 && s.PlacementMode == "per_channel" { + modeIcon = " 👥" + } + return Button(fmt.Sprintf("%s Дата размещения%s", icon, modeIcon), "opt_datetime") +} + +func (s *PurchaseOptionalDetails) paymentButton() echotron.InlineKeyboardButton { + icon := "+" + if s.hasPaymentDateValue() { + icon = "✎" + } + modeIcon := "" + if len(s.Channels) > 1 && s.PaymentDateMode == "per_channel" { + modeIcon = " 👥" + } + return Button(fmt.Sprintf("%s Дата оплаты%s", icon, modeIcon), "opt_payment_date") +} + +func (s *PurchaseOptionalDetails) costButton() echotron.InlineKeyboardButton { + icon := "+" + if s.hasCostValue() { + icon = "✎" + } + return Button(fmt.Sprintf("%s Стоимость", icon), "opt_cost") +} + +func (s *PurchaseOptionalDetails) costBeforeButton() echotron.InlineKeyboardButton { + icon := "+" + if s.hasCostBeforeValue() { + icon = "✎" + } + return Button(fmt.Sprintf("%s До торга", icon), "cost_before") +} + +func (s *PurchaseOptionalDetails) typeButton() echotron.InlineKeyboardButton { + icon := "+" + if s.PurchaseType != "" { + icon = "✎" + } + modeIcon := "" + if len(s.Channels) > 1 && s.PurchaseTypeMode == "per_channel" { + modeIcon = " 👥" + } + return Button(fmt.Sprintf("%s Тип%s", icon, modeIcon), "opt_type") +} + +func (s *PurchaseOptionalDetails) formatButton() echotron.InlineKeyboardButton { + icon := "+" + if s.hasFormatValue() { + icon = "✎" + } + modeIcon := "" + if len(s.Channels) > 1 && s.FormatMode == "per_channel" { + modeIcon = " 👥" + } + return Button(fmt.Sprintf("%s Формат%s", icon, modeIcon), "opt_format") +} + +func (s *PurchaseOptionalDetails) commentButton() echotron.InlineKeyboardButton { + icon := "+" + if s.Comment != "" { + icon = "✎" + } + modeIcon := "" + if len(s.Channels) > 1 && s.CommentMode == "per_channel" { + modeIcon = " 👥" + } + return Button(fmt.Sprintf("%s Комментарий%s", icon, modeIcon), "opt_comment") +} + +func (s *PurchaseOptionalDetails) inviteLinkTypeButton() echotron.InlineKeyboardButton { + icon := "+" + if s.hasInviteLinkTypeValue() { + icon = "✎" + } + modeIcon := "" + if len(s.Channels) > 1 && s.InviteLinkTypeMode == "per_channel" { + modeIcon = " 👥" + } + label := "Тип ссылки" + if s.InviteLinkType != "" { + label += ": " + s.inviteLinkTypeLabel(s.InviteLinkType) + } + return Button(fmt.Sprintf("%s %s%s", icon, label, modeIcon), "opt_invite_link_type") +} + +func (s *PurchaseOptionalDetails) globalModeButton() echotron.InlineKeyboardButton { + return Button("⚙️ Режимы параметров", "toggle_global_mode") +} + +func (s *PurchaseOptionalDetails) commentDeleteRow() []echotron.InlineKeyboardButton { + if s.Comment == "" { + return nil + } + return Row(Button("⌫ Удалить комментарий", "delete_comment")) +} + +func (s *PurchaseOptionalDetails) costBeforeDeleteRow() []echotron.InlineKeyboardButton { + if s.CostBeforeBargain == nil || s.CostBeforeMode == "per_channel" { + return nil + } + return Row(Button("⌫ Удалить до торга", "delete_cost_before")) +} + +func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderMode) { + text := "Дополнительно\n\n" + switch s.InputMode { + case "type": + text += "Выберите тип закупа" + keyboard := Keyboard( + Row(Button("Взаимный пиар", "type:mutual_pr"), Button("Стандарт", "type:standard")), + Row(Button("← Назад", "back_to_optional")), + ) + if mode == bot.EditMessage { + b.Edit(text, keyboard) + } else { + b.SendNew(text, keyboard) + } + return + case "cost_value": + text += "Ввод стоимости\n\nНапример: 15000" + if s.CurrentChannel != "" { + text += fmt.Sprintf("\n\nКанал: %s", channelLabelByKey(s.Channels, s.CurrentChannel)) + } + + typeLabel := s.costTypeLabelForCurrent() + keyboard := Keyboard( + Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_type_toggle")), + Row(Button("← Назад", s.backAction())), + ) + + if mode == bot.EditMessage { + b.Edit(text, keyboard) + } else { + b.SendNew(text, keyboard) + } + return + case "cost_before_value": + text += "Ввод стоимости до торга\n\nНапример: 20000" + if s.CurrentChannel != "" { + text += fmt.Sprintf("\n\nКанал: %s", channelLabelByKey(s.Channels, s.CurrentChannel)) + } + + typeLabel := s.costBeforeTypeLabelForCurrent() + var keyboard echotron.InlineKeyboardMarkup + + // Показываем кнопку удаления только если есть значение и это не режим per-channel + if s.CurrentChannel == "" && s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil { + keyboard = Keyboard( + Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")), + Row(Button("⌫ Удалить до торга", "delete_cost_before")), + Row(Button("← Назад", s.backAction())), + ) + } else { + keyboard = Keyboard( + Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")), + Row(Button("← Назад", s.backAction())), + ) + } + + if mode == bot.EditMessage { + b.Edit(text, keyboard) + } else { + b.SendNew(text, keyboard) + } + return + case "format_select": + text = "Страница ввода формата размещения\n\n" + if s.CurrentChannel != "" { + text += fmt.Sprintf("Канал: %s\n\n", channelLabelByKey(s.Channels, s.CurrentChannel)) + } + text += "Выберите формат\n\n" + keyboard := Keyboard( + Row( + Button("1ч / 24ч", "format_preset:60:1440"), + Button("1ч / 36ч", "format_preset:60:2160"), + Button("1ч / 48ч", "format_preset:60:2880"), + Button("1ч / 72ч", "format_preset:60:4320"), + ), + Row( + Button("1ч / 7д", "format_preset:60:10080"), + Button("1ч / 30д", "format_preset:60:43200"), + Button("1ч / 60д", "format_preset:60:86400"), + Button("1ч / 90д", "format_preset:60:129600"), + ), + Row( + Button("1ч / без удаления", "format_preset:60:0"), + ), + Row( + Button("2ч / 24ч", "format_preset:120:1440"), + Button("2ч / 36ч", "format_preset:120:2160"), + Button("2ч / 48ч", "format_preset:120:2880"), + Button("2ч / 72ч", "format_preset:120:4320"), + ), + Row( + Button("2ч / 7д", "format_preset:120:10080"), + Button("2ч / 30д", "format_preset:120:43200"), + Button("2ч / 60д", "format_preset:120:86400"), + Button("2ч / 90д", "format_preset:120:129600"), + ), + Row( + Button("← Назад", s.backAction()), + Button("Свой формат", "format_custom"), + ), + ) + if mode == bot.EditMessage { + b.Edit(text, keyboard) + } else { + b.SendNew(text, keyboard) + } + return + case "format_custom": + s.renderCustomFormatTop(b, mode) + return + case "format_custom_feed": + s.renderCustomFormatFeed(b, mode) + return + case "invite_link_type": + text = "Страница выбора типа ссылки\n\n" + if s.CurrentChannel != "" { + text += fmt.Sprintf("Канал: %s\n\n", channelLabelByKey(s.Channels, s.CurrentChannel)) + } + text += "Выберите тип ссылки\n\n" + keyboard := Keyboard( + Row( + Button("Открытая", "invite_link_type:public"), + Button("С заявками", "invite_link_type:approval"), + ), + Row(Button("← Назад", s.backAction())), + ) + if mode == bot.EditMessage { + b.Edit(text, keyboard) + } else { + b.SendNew(text, keyboard) + } + return + case "comment": + text += "Введите комментарий" + keyboard := Keyboard(Row(Button("← Назад", s.backAction()))) + if s.Comment != "" { + keyboard = Keyboard( + Row(Button("⌫ Удалить комментарий", "delete_comment")), + Row(Button("← Назад", s.backAction())), + ) + } + if mode == bot.EditMessage { + b.Edit(text, keyboard) + } else { + b.SendNew(text, keyboard) + } + return + default: + s.InputMode = "" + s.Enter(b, mode) + return + } + + keyboard := Keyboard( + Row(Button("← Назад", s.backAction())), + ) + + if mode == bot.EditMessage { + b.Edit(text, keyboard) + } else { + b.SendNew(text, keyboard) + } +} + +func (s *PurchaseOptionalDetails) renderCustomFormatTop(b *bot.Bot, mode bot.RenderMode) { + text := "Свой формат — время в топе\n\n" + if s.CurrentChannel != "" { + text += fmt.Sprintf("Канал: %s\n\n", channelLabelByKey(s.Channels, s.CurrentChannel)) + } + + var rows [][]echotron.InlineKeyboardButton + + if s.CustomFormatTopUnit == "minutes" { + text += "Выберите или введите число (в минутах):" + rows = append(rows, + Row( + Button("10", "format_custom_top:10"), + Button("15", "format_custom_top:15"), + Button("20", "format_custom_top:20"), + Button("30", "format_custom_top:30"), + ), + Row( + Button("45", "format_custom_top:45"), + Button("60", "format_custom_top:60"), + Button("90", "format_custom_top:90"), + Button("120", "format_custom_top:120"), + ), + Row(Button("⏱ Минуты", "format_custom_top_toggle_unit")), + ) + } else { + text += "Выберите или введите число (в часах):" + rows = append(rows, + Row( + Button("1", "format_custom_top:60"), + Button("2", "format_custom_top:120"), + Button("3", "format_custom_top:180"), + Button("4", "format_custom_top:240"), + ), + Row( + Button("5", "format_custom_top:300"), + Button("6", "format_custom_top:360"), + Button("8", "format_custom_top:480"), + Button("12", "format_custom_top:720"), + ), + Row(Button("⏱ Часы", "format_custom_top_toggle_unit")), + ) + } + + rows = append(rows, Row(Button("← Назад", "format_custom_back_to_select"))) + + b.Render(text, Keyboard(rows...), mode) +} + +func (s *PurchaseOptionalDetails) renderCustomFormatFeed(b *bot.Bot, mode bot.RenderMode) { + text := "Свой формат — время в ленте\n\n" + if s.CurrentChannel != "" { + text += fmt.Sprintf("Канал: %s\n\n", channelLabelByKey(s.Channels, s.CurrentChannel)) + } + + if s.CustomFormatTopValue != nil { + text += fmt.Sprintf("Время в топе: %s ✓\n\n", formatDuration(*s.CustomFormatTopValue, "top")) + } + + var rows [][]echotron.InlineKeyboardButton + + if s.CustomFormatFeedUnit == "days" { + text += "Выберите или введите число (в днях):" + rows = append(rows, + Row( + Button("7", "format_custom_feed:10080"), + Button("14", "format_custom_feed:20160"), + Button("30", "format_custom_feed:43200"), + Button("60", "format_custom_feed:86400"), + ), + Row( + Button("90", "format_custom_feed:129600"), + Button("120", "format_custom_feed:172800"), + Button("180", "format_custom_feed:259200"), + Button("365", "format_custom_feed:525600"), + ), + Row( + Button("⏱ Дни", "format_custom_feed_toggle_unit"), + Button("Без удаления", "format_custom_feed:0"), + ), + ) + } else { + text += "Выберите или введите число (в часах):" + rows = append(rows, + Row( + Button("24", "format_custom_feed:1440"), + Button("36", "format_custom_feed:2160"), + Button("48", "format_custom_feed:2880"), + Button("72", "format_custom_feed:4320"), + ), + Row( + Button("96", "format_custom_feed:5760"), + Button("120", "format_custom_feed:7200"), + Button("144", "format_custom_feed:8640"), + Button("168", "format_custom_feed:10080"), + ), + Row( + Button("⏱ Часы", "format_custom_feed_toggle_unit"), + Button("Без удаления", "format_custom_feed:0"), + ), + ) + } + + rows = append(rows, Row(Button("← Назад", "format_custom_back_to_top"))) + + b.Render(text, Keyboard(rows...), mode) +} + +func (s *PurchaseOptionalDetails) renderModeSelect(b *bot.Bot, mode bot.RenderMode) { + text := "⚙ Переключить режим параметра\n\n" + + // Показываем текущую информацию о параметрах + text += s.formatOptionalSummary() + text += "\n\n" + + text += "Выберите параметр для изменения режима:\n\n" + + var rows [][]echotron.InlineKeyboardButton + + // Дата размещения + placementLabel := "Дата размещения: " + if s.PlacementMode == "per_channel" { + placementLabel += "👥" + } else { + placementLabel += "общий" + } + rows = append(rows, Row(Button(placementLabel, "toggle_param_mode:placement"))) + + // Дата оплаты + paymentDateLabel := "Дата оплаты: " + if s.PaymentDateMode == "per_channel" { + paymentDateLabel += "👥" + } else { + paymentDateLabel += "общий" + } + rows = append(rows, Row(Button(paymentDateLabel, "toggle_param_mode:payment_date"))) + + // Тип закупа + purchaseTypeLabel := "Тип закупа: " + if s.PurchaseTypeMode == "per_channel" { + purchaseTypeLabel += "👥" + } else { + purchaseTypeLabel += "общий" + } + rows = append(rows, Row(Button(purchaseTypeLabel, "toggle_param_mode:purchase_type"))) + + // Комментарий + commentLabel := "Комментарий: " + if s.CommentMode == "per_channel" { + commentLabel += "👥" + } else { + commentLabel += "общий" + } + rows = append(rows, Row(Button(commentLabel, "toggle_param_mode:comment"))) + + // Формат + formatLabel := "Формат: " + if s.FormatMode == "per_channel" { + formatLabel += "👥" + } else { + formatLabel += "общий" + } + rows = append(rows, Row(Button(formatLabel, "toggle_param_mode:format"))) + + // Тип ссылки + inviteLinkTypeLabel := "Тип ссылки: " + if s.InviteLinkTypeMode == "per_channel" { + inviteLinkTypeLabel += "👥" + } else { + inviteLinkTypeLabel += "общий" + } + rows = append(rows, Row(Button(inviteLinkTypeLabel, "toggle_param_mode:invite_link_type"))) + + rows = append(rows, Row(Button("← Назад", "back_to_optional"))) + + b.Render(text, Keyboard(rows...), mode) +} + +func (s *PurchaseOptionalDetails) renderParamScreens(b *bot.Bot, mode bot.RenderMode) bool { + switch s.InputMode { + case "placement_channels": + s.renderParamChannels(b, mode, "placement") + return true + case "payment_date_channels": + s.renderParamChannels(b, mode, "payment_date") + return true + case "cost_channels": + s.renderParamChannels(b, mode, "cost") + return true + case "cost_before_channels": + s.renderParamChannels(b, mode, "cost_before") + return true + case "purchase_type_channels": + s.renderParamChannels(b, mode, "purchase_type") + return true + case "comment_channels": + s.renderParamChannels(b, mode, "comment") + return true + case "format_channels": + s.renderParamChannels(b, mode, "format") + return true + case "invite_link_type_channels": + s.renderParamChannels(b, mode, "invite_link_type") + return true + default: + return false + } +} + +func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.RenderMode, param string) { + // Сбрасываем на редактирование при смене параметра + if s.CurrentParam != param || s.ChannelEditMode == "" { + s.ChannelEditMode = "edit" + } + s.CurrentParam = param + text := fmt.Sprintf("%s — по каналам\n\n", s.paramTitle(param)) + text += s.renderParamChannelSummary(param) + "\n\n" + + const perPage = 5 + total := len(s.Channels) + if total == 0 { + text += "\nКаналы не выбраны" + b.Render(text, Keyboard(Row(Button("← Назад", "back_to_optional"))), mode) + return + } + + if s.ParamPage*perPage >= total { + s.ParamPage = 0 + } + start, end := ui2.GetPageBounds(s.ParamPage, perPage, total) + + + var rows [][]echotron.InlineKeyboardButton + + // Кнопки переключения режима + editLabel := "Редактирование" + copyLabel := "Копирование" + if s.ChannelEditMode == "edit" { + editLabel = "● " + editLabel + } else { + copyLabel = "● " + copyLabel + } + rows = append(rows, Row( + Button(editLabel, "channel_mode:edit"), + Button(copyLabel, "channel_mode:copy"), + )) + + // Список каналов с кнопками в зависимости от режима + for i := start; i < end; i++ { + label := fmt.Sprintf("%d", i+1) + channelKey := channelKey(s.Channels[i]) + + if s.ChannelEditMode == "copy" { + // Режим копирования + var channelRow []echotron.InlineKeyboardButton + channelRow = append(channelRow, Button(label, fmt.Sprintf("param_edit:%s:%d", param, i))) + channelRow = append(channelRow, Button("⧉", fmt.Sprintf("param_copy:%s:%d", param, i))) + + // Кнопка вставки - только если есть данные в буфере + if s.hasCopyBuffer(param) { + channelRow = append(channelRow, Button("⇲", fmt.Sprintf("param_paste:%s:%d", param, i))) + } else { + channelRow = append(channelRow, Button(" ", "noop")) + } + + // Кнопка удаления - только если есть данные у канала + if s.hasChannelValue(param, channelKey) { + channelRow = append(channelRow, Button("⌫", fmt.Sprintf("param_clear:%s:%d", param, i))) + } else { + channelRow = append(channelRow, Button(" ", "noop")) + } + + rows = append(rows, channelRow) + } else { + // Обычный режим редактирования + var channelRow []echotron.InlineKeyboardButton + channelRow = append(channelRow, Button(label, fmt.Sprintf("param_edit:%s:%d", param, i))) + + // Кнопка "Добавить" или "Изменить" в зависимости от наличия данных + hasValue := s.hasChannelValue(param, channelKey) + if hasValue { + channelRow = append(channelRow, Button("Изменить", fmt.Sprintf("param_edit:%s:%d", param, i))) + } else { + channelRow = append(channelRow, Button("Добавить", fmt.Sprintf("param_edit:%s:%d", param, i))) + } + + // Кнопка удаления - только если есть данные у канала + if hasValue { + channelRow = append(channelRow, Button("⌫", fmt.Sprintf("param_clear:%s:%d", param, i))) + } else { + channelRow = append(channelRow, Button(" ", "noop")) + } + + rows = append(rows, channelRow) + } + } + + pages := ui2.CalculatePages(total, perPage) + if navRow := ui2.BuildNavigationRow(ui2.PaginationConfig{ + CurrentPage: s.ParamPage, + TotalPages: pages, + }); navRow != nil { + rows = append(rows, navRow) + } + + rows = append(rows, Row( + Button("← Назад", fmt.Sprintf("param_back:%s", param)), + )) + + b.Render(text, Keyboard(rows...), mode) +} + +func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string { + // Собираем строки и вычисляем максимальную длину названия канала + type channelLine struct { + label string + value string + } + var lines []channelLine + maxLabelLen := 0 + + for _, ch := range s.Channels { + label := channelLabel(ch) + labelLen := len([]rune(label)) // Считаем руны для Unicode + if labelLen > maxLabelLen { + maxLabelLen = labelLen + } + channelKey := channelKey(ch) + value := s.formatParamValue(param, channelKey) + lines = append(lines, channelLine{label: label, value: value}) + } + + // Логирование для отладки (INFO уровень) + log.Info(). + Str("param", param). + Int("maxLabelLen", maxLabelLen). + Int("channelsCount", len(lines)). + Msg("🔍 renderParamChannelSummary") + + // Форматируем: маркер + название + паддинг + значение + // Паддинг вычисляем так, чтобы все значения начинались с одной позиции + const ( + marker = "· " // Маркер пункта + valueIndent = 6 // Отступ после самого длинного названия (увеличен для лучшей читаемости) + ) + + var result []string + for i, line := range lines { + labelLen := len([]rune(line.label)) + // Паддинг = (макс.длина - тек.длина) + отступ после названия + paddingLen := maxLabelLen - labelLen + valueIndent + padding := strings.Repeat("\u00A0", paddingLen) + + formattedLine := fmt.Sprintf("%s%s%s%s", marker, line.label, padding, line.value) + result = append(result, formattedLine) + + // Логируем каждую строку (INFO уровень) + log.Info(). + Int("index", i). + Str("label", line.label). + Int("labelLen", labelLen). + Int("paddingLen", paddingLen). + Str("formattedLine", formattedLine). + Msg("📝 Channel line") + } + + finalResult := strings.Join(result, "\n") + + // Оборачиваем в
 для моноширинного шрифта (выравнивание работает только в monospace)
+	finalResult = fmt.Sprintf("
%s
", finalResult) + + // Логируем итоговый результат + log.Info(). + Str("param", param). + Str("result", finalResult). + Msg("✅ Final summary") + + return finalResult +} + +func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) bool { + switch { + case strings.HasPrefix(data, "param_edit:"): + param, index, ok := s.parseParamIndex(data, "param_edit:") + if !ok { + s.Enter(b, bot.EditMessage) + return true + } + if index >= 0 && index < len(s.Channels) { + s.CurrentParam = param + s.CurrentChannel = channelKey(s.Channels[index]) + s.openChannelEditor(b, param, s.CurrentChannel) + return true + } + s.Enter(b, bot.EditMessage) + return true + case strings.HasPrefix(data, "param_copy:"): + param, index, ok := s.parseParamIndex(data, "param_copy:") + if !ok { + s.Enter(b, bot.EditMessage) + return true + } + if index >= 0 && index < len(s.Channels) { + channelKey := channelKey(s.Channels[index]) + s.copyParamValue(param, channelKey) + s.Enter(b, bot.EditMessage) + return true + } + s.Enter(b, bot.EditMessage) + return true + case strings.HasPrefix(data, "param_paste:"): + param, index, ok := s.parseParamIndex(data, "param_paste:") + if !ok { + s.Enter(b, bot.EditMessage) + return true + } + if index >= 0 && index < len(s.Channels) { + channelKey := channelKey(s.Channels[index]) + s.pasteParamValue(param, channelKey) + s.Enter(b, bot.EditMessage) + return true + } + s.Enter(b, bot.EditMessage) + return true + case strings.HasPrefix(data, "param_clear:"): + param, index, ok := s.parseParamIndex(data, "param_clear:") + if !ok { + s.Enter(b, bot.EditMessage) + return true + } + if index >= 0 && index < len(s.Channels) { + channelKey := channelKey(s.Channels[index]) + s.clearParamValue(param, channelKey) + s.Enter(b, bot.EditMessage) + return true + } + s.Enter(b, bot.EditMessage) + return true + case data == "prev" && strings.HasSuffix(s.InputMode, "_channels"): + if s.ParamPage > 0 { + s.ParamPage-- + } + s.Enter(b, bot.EditMessage) + return true + case data == "next" && strings.HasSuffix(s.InputMode, "_channels"): + s.ParamPage++ + s.Enter(b, bot.EditMessage) + return true + case strings.HasPrefix(data, "param_back:"): + // Возвращаемся на главный экран + s.InputMode = "" + s.Enter(b, bot.EditMessage) + return true + } + return false +} + +func (s *PurchaseOptionalDetails) parseParamIndex(data, prefix string) (string, int, bool) { + rest := strings.TrimPrefix(data, prefix) + parts := strings.Split(rest, ":") + if len(parts) != 2 { + return "", 0, false + } + param := parts[0] + index, err := strconv.Atoi(parts[1]) + if err != nil { + return "", 0, false + } + return param, index, true +} + +func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) { + switch param { + case "placement": + s.InputMode = "" + s.ReturnMode = "" + b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{ + Title: "Дата и время размещения", + Key: "placement_datetime", + IncludeTime: true, + AllowPast: true, + Selected: s.PlacementDateTime, + BackState: s, + }), bot.EditMessage) + case "payment_date": + s.InputMode = "" + s.ReturnMode = "" + b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{ + Title: "Дата оплаты", + Key: "payment_date", + IncludeTime: false, + AllowPast: true, + Selected: s.PaymentDate, + BackState: s, + }), bot.EditMessage) + case "purchase_type": + s.InputMode = "type" + s.ReturnMode = "" + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + case "comment": + s.InputMode = "comment" + s.ReturnMode = "" + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + case "cost": + s.InputMode = "cost_value" + s.ReturnMode = "" + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + case "cost_before": + s.InputMode = "cost_before_value" + s.ReturnMode = "" + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + case "format": + s.InputMode = "format_select" + s.ReturnMode = "" + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + case "invite_link_type": + s.InputMode = "invite_link_type" + s.ReturnMode = "" + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + default: + s.InputMode = "" + s.Enter(b, bot.EditMessage) + } +} + +func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, channelKey string) { + switch param { + case "placement": + s.InputMode = "placement_channels" + b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{ + Title: "Дата и время размещения", + Key: "placement_datetime:" + channelKey, + IncludeTime: true, + AllowPast: true, + Selected: s.PlacementByChannel[channelKey], + BackState: s, + }), bot.EditMessage) + case "payment_date": + s.InputMode = "payment_date_channels" + b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{ + Title: "Дата оплаты", + Key: "payment_date:" + channelKey, + IncludeTime: false, + AllowPast: true, + Selected: s.PaymentDateByChannel[channelKey], + BackState: s, + }), bot.EditMessage) + case "purchase_type": + s.InputMode = "type" + s.ReturnMode = "purchase_type_channels" + s.CurrentChannel = channelKey + s.Enter(b, bot.EditMessage) + case "comment": + s.InputMode = "comment" + s.ReturnMode = "comment_channels" + s.CurrentChannel = channelKey + s.Enter(b, bot.EditMessage) + case "cost": + s.InputMode = "cost_value" + s.ReturnMode = "cost_channels" + s.CurrentChannel = channelKey + s.Enter(b, bot.EditMessage) + case "cost_before": + s.InputMode = "cost_before_value" + s.ReturnMode = "cost_before_channels" + s.CurrentChannel = channelKey + s.Enter(b, bot.EditMessage) + case "format": + s.InputMode = "format_select" + s.ReturnMode = "format_channels" + s.CurrentChannel = channelKey + s.Enter(b, bot.EditMessage) + case "invite_link_type": + s.InputMode = "invite_link_type" + s.ReturnMode = "invite_link_type_channels" + s.CurrentChannel = channelKey + s.Enter(b, bot.EditMessage) + default: + s.InputMode = "" + s.Enter(b, bot.EditMessage) + } +} + +func (s *PurchaseOptionalDetails) copyParamValue(param, channelKey string) { + switch param { + case "placement": + s.PlacementCopy = s.PlacementByChannel[channelKey] + case "payment_date": + s.PaymentDateCopy = s.PaymentDateByChannel[channelKey] + case "cost": + entry := s.CostByChannel[channelKey] + copied := entry + if entry.Value == nil { + copied.Value = nil + } else { + value := *entry.Value + copied.Value = &value + } + s.CostCopy = &copied + case "cost_before": + entry := s.CostBeforeByChannel[channelKey] + copied := entry + if entry.Value == nil { + copied.Value = nil + } else { + value := *entry.Value + copied.Value = &value + } + s.CostBeforeCopy = &copied + case "purchase_type": + if value, ok := s.PurchaseTypeByChannel[channelKey]; ok { + copied := value + s.PurchaseTypeCopy = &copied + } else { + s.PurchaseTypeCopy = nil + } + case "comment": + if value, ok := s.CommentByChannel[channelKey]; ok { + copied := value + s.CommentCopy = &copied + } else { + s.CommentCopy = nil + } + case "format": + if value, ok := s.FormatByChannel[channelKey]; ok { + copied := value + s.FormatCopy = &copied + } else { + s.FormatCopy = nil + } + case "invite_link_type": + if value, ok := s.InviteLinkTypeByChannel[channelKey]; ok { + copied := value + s.InviteLinkTypeCopy = &copied + } else { + s.InviteLinkTypeCopy = nil + } + } +} + +func (s *PurchaseOptionalDetails) copyCommonValueToChannels(param string) { + switch param { + case "placement": + if s.PlacementDateTime == nil { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + value := s.PlacementDateTime + s.PlacementByChannel[key] = value + } + case "payment_date": + if s.PaymentDate == nil { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + value := s.PaymentDate + s.PaymentDateByChannel[key] = value + } + case "cost": + if s.CostValue == nil { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + entry := CostEntry{ + Type: s.CostType, + Value: s.CostValue, + } + s.CostByChannel[key] = entry + } + case "cost_before": + if s.CostBeforeBargain == nil || s.CostBeforeBargain.Value == nil { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + entry := CostEntry{ + Type: s.CostBeforeBargain.Type, + Value: s.CostBeforeBargain.Value, + } + s.CostBeforeByChannel[key] = entry + } + case "purchase_type": + if s.PurchaseType == "" { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + s.PurchaseTypeByChannel[key] = s.PurchaseType + } + case "comment": + if s.Comment == "" { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + s.CommentByChannel[key] = s.Comment + } + case "format": + if s.Format == "" { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + s.FormatByChannel[key] = s.Format + s.TopTimeByChannel[key] = s.TopTimeMinutes + s.FeedTimeByChannel[key] = s.FeedTimeMinutes + } + case "invite_link_type": + if s.InviteLinkType == "" { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + s.InviteLinkTypeByChannel[key] = s.InviteLinkType + } + } +} + +func (s *PurchaseOptionalDetails) pasteParamValue(param, channelKey string) { + switch param { + case "placement": + if s.PlacementCopy == nil { + s.PlacementByChannel[channelKey] = nil + return + } + value := s.PlacementCopy.In(ui2.MskLocation) + s.PlacementByChannel[channelKey] = &value + case "payment_date": + if s.PaymentDateCopy == nil { + s.PaymentDateByChannel[channelKey] = nil + return + } + value := s.PaymentDateCopy.In(ui2.MskLocation) + s.PaymentDateByChannel[channelKey] = &value + case "cost": + if s.CostCopy == nil { + delete(s.CostByChannel, channelKey) + return + } + copied := *s.CostCopy + if copied.Value != nil { + value := *copied.Value + copied.Value = &value + } + s.CostByChannel[channelKey] = copied + case "cost_before": + if s.CostBeforeCopy == nil { + delete(s.CostBeforeByChannel, channelKey) + return + } + copied := *s.CostBeforeCopy + if copied.Value != nil { + value := *copied.Value + copied.Value = &value + } + s.CostBeforeByChannel[channelKey] = copied + case "purchase_type": + if s.PurchaseTypeCopy == nil { + delete(s.PurchaseTypeByChannel, channelKey) + return + } + s.PurchaseTypeByChannel[channelKey] = *s.PurchaseTypeCopy + case "comment": + if s.CommentCopy == nil { + delete(s.CommentByChannel, channelKey) + return + } + s.CommentByChannel[channelKey] = *s.CommentCopy + case "format": + if s.FormatCopy == nil { + delete(s.FormatByChannel, channelKey) + return + } + s.FormatByChannel[channelKey] = *s.FormatCopy + case "invite_link_type": + if s.InviteLinkTypeCopy == nil { + delete(s.InviteLinkTypeByChannel, channelKey) + return + } + s.InviteLinkTypeByChannel[channelKey] = *s.InviteLinkTypeCopy + } +} + +func (s *PurchaseOptionalDetails) applyParamToAll(param string) { + switch param { + case "placement": + for _, ch := range s.Channels { + s.pasteParamValue(param, channelKey(ch)) + } + case "payment_date": + for _, ch := range s.Channels { + s.pasteParamValue(param, channelKey(ch)) + } + case "cost": + for _, ch := range s.Channels { + s.pasteParamValue(param, channelKey(ch)) + } + case "cost_before": + for _, ch := range s.Channels { + s.pasteParamValue(param, channelKey(ch)) + } + case "purchase_type": + for _, ch := range s.Channels { + s.pasteParamValue(param, channelKey(ch)) + } + case "comment": + for _, ch := range s.Channels { + s.pasteParamValue(param, channelKey(ch)) + } + case "format": + for _, ch := range s.Channels { + s.pasteParamValue(param, channelKey(ch)) + } + case "invite_link_type": + for _, ch := range s.Channels { + s.pasteParamValue(param, channelKey(ch)) + } + } +} + +func (s *PurchaseOptionalDetails) clearParamValue(param, channelKey string) { + switch param { + case "placement": + s.PlacementByChannel[channelKey] = nil + case "payment_date": + s.PaymentDateByChannel[channelKey] = nil + case "cost": + delete(s.CostByChannel, channelKey) + case "cost_before": + delete(s.CostBeforeByChannel, channelKey) + case "purchase_type": + delete(s.PurchaseTypeByChannel, channelKey) + case "comment": + delete(s.CommentByChannel, channelKey) + case "format": + delete(s.FormatByChannel, channelKey) + delete(s.TopTimeByChannel, channelKey) + delete(s.FeedTimeByChannel, channelKey) + case "invite_link_type": + delete(s.InviteLinkTypeByChannel, channelKey) + } +} + +func (s *PurchaseOptionalDetails) paramTitle(param string) string { + switch param { + case "placement": + return "Дата и время размещения" + case "payment_date": + return "Дата оплаты" + case "cost": + return "Стоимость" + case "cost_before": + return "Стоимость до торга" + case "purchase_type": + return "Тип закупа" + case "comment": + return "Комментарий" + case "format": + return "Формат" + case "invite_link_type": + return "Тип ссылки" + default: + return "" + } +} + +func (s *PurchaseOptionalDetails) paramMode(param string) string { + switch param { + case "placement": + if s.PlacementMode == "" { + return "common" + } + return s.PlacementMode + case "payment_date": + if s.PaymentDateMode == "" { + return "common" + } + return s.PaymentDateMode + case "cost": + if s.CostMode == "" { + return "per_channel" + } + return s.CostMode + case "cost_before": + if s.CostBeforeMode == "" { + return "per_channel" + } + return s.CostBeforeMode + case "purchase_type": + if s.PurchaseTypeMode == "" { + return "common" + } + return s.PurchaseTypeMode + case "comment": + if s.CommentMode == "" { + return "common" + } + return s.CommentMode + case "format": + if s.FormatMode == "" { + return "common" + } + return s.FormatMode + case "invite_link_type": + if s.InviteLinkTypeMode == "" { + return "common" + } + return s.InviteLinkTypeMode + default: + return "common" + } +} + +func (s *PurchaseOptionalDetails) setParamMode(param, mode string) { + switch param { + case "placement": + s.PlacementMode = mode + case "payment_date": + s.PaymentDateMode = mode + case "cost": + s.CostMode = mode + case "cost_before": + s.CostBeforeMode = mode + case "purchase_type": + s.PurchaseTypeMode = mode + case "comment": + s.CommentMode = mode + case "format": + s.FormatMode = mode + case "invite_link_type": + s.InviteLinkTypeMode = mode + } +} + +func (s *PurchaseOptionalDetails) ensureDefaults() { + if s.PlacementMode == "" { + s.PlacementMode = "common" + } + if s.PaymentDateMode == "" { + s.PaymentDateMode = "common" + } + if s.CostMode == "" { + s.CostMode = "per_channel" + } + if s.CostBeforeMode == "" { + s.CostBeforeMode = "per_channel" + } + if s.PurchaseTypeMode == "" { + s.PurchaseTypeMode = "common" + } + if s.CommentMode == "" { + s.CommentMode = "common" + } + if s.FormatMode == "" { + s.FormatMode = "common" + } + if s.InviteLinkTypeMode == "" { + s.InviteLinkTypeMode = "common" + } + if s.InviteLinkType == "" { + s.InviteLinkType = s.ProjectDefaultLinkType + if s.InviteLinkType == "" { + s.InviteLinkType = "approval" + } + } + if s.PlacementByChannel == nil { + s.PlacementByChannel = make(map[string]*time.Time) + } + if s.PaymentDateByChannel == nil { + s.PaymentDateByChannel = make(map[string]*time.Time) + } + if s.CostByChannel == nil { + s.CostByChannel = make(map[string]CostEntry) + } + if s.CostBeforeByChannel == nil { + s.CostBeforeByChannel = make(map[string]CostEntry) + } + if s.PurchaseTypeByChannel == nil { + s.PurchaseTypeByChannel = make(map[string]string) + } + if s.CommentByChannel == nil { + s.CommentByChannel = make(map[string]string) + } + if s.FormatByChannel == nil { + s.FormatByChannel = make(map[string]string) + } + if s.TopTimeByChannel == nil { + s.TopTimeByChannel = make(map[string]*int) + } + if s.FeedTimeByChannel == nil { + s.FeedTimeByChannel = make(map[string]*int) + } + if s.CustomFormatTopUnit == "" { + s.CustomFormatTopUnit = "hours" + } + if s.CustomFormatFeedUnit == "" { + s.CustomFormatFeedUnit = "hours" + } + if s.InviteLinkTypeByChannel == nil { + s.InviteLinkTypeByChannel = make(map[string]string) + } + s.syncChannelMaps() +} + +func (s *PurchaseOptionalDetails) syncChannelMaps() { + valid := make(map[string]struct{}, len(s.Channels)) + for _, ch := range s.Channels { + key := channelKey(ch) + if key != "" { + valid[key] = struct{}{} + } + } + for key := range s.PlacementByChannel { + if _, ok := valid[key]; !ok { + delete(s.PlacementByChannel, key) + } + } + for key := range s.PaymentDateByChannel { + if _, ok := valid[key]; !ok { + delete(s.PaymentDateByChannel, key) + } + } + for key := range s.CostByChannel { + if _, ok := valid[key]; !ok { + delete(s.CostByChannel, key) + } + } + for key := range s.CostBeforeByChannel { + if _, ok := valid[key]; !ok { + delete(s.CostBeforeByChannel, key) + } + } + for key := range s.PurchaseTypeByChannel { + if _, ok := valid[key]; !ok { + delete(s.PurchaseTypeByChannel, key) + } + } + for key := range s.CommentByChannel { + if _, ok := valid[key]; !ok { + delete(s.CommentByChannel, key) + } + } + for key := range s.FormatByChannel { + if _, ok := valid[key]; !ok { + delete(s.FormatByChannel, key) + } + } + for key := range s.TopTimeByChannel { + if _, ok := valid[key]; !ok { + delete(s.TopTimeByChannel, key) + } + } + for key := range s.FeedTimeByChannel { + if _, ok := valid[key]; !ok { + delete(s.FeedTimeByChannel, key) + } + } + for key := range s.InviteLinkTypeByChannel { + if _, ok := valid[key]; !ok { + delete(s.InviteLinkTypeByChannel, key) + } + } +} + +func (s *PurchaseOptionalDetails) formatPlacementSummary() string { + if s.PlacementMode == "per_channel" && len(s.Channels) > 1 { + return "👥" + } + return s.formatDateTime(s.PlacementDateTime) +} + +func (s *PurchaseOptionalDetails) formatPlacementDetails() string { + if s.PlacementMode != "per_channel" || len(s.Channels) <= 1 { + return "" + } + return s.renderParamChannelSummary("placement") +} + +func (s *PurchaseOptionalDetails) formatPaymentDateSummary() string { + if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 { + return "👥" + } + return s.formatDate(s.PaymentDate) +} + +func (s *PurchaseOptionalDetails) formatPaymentDateDetails() string { + if s.PaymentDateMode != "per_channel" || len(s.Channels) <= 1 { + return "" + } + return s.renderParamChannelSummary("payment_date") +} + +func (s *PurchaseOptionalDetails) formatCostSummary() string { + if s.CostMode == "per_channel" && len(s.Channels) > 1 { + return "👥" + } + return s.formatCostValue() +} + +func (s *PurchaseOptionalDetails) formatCostDetails() string { + if s.CostMode != "per_channel" || len(s.Channels) <= 1 { + return "" + } + return s.renderParamChannelSummary("cost") +} + +func (s *PurchaseOptionalDetails) formatCostBeforeSummary() string { + if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 { + return "👥" + } + return s.formatCostBefore() +} + +func (s *PurchaseOptionalDetails) formatCostBeforeDetails() string { + if s.CostBeforeMode != "per_channel" || len(s.Channels) <= 1 { + return "" + } + return s.renderParamChannelSummary("cost_before") +} + +func (s *PurchaseOptionalDetails) formatPurchaseTypeSummary() string { + if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 { + return "👥" + } + return s.formatText(s.PurchaseType) +} + +func (s *PurchaseOptionalDetails) formatPurchaseTypeDetails() string { + if s.PurchaseTypeMode != "per_channel" || len(s.Channels) <= 1 { + return "" + } + return s.renderParamChannelSummary("purchase_type") +} + +func (s *PurchaseOptionalDetails) formatCommentSummary() string { + if s.CommentMode == "per_channel" && len(s.Channels) > 1 { + return "👥" + } + return s.formatText(s.Comment) +} + +func (s *PurchaseOptionalDetails) formatCommentDetails() string { + if s.CommentMode != "per_channel" || len(s.Channels) <= 1 { + return "" + } + return s.renderParamChannelSummary("comment") +} + +func (s *PurchaseOptionalDetails) formatFormatSummary() string { + if s.FormatMode == "per_channel" && len(s.Channels) > 1 { + return "👥" + } + return s.formatText(s.Format) +} + +func (s *PurchaseOptionalDetails) formatFormatDetails() string { + if s.FormatMode != "per_channel" || len(s.Channels) <= 1 { + return "" + } + return s.renderParamChannelSummary("format") +} + +func (s *PurchaseOptionalDetails) formatInviteLinkTypeSummary() string { + if s.InviteLinkTypeMode == "per_channel" && len(s.Channels) > 1 { + return "👥" + } + return s.inviteLinkTypeLabel(s.InviteLinkType) +} + +func (s *PurchaseOptionalDetails) formatInviteLinkTypeDetails() string { + if s.InviteLinkTypeMode != "per_channel" || len(s.Channels) <= 1 { + return "" + } + return s.renderParamChannelSummary("invite_link_type") +} + +func (s *PurchaseOptionalDetails) formatParamValue(param, channelKey string) string { + switch param { + case "placement": + value := s.PlacementByChannel[channelKey] + return s.formatDateTime(value) + case "payment_date": + value := s.PaymentDateByChannel[channelKey] + return s.formatDate(value) + case "cost": + return s.formatCostForChannel(channelKey) + case "cost_before": + return s.formatCostBeforeForChannel(channelKey) + case "purchase_type": + if value, ok := s.PurchaseTypeByChannel[channelKey]; ok && value != "" { + return value + } + return "—" + case "comment": + if value, ok := s.CommentByChannel[channelKey]; ok && value != "" { + return value + } + return "—" + case "format": + if value, ok := s.FormatByChannel[channelKey]; ok && value != "" { + return value + } + return "—" + case "invite_link_type": + if value, ok := s.InviteLinkTypeByChannel[channelKey]; ok && value != "" { + return s.inviteLinkTypeLabel(value) + } + return "—" + default: + return "—" + } +} + +func (s *PurchaseOptionalDetails) formatCostForChannel(channelKey string) string { + entry, ok := s.CostByChannel[channelKey] + if !ok || entry.Value == nil { + return "—" + } + label := s.costTypeLabelForEntry(entry) + return fmt.Sprintf("%s %.0f₽", label, *entry.Value) +} + +func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(channelKey string) string { + entry, ok := s.CostBeforeByChannel[channelKey] + if !ok || entry.Value == nil { + return "—" + } + label := s.costBeforeTypeLabelForEntry(entry) + return fmt.Sprintf("%s %.0f₽", label, *entry.Value) +} + +func (s *PurchaseOptionalDetails) hasCopyBuffer(param string) bool { + switch param { + case "placement": + return s.PlacementCopy != nil + case "payment_date": + return s.PaymentDateCopy != nil + case "cost": + return s.CostCopy != nil + case "cost_before": + return s.CostBeforeCopy != nil + case "purchase_type": + return s.PurchaseTypeCopy != nil + case "comment": + return s.CommentCopy != nil + case "format": + return s.FormatCopy != nil + case "invite_link_type": + return s.InviteLinkTypeCopy != nil + default: + return false + } +} + +func (s *PurchaseOptionalDetails) hasChannelValue(param, channelKey string) bool { + switch param { + case "placement": + value, ok := s.PlacementByChannel[channelKey] + return ok && value != nil + case "payment_date": + value, ok := s.PaymentDateByChannel[channelKey] + return ok && value != nil + case "cost": + entry, ok := s.CostByChannel[channelKey] + return ok && entry.Value != nil + case "cost_before": + entry, ok := s.CostBeforeByChannel[channelKey] + return ok && entry.Value != nil + case "purchase_type": + value, ok := s.PurchaseTypeByChannel[channelKey] + return ok && value != "" + case "comment": + value, ok := s.CommentByChannel[channelKey] + return ok && value != "" + case "format": + value, ok := s.FormatByChannel[channelKey] + return ok && value != "" + case "invite_link_type": + value, ok := s.InviteLinkTypeByChannel[channelKey] + return ok && value != "" + default: + return false + } +} + +func (s *PurchaseOptionalDetails) hasPlacementValue() bool { + if s.PlacementMode == "per_channel" && len(s.Channels) > 1 { + for _, value := range s.PlacementByChannel { + if value != nil { + return true + } + } + return false + } + return s.PlacementDateTime != nil +} + +func (s *PurchaseOptionalDetails) hasPaymentDateValue() bool { + if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 { + for _, value := range s.PaymentDateByChannel { + if value != nil { + return true + } + } + return false + } + return s.PaymentDate != nil +} + +func (s *PurchaseOptionalDetails) hasCostValue() bool { + if s.CostMode == "per_channel" && len(s.Channels) > 1 { + for _, entry := range s.CostByChannel { + if entry.Value != nil { + return true + } + } + return false + } + return s.CostValue != nil +} + +func (s *PurchaseOptionalDetails) hasCostBeforeValue() bool { + if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 { + for _, entry := range s.CostBeforeByChannel { + if entry.Value != nil { + return true + } + } + return false + } + return s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil +} + +func (s *PurchaseOptionalDetails) hasFormatValue() bool { + if s.FormatMode == "per_channel" && len(s.Channels) > 1 { + for _, value := range s.FormatByChannel { + if value != "" { + return true + } + } + return false + } + return s.Format != "" +} + +func (s *PurchaseOptionalDetails) hasPurchaseTypeValue() bool { + if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 { + for _, value := range s.PurchaseTypeByChannel { + if value != "" { + return true + } + } + return false + } + return s.PurchaseType != "" +} + +func (s *PurchaseOptionalDetails) hasCommentValue() bool { + if s.CommentMode == "per_channel" && len(s.Channels) > 1 { + for _, value := range s.CommentByChannel { + if value != "" { + return true + } + } + return false + } + return s.Comment != "" +} + +func (s *PurchaseOptionalDetails) hasInviteLinkTypeValue() bool { + if s.InviteLinkTypeMode == "per_channel" && len(s.Channels) > 1 { + for _, value := range s.InviteLinkTypeByChannel { + if value != "" { + return true + } + } + return false + } + return s.InviteLinkType != "" +} + +func (s *PurchaseOptionalDetails) inviteLinkTypeLabel(linkType string) string { + if linkType == "public" { + return "Открытая" + } + if linkType == "approval" { + return "С заявками" + } + return "—" +} + +func (s *PurchaseOptionalDetails) costTypeLabelForEntry(entry CostEntry) string { + if entry.Type == "cpm" { + return "СРМ" + } + return "Фикс" +} + +func (s *PurchaseOptionalDetails) costBeforeTypeLabelForEntry(entry CostEntry) string { + if entry.Type == "cpm" { + return "СРМ" + } + return "Фикс" +} + +func (s *PurchaseOptionalDetails) costTypeLabelForCurrent() string { + if s.CurrentChannel == "" { + return s.costTypeLabel() + } + entry, ok := s.CostByChannel[s.CurrentChannel] + if !ok || entry.Type == "" { + return s.costTypeLabel() + } + return s.costTypeLabelForEntry(entry) +} + +func (s *PurchaseOptionalDetails) costBeforeTypeLabelForCurrent() string { + if s.CurrentChannel == "" { + return s.costBeforeTypeLabel() + } + entry, ok := s.CostBeforeByChannel[s.CurrentChannel] + if !ok || entry.Type == "" { + return s.costBeforeTypeLabel() + } + return s.costBeforeTypeLabelForEntry(entry) +} + +func (s *PurchaseOptionalDetails) toggleCostType() { + if s.CurrentChannel == "" { + if s.costTypeLabel() == "СРМ" { + s.CostType = "fixed" + } else { + s.CostType = "cpm" + } + return + } + entry := s.CostByChannel[s.CurrentChannel] + if entry.Type == "" { + entry.Type = s.CostType + } + if s.costTypeLabelForEntry(entry) == "СРМ" { + entry.Type = "fixed" + } else { + entry.Type = "cpm" + } + s.CostByChannel[s.CurrentChannel] = entry +} + +func (s *PurchaseOptionalDetails) toggleCostBeforeType() { + if s.CurrentChannel == "" { + if s.costBeforeTypeLabel() == "СРМ" { + s.CostBeforeType = "fixed" + } else { + s.CostBeforeType = "cpm" + } + if s.CostBeforeBargain != nil { + s.CostBeforeBargain.Type = s.CostBeforeType + } + return + } + entry := s.CostBeforeByChannel[s.CurrentChannel] + if entry.Type == "" { + entry.Type = s.CostBeforeType + } + if s.costBeforeTypeLabelForEntry(entry) == "СРМ" { + entry.Type = "fixed" + } else { + entry.Type = "cpm" + } + s.CostBeforeByChannel[s.CurrentChannel] = entry +} + +func (s *PurchaseOptionalDetails) setCostValue(value float64) { + if s.CurrentChannel == "" { + s.CostValue = &value + return + } + entry := s.CostByChannel[s.CurrentChannel] + entry.Value = &value + if entry.Type == "" { + entry.Type = s.CostType + } + s.CostByChannel[s.CurrentChannel] = entry +} + +func (s *PurchaseOptionalDetails) setCostBeforeValue(value float64) { + if s.CurrentChannel == "" { + if s.CostBeforeBargain == nil { + s.CostBeforeBargain = &CostEntry{} + } + s.CostBeforeBargain.Value = &value + if s.CostBeforeBargain.Type == "" { + s.CostBeforeBargain.Type = s.CostBeforeType + } + return + } + entry := s.CostBeforeByChannel[s.CurrentChannel] + entry.Value = &value + if entry.Type == "" { + entry.Type = s.CostBeforeType + } + s.CostBeforeByChannel[s.CurrentChannel] = entry +} + +func formatDuration(minutes int, unit string) string { + switch unit { + case "top": + if minutes > 0 && minutes%60 == 0 { + return fmt.Sprintf("%dч", minutes/60) + } + return fmt.Sprintf("%dмин", minutes) + case "feed": + if minutes == 0 { + return "без удаления" + } + if minutes >= 7*24*60 && minutes%(24*60) == 0 { + return fmt.Sprintf("%dд", minutes/(24*60)) + } + if minutes%60 == 0 { + return fmt.Sprintf("%dч", minutes/60) + } + return fmt.Sprintf("%dмин", minutes) + } + return fmt.Sprintf("%d", minutes) +} + +func formatLabel(topMinutes, feedMinutes int) string { + return formatDuration(topMinutes, "top") + " / " + formatDuration(feedMinutes, "feed") +} + +func (s *PurchaseOptionalDetails) setFormatPreset(topMinutes int, feedMinutes int) { + label := formatLabel(topMinutes, feedMinutes) + topPtr := &topMinutes + var feedPtr *int + if feedMinutes == 0 { + // 0 = без удаления — сохраняем как 0 + feedPtr = &feedMinutes + } else { + feedPtr = &feedMinutes + } + + if s.CurrentChannel == "" { + s.Format = label + s.TopTimeMinutes = topPtr + s.FeedTimeMinutes = feedPtr + } else { + s.FormatByChannel[s.CurrentChannel] = label + s.TopTimeByChannel[s.CurrentChannel] = topPtr + s.FeedTimeByChannel[s.CurrentChannel] = feedPtr + } +} + +func (s *PurchaseOptionalDetails) setFormatValue(value string) { + if s.CurrentChannel == "" { + s.Format = value + s.TopTimeMinutes = nil + s.FeedTimeMinutes = nil + return + } + s.FormatByChannel[s.CurrentChannel] = value + delete(s.TopTimeByChannel, s.CurrentChannel) + delete(s.FeedTimeByChannel, s.CurrentChannel) +} + +func (s *PurchaseOptionalDetails) setInviteLinkTypeValue(value string) { + if s.CurrentChannel == "" { + s.InviteLinkType = value + return + } + s.InviteLinkTypeByChannel[s.CurrentChannel] = value +} + +func (s *PurchaseOptionalDetails) backAction() string { + if s.ReturnMode != "" { + return "back_to_return" + } + return "back_to_optional" +} + +func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) { + if len(s.Channels) == 0 { + b.SendNew("❌ Добавьте хотя бы один канал", Keyboard( + Row(Button("← Назад", "back")), + )) + return + } + + placementType, ok := s.normalizePurchaseType() + if !ok { + b.SendNew("❌ Тип закупа: используйте «самопиар» или «стандарт»", Keyboard( + Row(Button("← Назад", "back")), + )) + return + } + + apiChannels := make([]backend.CreatePlacementChannelInput, 0, len(s.Channels)) + for _, ch := range s.Channels { + channelDetails := s.buildChannelDetails(channelKey(ch), placementType) + if channelDetails != nil && s.isChannelDetailsEmpty(channelDetails) { + channelDetails = nil + } + apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{ + ChannelID: ch.ChannelID, + Comment: ch.Comment, + Details: channelDetails, + }) + } + + input := backend.CreatePlacementsInput{ + CreativeID: &s.CreativeID, + Channels: apiChannels, + } + + placements, err := b.Backend.CreatePlacements(context.Background(), jwt, b.Session.WorkspaceID, s.ProjectID, input) + if err != nil { + log.Error().Err(err).Msg("Failed to create placements") + b.SendNew("❌ Не удалось создать размещения", Keyboard( + Row(Button("← Назад", "back")), + )) + return + } + + for _, placement := range placements.Placements { + _, err := b.Backend.BuildPlacementCreative( + context.Background(), + jwt, + b.Session.WorkspaceID, + s.ProjectID, + placement.ID, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to build placement creative") + continue + } + } + + // Очищаем сохранённое состояние после успешного создания + // Находим SelectChannelsForPurchase в BackState и очищаем его OptionalDetailsState + if selectState, ok := s.BackState.(*SelectChannelsForPurchase); ok { + selectState.OptionalDetailsState = nil + } + + b.SetState(&Placements{ + ProjectID: s.ProjectID, + ProjectTitle: s.ProjectTitle, + BackState: s.BackState, + }, bot.NewMessage) +} + +func (s *PurchaseOptionalDetails) buildChannelDetails(channelKey string, placementType *string) *backend.PlacementDetails { + details := &backend.PlacementDetails{} + + // Placement date + if s.PlacementMode == "per_channel" && len(s.Channels) > 1 { + if value, ok := s.PlacementByChannel[channelKey]; ok && value != nil { + formatted := value.UTC().Format(time.RFC3339) + details.PlacementAt = &formatted + } + } else if s.PlacementDateTime != nil { + formatted := s.PlacementDateTime.UTC().Format(time.RFC3339) + details.PlacementAt = &formatted + } + + // Payment date + if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 { + if value, ok := s.PaymentDateByChannel[channelKey]; ok && value != nil { + formatted := value.UTC().Format(time.RFC3339) + details.PaymentAt = &formatted + } + } else if s.PaymentDate != nil { + formatted := s.PaymentDate.UTC().Format(time.RFC3339) + details.PaymentAt = &formatted + } + + // Cost + if s.CostMode == "per_channel" && len(s.Channels) > 1 { + entry := s.CostByChannel[channelKey] + details.Cost = s.buildCostInfo(entry.Type, entry.Value) + } else { + details.Cost = s.buildCostInfo(s.CostType, s.CostValue) + } + + // Cost before bargain + if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 { + if entry, ok := s.CostBeforeByChannel[channelKey]; ok && entry.Value != nil { + details.CostBeforeBargain = s.buildCostInfo(entry.Type, entry.Value) + } + } else if s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil { + details.CostBeforeBargain = s.buildCostInfo(s.CostBeforeBargain.Type, s.CostBeforeBargain.Value) + } + + // Placement type + if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 { + if value, ok := s.PurchaseTypeByChannel[channelKey]; ok && value != "" { + normalized := normalizePurchaseTypeValue(value) + if normalized != nil { + details.PlacementType = normalized + } + } + } else if placementType != nil { + details.PlacementType = placementType + } + + // Comment + if s.CommentMode == "per_channel" && len(s.Channels) > 1 { + if value, ok := s.CommentByChannel[channelKey]; ok && value != "" { + details.Comment = &value + } + } else if s.Comment != "" { + details.Comment = &s.Comment + } + + // Format + if s.FormatMode == "per_channel" && len(s.Channels) > 1 { + if value, ok := s.FormatByChannel[channelKey]; ok && value != "" { + details.Format = &value + } + if value, ok := s.TopTimeByChannel[channelKey]; ok && value != nil { + details.TopTimeMinutes = value + } + if value, ok := s.FeedTimeByChannel[channelKey]; ok && value != nil { + details.FeedTimeMinutes = value + } + } else { + if s.Format != "" { + details.Format = &s.Format + } + details.TopTimeMinutes = s.TopTimeMinutes + details.FeedTimeMinutes = s.FeedTimeMinutes + } + + // Invite link type + if s.InviteLinkTypeMode == "per_channel" && len(s.Channels) > 1 { + if value, ok := s.InviteLinkTypeByChannel[channelKey]; ok && value != "" { + details.InviteLinkType = &value + } + } else if s.InviteLinkType != "" { + details.InviteLinkType = &s.InviteLinkType + } + + return details +} + +func (s *PurchaseOptionalDetails) buildCostInfo(costType string, value *float64) *backend.CostInfo { + if value == nil { + return nil + } + normalized := "fixed" + if costType == "cpm" { + normalized = "cpm" + } + return &backend.CostInfo{ + Type: normalized, + Value: *value, + } +} + +func (s *PurchaseOptionalDetails) isChannelDetailsEmpty(details *backend.PlacementDetails) bool { + return details.PlacementAt == nil && + details.PaymentAt == nil && + details.Cost == nil && + details.CostBeforeBargain == nil && + details.PlacementType == nil && + details.Format == nil && + details.TopTimeMinutes == nil && + details.FeedTimeMinutes == nil && + details.Comment == nil && + details.InviteLinkType == nil +} + +func normalizePurchaseTypeValue(raw string) *string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + normalized := strings.ToLower(raw) + normalized = strings.TrimSpace(strings.Trim(normalized, ".")) + switch normalized { + case "взаимный пиар", "взаимнопиар", "self_promo", "mutual_pr", "mutual pr", "вп", "vp": + value := "self_promo" + return &value + case "стандарт", "standard": + value := "standard" + return &value + default: + return nil + } +} + +func (s *PurchaseOptionalDetails) normalizePurchaseType() (*string, bool) { + result := normalizePurchaseTypeValue(s.PurchaseType) + if s.PurchaseType == "" { + return nil, true + } + return result, result != nil +} diff --git a/tg_bot/screens/select_channels_for_purchase.go b/tg_bot/screens/select_channels_for_purchase.go new file mode 100644 index 0000000..ef14542 --- /dev/null +++ b/tg_bot/screens/select_channels_for_purchase.go @@ -0,0 +1,641 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" + "github.com/rs/zerolog/log" +) + +type PurchaseChannelInput struct { + ChannelID string + Username string + Title string + InviteLink string + PlannedCost *float64 + Comment *string +} + +type SelectChannelsForPurchase struct { + ProjectID string + ProjectTitle string + ProjectTelegramID int64 + ProjectUsername string + ProjectStatus string + ProjectDefaultLinkType string + CreativeID string + CreativeTitle string + Channels []PurchaseChannelInput + CurrentPage int + InvalidUsernames []string + Duplicates []string // дубликаты каналов + ParsingErrors []ParseError // ошибки парсинга с предложениями + OptionalDetailsState *PurchaseOptionalDetails // сохранённое состояние деталей + BackState bot.State +} + +// ParseError представляет ошибку парсинга с предложением исправления +type ParseError struct { + Input string + Suggestion string +} + +func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) { + s.renderChannelSelection(b, mode) +} + +func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.RenderMode) { + text := "Создание закупа\n\n" + text += "Шаг 2/2: Добавление каналов\n\n" + + var buttons [][]echotron.InlineKeyboardButton + + if len(s.Channels) == 0 { + text += `Добавьте каналы для размещения рекламы + +Отправьте username (без @) или invite link приватного канала +Например: channel_name или https://t.me/+abcdef + +После добавления всех каналов нажмите Далее` + } else { + text += fmt.Sprintf("Добавлено каналов: %d\n\n", len(s.Channels)) + + for i, ch := range s.Channels { + channelText := fmt.Sprintf(" %d. %s", i+1, channelLabel(ch)) + if ch.PlannedCost != nil { + channelText += fmt.Sprintf(" — %.0f₽", *ch.PlannedCost) + } + text += channelText + "\n" + } + text += "\n" + + const channelsPerPage = 6 + if s.CurrentPage*channelsPerPage >= len(s.Channels) { + s.CurrentPage = 0 + } + start, end := ui.GetPageBounds(s.CurrentPage, channelsPerPage, len(s.Channels)) + + var slots []echotron.InlineKeyboardButton + for i := start; i < end; i++ { + ch := s.Channels[i] + buttonText := channelLabel(ch) + if ch.PlannedCost != nil { + buttonText = fmt.Sprintf("%s — %.0f₽", channelLabel(ch), *ch.PlannedCost) + } + slots = append(slots, Button("✖ "+buttonText, fmt.Sprintf("remove_channel:%d", i))) + } + + if len(s.Channels) > channelsPerPage { + for len(slots) < channelsPerPage { + slots = append(slots, Button(" ", "empty")) + } + } + + for i := 0; i < len(slots); i += 2 { + row := []echotron.InlineKeyboardButton{slots[i]} + if i+1 < len(slots) { + row = append(row, slots[i+1]) + } else { + row = append(row, Button(" ", "empty")) + } + buttons = append(buttons, row) + } + + pages := ui.CalculatePages(len(s.Channels), channelsPerPage) + navRow := ui.BuildNavigationRow(ui.PaginationConfig{ + CurrentPage: s.CurrentPage, + TotalPages: pages, + }) + if navRow != nil { + buttons = append(buttons, navRow) + } + + text += "\nДобавьте еще каналы или создайте закуп" + + // Кнопка создания закупа (доступна только если есть каналы) + } + + if len(s.InvalidUsernames) > 0 { + text += fmt.Sprintf("\n\n⚠️ Пропущены: %s", strings.Join(s.InvalidUsernames, ", ")) + s.InvalidUsernames = nil + } + + // Показываем дубликаты + if len(s.Duplicates) > 0 { + text += fmt.Sprintf("\n\n⏭️ Пропущены (дубликаты): %s", strings.Join(s.Duplicates, ", ")) + s.Duplicates = nil + } + + // Показываем ошибки с предложениями + if len(s.ParsingErrors) > 0 { + text += "\n\n❌ Ошибки форматирования:\n" + for i, err := range s.ParsingErrors { + if i < 3 { // Показываем максимум 3 ошибки + if err.Suggestion != "" { + text += fmt.Sprintf("• %s → возможно: %s\n", err.Input, err.Suggestion) + } else { + text += fmt.Sprintf("• %s\n", err.Input) + } + } + } + if len(s.ParsingErrors) > 3 { + text += fmt.Sprintf("• ... и еще %d\n", len(s.ParsingErrors)-3) + } + s.ParsingErrors = nil + } + + // Кнопки навигации + if len(s.Channels) > 0 { + buttons = append(buttons, Row( + Button("← Назад", "back"), + Button("→ Далее", "next_step"), + )) + } else { + buttons = append(buttons, Row( + Button("← Назад", "back"), + )) + } + + keyboard := Keyboard(buttons...) + b.Render(text, keyboard, mode) +} + +func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch data { + case "back": + // Возвращаемся к выбору креатива + b.SetState(&AddPurchase{ + ProjectID: s.ProjectID, + ProjectTitle: s.ProjectTitle, + ProjectTelegramID: s.ProjectTelegramID, + ProjectUsername: s.ProjectUsername, + ProjectStatus: s.ProjectStatus, + CreativeID: s.CreativeID, + CreativeTitle: s.CreativeTitle, + ActivePicker: "", + BackState: s.BackState, + }, bot.EditMessage) + + case "next_step": + if len(s.Channels) == 0 { + b.Edit("❌ Добавьте хотя бы один канал", Keyboard( + Row(Button("← Назад", "back_to_select")), + )) + return + } + + // Проверяем, что все каналы резолвлены (имеют ChannelID) + var unresolved []string + for _, ch := range s.Channels { + if ch.ChannelID == "" { + label := channelLabel(ch) + unresolved = append(unresolved, label) + } + } + + if len(unresolved) > 0 { + text := "❌ Некоторые каналы не были найдены:\n\n" + for _, label := range unresolved { + text += fmt.Sprintf("• %s\n", label) + } + text += "\nУдалите их из списка и попробуйте снова" + + b.Edit(text, Keyboard( + Row(Button("← Назад", "back_to_select")), + )) + return + } + + // Каналы уже резолвлены в HandleMessage, переходим к следующему шагу + // Если есть сохранённое состояние - используем его, иначе создаём новое + var optionalDetails *PurchaseOptionalDetails + if s.OptionalDetailsState != nil { + optionalDetails = s.OptionalDetailsState + // Обновляем каналы на случай если они изменились + optionalDetails.Channels = s.Channels + } else { + optionalDetails = &PurchaseOptionalDetails{ + ProjectID: s.ProjectID, + ProjectTitle: s.ProjectTitle, + ProjectDefaultLinkType: s.ProjectDefaultLinkType, + CreativeID: s.CreativeID, + CreativeTitle: s.CreativeTitle, + Channels: s.Channels, + BackState: s, + } + } + b.SetState(optionalDetails, bot.EditMessage) + + case "back_to_select": + s.Enter(b, bot.EditMessage) + + case "prev": + if s.CurrentPage > 0 { + s.CurrentPage-- + } + s.Enter(b, bot.EditMessage) + + case "next": + s.CurrentPage++ + s.Enter(b, bot.EditMessage) + + default: + if len(data) > 15 && data[:15] == "remove_channel:" { + var index int + if _, err := fmt.Sscanf(data[15:], "%d", &index); err == nil { + if index >= 0 && index < len(s.Channels) { + s.Channels = append(s.Channels[:index], s.Channels[index+1:]...) + if s.CurrentPage > 0 { + channelsPerPage := 6 + pages := ui.CalculatePages(len(s.Channels), channelsPerPage) + if s.CurrentPage >= pages { + s.CurrentPage = pages - 1 + } + } + s.Enter(b, bot.EditMessage) + return + } + } + } + s.Enter(b, bot.NewMessage) + } +} + +func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) { + // Преобразуем каналы в формат API + var apiChannels []backend.CreatePlacementChannelInput + for _, ch := range s.Channels { + var details *backend.PlacementDetails + if ch.PlannedCost != nil { + cost := backend.CostInfo{ + Type: "fixed", + Value: *ch.PlannedCost, + } + details = &backend.PlacementDetails{ + Cost: &cost, + } + } + apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{ + ChannelID: ch.ChannelID, + Comment: ch.Comment, + Details: details, + }) + } + + input := backend.CreatePlacementsInput{ + CreativeID: &s.CreativeID, + Channels: apiChannels, + } + + placements, err := b.Backend.CreatePlacements(context.Background(), jwt, b.Session.WorkspaceID, s.ProjectID, input) + + if err != nil { + log.Error().Err(err).Msg("Failed to create placements") + b.Edit("❌ Не удалось создать размещения\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", Keyboard( + Row(Button("← Назад", "back_to_select")), + )) + return + } + + if len(placements.Placements) == 0 { + b.Edit("❌ Размещения не созданы", Keyboard( + Row(Button("← Назад", "back_to_select")), + )) + return + } + + b.SetState(&PlacementDetails{ + ProjectID: s.ProjectID, + PlacementID: placements.Placements[0].ID, + BackState: s.BackState, + }, bot.EditMessage) +} + +func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update) { + if u.Message == nil || u.Message.Text == "" { + return + } + + raw := strings.TrimSpace(u.Message.Text) + if raw == "" { + return + } + + // Проверяем JWT + jwt := b.Session.JWT + if jwt == "" { + log.Error().Msg("JWT is empty in session") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard()) + return + } + + // Разбиваем ввод на токены + tokens := strings.FieldsFunc(raw, func(r rune) bool { + return r == ' ' || r == '\n' || r == '\t' || r == ',' || r == ';' + }) + + added := 0 + s.Duplicates = nil + s.InvalidUsernames = nil + s.ParsingErrors = nil + +tokensLoop: + for _, token := range tokens { + entry := strings.TrimSpace(token) + entry = strings.Trim(entry, ",;") + if entry == "" { + continue + } + + // Используем новый парсер + parsed := ParseChannelInput(entry) + + if !parsed.Valid { + // Пробуем предложить исправление + if suggestion, ok := SuggestFix(entry); ok { + s.ParsingErrors = append(s.ParsingErrors, ParseError{ + Input: entry, + Suggestion: suggestion, + }) + } else { + s.InvalidUsernames = append(s.InvalidUsernames, entry) + } + continue + } + + // Проверяем дубликаты по username/invite link + if IsDuplicate(parsed, s.Channels) { + label := FormatChannelLabel(parsed) + s.Duplicates = append(s.Duplicates, label) + continue + } + + // Сразу резолвим канал через бэкенд + channel, err := s.resolveSingleChannel(b, jwt, parsed) + if err != nil { + // Канал не найден или ошибка + s.InvalidUsernames = append(s.InvalidUsernames, entry) + log.Error().Err(err).Str("input", entry).Msg("Failed to resolve channel") + continue + } + + // Проверяем дубликаты по ID канала (после резолва) + for _, ch := range s.Channels { + if ch.ChannelID != "" && ch.ChannelID == channel.ID { + var title, username string + if channel.Title != nil { + title = *channel.Title + } + if channel.Username != nil { + username = *channel.Username + } + label := channelLabelByID(&channel.ID, &title, &username) + s.Duplicates = append(s.Duplicates, label) + continue tokensLoop // <-- Выход из внешнего цикла, а не внутреннего! + } + + // Дополнительная проверка по username (case-insensitive) + // На случай, если канал еще не резолвлен или ID отличается + if channel.Username != nil && ch.Username != "" { + if strings.EqualFold(ch.Username, *channel.Username) { + var title string + if channel.Title != nil { + title = *channel.Title + } + label := channelLabelByID(&channel.ID, &title, channel.Username) + s.Duplicates = append(s.Duplicates, label) + continue tokensLoop + } + } + } + + // Добавляем канал с полными данными + var title string + if channel.Title != nil { + title = *channel.Title + } + s.Channels = append(s.Channels, PurchaseChannelInput{ + ChannelID: channel.ID, + Username: parsed.Username, + Title: title, + InviteLink: parsed.InviteLink, + }) + added++ + } + + // Если ничего не добавлено и нет ошибок - не обновляем экран + if added == 0 && len(s.InvalidUsernames) == 0 && len(s.Duplicates) == 0 && len(s.ParsingErrors) == 0 { + return + } + + // Обновляем экран + s.Enter(b, bot.NewMessage) +} + +// resolveSingleChannel резолвит один канал через бэкенд +func (s *SelectChannelsForPurchase) resolveSingleChannel(b *bot.Bot, jwt string, parsed ChannelInput) (*backend.Channel, error) { + var input backend.CreateChannelInput + if parsed.InviteLink != "" { + link := parsed.InviteLink + input = backend.CreateChannelInput{InviteLink: &link} + } else { + username := parsed.Username + input = backend.CreateChannelInput{Username: &username} + } + + resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{ + Channels: []backend.CreateChannelInput{input}, + }) + if err != nil { + return nil, err + } + + if len(resp.Results) == 0 { + return nil, fmt.Errorf("no response from backend") + } + + result := resp.Results[0] + if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" { + errMsg := "channel not found" + if result.Error != nil { + errMsg = *result.Error + } + return nil, fmt.Errorf(errMsg) + } + + // Если пришел username из ответа, обновляем его + if result.Channel.Username != nil && *result.Channel.Username != "" { + parsed.Username = *result.Channel.Username + } + + return result.Channel, nil +} + +// channelLabelByID формирует метку канала по ID +func channelLabelByID(id, title, username *string) string { + if title != nil && *title != "" { + return *title + } + if username != nil && *username != "" { + return "@" + *username + } + if id != nil && *id != "" { + return *id + } + return "канал" +} + +func (s *SelectChannelsForPurchase) resolveChannels(b *bot.Bot, jwt string) []string { + inputs := make([]backend.CreateChannelInput, 0, len(s.Channels)) + for _, ch := range s.Channels { + if ch.InviteLink != "" { + link := ch.InviteLink + inputs = append(inputs, backend.CreateChannelInput{InviteLink: &link}) + } else { + username := ch.Username + inputs = append(inputs, backend.CreateChannelInput{Username: &username}) + } + } + + resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{ + Channels: inputs, + }) + if err != nil { + log.Error().Err(err).Msg("Failed to create channels") + return []string{"ошибка сервера"} + } + + var failed []string + for _, result := range resp.Results { + if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" { + if result.Index >= 0 && result.Index < len(s.Channels) { + failed = append(failed, channelLabel(s.Channels[result.Index])) + } + continue + } + if result.Index < 0 || result.Index >= len(s.Channels) { + continue + } + ch := &s.Channels[result.Index] + ch.ChannelID = result.Channel.ID + if result.Channel.Username != nil { + ch.Username = *result.Channel.Username + } + if result.Channel.Title != nil { + ch.Title = *result.Channel.Title + } + } + + return failed +} + +func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) { + // Обрабатываем callback после успешного создания + if u.CallbackQuery != nil && u.CallbackQuery.Data == "done" { + // Возвращаемся к списку закупов + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + } +} + +func (s *SelectChannelsForPurchase) Exit() {} + +func channelLabel(ch PurchaseChannelInput) string { + if ch.Username != "" { + return "@" + ch.Username + } + if ch.Title != "" { + return ch.Title + } + if ch.InviteLink != "" { + return formatInviteLabel(ch.InviteLink) + } + if ch.ChannelID != "" { + return ch.ChannelID + } + return "канал" +} + +func channelKey(ch PurchaseChannelInput) string { + if ch.ChannelID != "" { + return ch.ChannelID + } + if ch.Username != "" { + return ch.Username + } + if ch.InviteLink != "" { + return ch.InviteLink + } + return "" +} + +func channelLabelByKey(channels []PurchaseChannelInput, key string) string { + for _, ch := range channels { + if channelKey(ch) == key { + return channelLabel(ch) + } + } + return key +} + +func isInviteLink(value string) bool { + value = strings.TrimSpace(value) + if strings.Contains(value, "t.me/") || strings.Contains(value, "telegram.me/") { + return true + } + if strings.HasPrefix(value, "tg://") || strings.HasPrefix(value, "tg:") { + return true + } + return false +} + +func formatInviteLabel(inviteLink string) string { + link := strings.TrimSpace(inviteLink) + if link == "" { + return "приватный канал" + } + + code := link + if strings.HasPrefix(code, "tg://") || strings.HasPrefix(code, "tg:") { + if idx := strings.Index(code, "invite="); idx != -1 { + code = code[idx+len("invite="):] + if end := strings.IndexAny(code, "&?#"); end != -1 { + code = code[:end] + } + } + } else { + code = strings.TrimPrefix(code, "https://") + code = strings.TrimPrefix(code, "http://") + code = strings.TrimPrefix(code, "t.me/") + code = strings.TrimPrefix(code, "telegram.me/") + code = strings.TrimPrefix(code, "joinchat/") + code = strings.TrimPrefix(code, "+") + if idx := strings.LastIndex(code, "/"); idx != -1 { + code = code[idx+1:] + } + if end := strings.IndexAny(code, "?#"); end != -1 { + code = code[:end] + } + } + + code = strings.Trim(code, "/+ ") + if code == "" { + return "приватный канал" + } + if len(code) > 10 { + code = code[:6] + "..." + code[len(code)-2:] + } + return "приватный: " + code +} diff --git a/tg_bot/screens/select_workspace.go b/tg_bot/screens/select_workspace.go new file mode 100644 index 0000000..ce36b54 --- /dev/null +++ b/tg_bot/screens/select_workspace.go @@ -0,0 +1,151 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" + "github.com/rs/zerolog/log" +) + +type SelectWorkspace struct { + ChannelID string + BackState bot.State + CurrentPage int +} + +const msgChooseWorkspace = ` +📁 Выбор workspace для канала +В какой workspace добавить канал? +` + +const msgSuccessChooseWorkspace = ` +✅ Канал успешно добавлен! +📊 Проект ID: %s +📝 Название: %s +🏷 Статус: %s +` + +func (s *SelectWorkspace) Enter(b *bot.Bot, mode bot.RenderMode) { + workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT) + if err != nil { + b.SendNew("❌ Не удалось загрузить список workspace'ов", Keyboard()) + return + } + + if len(workspaces) == 0 { + kb := Keyboard(Row(Button("Главное меню", "back"))) + b.SendNew("У вас пока нет workspace'ов", kb) + return + } + + // Создаем кнопки для всех workspace'ов + var allButtons []echotron.InlineKeyboardButton + for _, ws := range workspaces { + allButtons = append(allButtons, Button(ws.Name, fmt.Sprintf("select_workspace:%s", ws.ID))) + } + + // Используем компонент пагинации для автоматической раскладки + const workspacesPerPage = 6 + var buttons [][]echotron.InlineKeyboardButton + + // Раскладываем workspace'ы в grid (2 в ряд) с пагинацией + elementRows := ui.BuildElementRows(ui.ElementLayoutConfig{ + CurrentPage: s.CurrentPage, + ItemsPerPage: workspacesPerPage, + ItemsPerRow: 2, + }, allButtons) + buttons = append(buttons, elementRows...) + + // Добавляем навигацию (стрелки появятся только если страниц > 1) + if navRow := ui.BuildNavigationRow(ui.PaginationConfig{ + CurrentPage: s.CurrentPage, + TotalItems: len(allButtons), + ItemsPerPage: workspacesPerPage, + }); navRow != nil { + buttons = append(buttons, navRow) + } + + // Кнопка отмены + buttons = append(buttons, Row(Button("Отмена", "cancel"))) + + kb := Keyboard(buttons...) + b.Render(msgChooseWorkspace, kb, mode) +} + +func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "prev": + if s.CurrentPage > 0 { + s.CurrentPage-- + } + s.Enter(b, bot.EditMessage) + + case data == "next": + s.CurrentPage++ + s.Enter(b, bot.EditMessage) + + case data == "cancel", data == "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.NewMessage) + } + + case strings.HasPrefix(data, "select_workspace:"): + parts := strings.Split(data, ":") + if len(parts) != 2 { + log.Error().Str("callback_data", data).Msg("Invalid callback data format") + return + } + + workspaceID := parts[1] + + project, err := b.Backend.AttachChannelToWorkspace( + context.Background(), + s.ChannelID, + workspaceID, + b.ChatID, + ) + if err != nil { + b.Edit("❌ Не удалось добавить канал в workspace", Keyboard( + Row(Button("← Назад", "back_to_select")), + )) + return + } + + successText := fmt.Sprintf(msgSuccessChooseWorkspace, project.ID, project.Title, project.Status) + kb := Keyboard( + Row(Button("Мои проекты", "my_projects")), + Row(Button("Главное меню", "main_menu")), + ) + + b.Edit(successText, kb) + + case data == "back_to_select": + s.Enter(b, bot.EditMessage) + + case data == "my_projects": + b.SetState(&MyProjects{}, bot.NewMessage) + + case data == "main_menu": + b.SetState(&MainMenu{}, bot.NewMessage) + + default: + s.Enter(b, bot.NewMessage) + } + return +} + +func (s *SelectWorkspace) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *SelectWorkspace) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *SelectWorkspace) Exit() {} diff --git a/tg_bot/screens/ui/buttons.go b/tg_bot/screens/ui/buttons.go new file mode 100644 index 0000000..e2eef78 --- /dev/null +++ b/tg_bot/screens/ui/buttons.go @@ -0,0 +1,21 @@ +package ui + +import "github.com/NicoNex/echotron/v3" + +// BuildGrid builds button rows from items using a callback builder. +func BuildGrid[T any](items []T, itemsPerRow, itemsPerPage, totalPages int, itemFn func(T) (title string, callback string)) [][]echotron.InlineKeyboardButton { + if len(items) == 0 { + return nil + } + + buttons := make([]echotron.InlineKeyboardButton, 0, len(items)) + for _, item := range items { + title, callback := itemFn(item) + buttons = append(buttons, echotron.InlineKeyboardButton{ + Text: title, + CallbackData: callback, + }) + } + + return BuildPageRows(buttons, itemsPerRow, itemsPerPage, totalPages) +} diff --git a/tg_bot/screens/ui/date_time_picker.go b/tg_bot/screens/ui/date_time_picker.go new file mode 100644 index 0000000..2ed728d --- /dev/null +++ b/tg_bot/screens/ui/date_time_picker.go @@ -0,0 +1,720 @@ +package ui + +import ( + "fmt" + "regexp" + "strings" + "time" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/olebedev/when" + "github.com/olebedev/when/rules/common" + "github.com/olebedev/when/rules/ru" +) + +const ( + pickerViewCalendar = "calendar" + pickerViewMonths = "months" + pickerViewTimeCombined = "time_combined" + pickerViewConfirm = "confirm" + dtpCallbackPrefix = "dtp:" +) + +var MskLocation = time.FixedZone("MSK", 3*60*60) + +type DateTimePickerConfig struct { + Title string + Key string + IncludeTime bool + AllowPast bool + Selected *time.Time + BackState bot.State +} + +type DateTimeSelectionTarget interface { + SetDateTimeSelection(key string, value time.Time) +} + +type DateTimePicker struct { + Title string + Key string + IncludeTime bool + AllowPast bool + BackState bot.State + + view string + year int + month time.Month + selectedDate *time.Time + selectedHour *int + selectedMinute *int +} + +func NewDateTimePicker(cfg DateTimePickerConfig) *DateTimePicker { + now := time.Now().In(MskLocation) + year := now.Year() + month := now.Month() + var selected *time.Time + + if cfg.Selected != nil { + value := cfg.Selected.In(MskLocation) + selected = &value + year = value.Year() + month = value.Month() + } + + return &DateTimePicker{ + Title: cfg.Title, + Key: cfg.Key, + IncludeTime: cfg.IncludeTime, + AllowPast: cfg.AllowPast, + BackState: cfg.BackState, + view: pickerViewCalendar, + year: year, + month: month, + selectedDate: selected, + } +} + +func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) { + text := fmt.Sprintf("%s\n\n", s.Title) + text += fmt.Sprintf("%s\n\n", s.formatSelectedDateTime()) + switch s.view { + case pickerViewCalendar: + text += s.renderCalendarTitle() + b.Render(text, s.calendarKeyboard(), mode) + case pickerViewMonths: + text += "Выберите месяц\n\n" + b.Render(text, s.monthsKeyboard(), mode) + case pickerViewTimeCombined: + b.Render(text, s.timeCombinedKeyboard(), mode) + case pickerViewConfirm: + text += s.renderSelectedDateTime() + b.Render(text, s.confirmKeyboard(), mode) + } +} + +func (s *DateTimePicker) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + if data == "back" { + s.handleBack(b) + return + } + if data == "cancel" { + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + return + } + + if !strings.HasPrefix(data, dtpCallbackPrefix) { + s.Enter(b, bot.EditMessage) + return + } + + payload := strings.TrimPrefix(data, dtpCallbackPrefix) + switch { + case payload == "month_prev": + s.prevMonth() + case payload == "month_next": + s.nextMonth() + case payload == "open_months": + s.view = pickerViewMonths + case payload == "year_prev": + s.year-- + case payload == "year_next": + s.year++ + case strings.HasPrefix(payload, "month:"): + month := parseInt(strings.TrimPrefix(payload, "month:")) + if month >= 1 && month <= 12 { + s.month = time.Month(month) + s.view = pickerViewCalendar + } + case strings.HasPrefix(payload, "day:"): + s.handleDaySelect(payload, b) + return // confirmSelection уже делает Enter + case strings.HasPrefix(payload, "hour:"): + s.handleHourSelect(payload) + case strings.HasPrefix(payload, "min:"): + s.handleMinuteSelect(payload) + case payload == "confirm": + s.confirmSelection(b) + return + } + + // Если не было подтверждения (для дат без времени), перерисовываем + s.Enter(b, bot.EditMessage) +} + +func (s *DateTimePicker) handleBack(b *bot.Bot) { + switch s.view { + case pickerViewMonths: + s.view = pickerViewCalendar + case pickerViewTimeCombined: + s.view = pickerViewCalendar + case pickerViewConfirm: + if s.IncludeTime { + s.view = pickerViewTimeCombined + } else { + s.view = pickerViewCalendar + } + default: + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + return + } + } + s.Enter(b, bot.EditMessage) +} + +func (s *DateTimePicker) renderCalendarTitle() string { + if s.year == time.Now().In(MskLocation).Year() { + return "\n" + } + return fmt.Sprintf("%d\n\n", s.year) +} + +func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup { + var rows [][]echotron.InlineKeyboardButton + + rows = append(rows, []echotron.InlineKeyboardButton{ + s.monthPrevButton(), + {Text: monthName(s.month), CallbackData: dtpCallbackPrefix + "open_months"}, + {Text: "→", CallbackData: dtpCallbackPrefix + "month_next"}, + }) + + rows = append(rows, []echotron.InlineKeyboardButton{ + {Text: "Пн", CallbackData: "empty"}, + {Text: "Вт", CallbackData: "empty"}, + {Text: "Ср", CallbackData: "empty"}, + {Text: "Чт", CallbackData: "empty"}, + {Text: "Пт", CallbackData: "empty"}, + {Text: "Сб", CallbackData: "empty"}, + {Text: "Вс", CallbackData: "empty"}, + }) + + firstOfMonth := time.Date(s.year, s.month, 1, 0, 0, 0, 0, MskLocation) + weekday := int(firstOfMonth.Weekday()) + if weekday == 0 { + weekday = 7 + } + daysInMonth := daysInMonth(s.year, s.month) + today := time.Now().In(MskLocation) + todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation) + + var row []echotron.InlineKeyboardButton + for i := 1; i < weekday; i++ { + row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"}) + } + + for day := 1; day <= daysInMonth; day++ { + date := time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation) + if !s.AllowPast && date.Before(todayDate) { + row = append(row, echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}) + } else { + label := fmt.Sprintf("%d", day) + row = append(row, echotron.InlineKeyboardButton{ + Text: label, + CallbackData: fmt.Sprintf("%sday:%04d-%02d-%02d", dtpCallbackPrefix, s.year, int(s.month), day), + }) + } + if len(row) == 7 { + rows = append(rows, row) + row = nil + } + } + + if len(row) > 0 { + for len(row) < 7 { + row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"}) + } + rows = append(rows, row) + } + + rows = append(rows, []echotron.InlineKeyboardButton{ + {Text: "← Назад", CallbackData: "back"}, + {Text: "Отмена", CallbackData: "cancel"}, + }) + + return echotron.InlineKeyboardMarkup{InlineKeyboard: rows} +} + +func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup { + var rows [][]echotron.InlineKeyboardButton + + months := []string{"Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"} + for i := 0; i < 12; i += 3 { + rows = append(rows, []echotron.InlineKeyboardButton{ + {Text: months[i], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+1)}, + {Text: months[i+1], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+2)}, + {Text: months[i+2], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+3)}, + }) + } + + rows = append(rows, []echotron.InlineKeyboardButton{ + {Text: "← Назад", CallbackData: "back"}, + }) + + return echotron.InlineKeyboardMarkup{InlineKeyboard: rows} +} + +func (s *DateTimePicker) timeCombinedKeyboard() echotron.InlineKeyboardMarkup { + var rows [][]echotron.InlineKeyboardButton + rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Часы", CallbackData: "empty"}}) + for i := 0; i < 24; i += 6 { + rows = append(rows, []echotron.InlineKeyboardButton{ + s.hourButton(i), + s.hourButton(i + 1), + s.hourButton(i + 2), + s.hourButton(i + 3), + s.hourButton(i + 4), + s.hourButton(i + 5), + }) + } + rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Минуты", CallbackData: "empty"}}) + minutes := []int{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55} + for i := 0; i < len(minutes); i += 6 { + rows = append(rows, []echotron.InlineKeyboardButton{ + s.minuteButton(minutes[i]), + s.minuteButton(minutes[i+1]), + s.minuteButton(minutes[i+2]), + s.minuteButton(minutes[i+3]), + s.minuteButton(minutes[i+4]), + s.minuteButton(minutes[i+5]), + }) + } + rows = append(rows, []echotron.InlineKeyboardButton{ + {Text: "← Назад", CallbackData: "back"}, + {Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"}, + }) + return echotron.InlineKeyboardMarkup{InlineKeyboard: rows} +} + +func (s *DateTimePicker) confirmKeyboard() echotron.InlineKeyboardMarkup { + return echotron.InlineKeyboardMarkup{ + InlineKeyboard: [][]echotron.InlineKeyboardButton{ + { + {Text: "← Назад", CallbackData: "back"}, + {Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"}, + }, + }, + } +} + +func (s *DateTimePicker) renderSelectedDateTime() string { + if s.selectedDate == nil { + return "—" + } + hour := 0 + minute := 0 + if s.selectedHour != nil { + hour = *s.selectedHour + } + if s.selectedMinute != nil { + minute = *s.selectedMinute + } + value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation) + return fmt.Sprintf("%s", formatCompactDateTime(value)) +} + +func (s *DateTimePicker) formatTimePreview(showPlaceholders bool) string { + hour := "__" + minute := "__" + if s.selectedHour != nil { + hour = fmt.Sprintf("%02d", *s.selectedHour) + } else if !showPlaceholders { + hour = "00" + } + if s.selectedMinute != nil { + minute = fmt.Sprintf("%02d", *s.selectedMinute) + } else if !showPlaceholders { + minute = "00" + } + return fmt.Sprintf("%s:%s", hour, minute) +} + +func (s *DateTimePicker) formatSelectedDateTime() string { + displayDate, ok := s.displayDate() + if !ok { + return "—" + } + timeLabel := "--:--" + if s.selectedHour != nil && s.selectedMinute != nil { + timeLabel = fmt.Sprintf("%02d:%02d", *s.selectedHour, *s.selectedMinute) + } + weekday := WeekdayName(displayDate.Weekday()) + month := MonthShort(displayDate.Month()) + return fmt.Sprintf("%s %02d %s %s %dг.", weekday, displayDate.Day(), month, timeLabel, displayDate.Year()) +} + +func (s *DateTimePicker) displayDate() (time.Time, bool) { + if s.selectedDate == nil { + if s.view == pickerViewCalendar || s.view == pickerViewMonths { + return time.Date(s.year, s.month, 1, 0, 0, 0, 0, MskLocation), true + } + return time.Time{}, false + } + + if (s.view == pickerViewCalendar || s.view == pickerViewMonths) && + (s.selectedDate.Year() != s.year || s.selectedDate.Month() != s.month) { + day := s.selectedDate.Day() + maxDay := daysInMonth(s.year, s.month) + if day > maxDay { + day = maxDay + } + return time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation), true + } + + return s.selectedDate.In(MskLocation), true +} + +func (s *DateTimePicker) handleDaySelect(payload string, b *bot.Bot) { + datePart := strings.TrimPrefix(payload, "day:") + parsed, err := time.ParseInLocation("2006-01-02", datePart, MskLocation) + if err != nil { + return + } + s.selectedDate = &parsed + if s.IncludeTime { + s.view = pickerViewTimeCombined + s.selectedHour = nil + s.selectedMinute = nil + s.Enter(b, bot.EditMessage) + } else { + // Сразу подтверждаем выбор для даты без времени + s.confirmSelection(b) + } +} + +func (s *DateTimePicker) handleHourSelect(payload string) { + hour := parseInt(strings.TrimPrefix(payload, "hour:")) + if hour < 0 || hour > 23 { + return + } + if s.isHourDisabled(hour) { + return + } + s.selectedHour = &hour +} + +func (s *DateTimePicker) handleMinuteSelect(payload string) { + minute := parseInt(strings.TrimPrefix(payload, "min:")) + if minute < 0 || minute > 59 { + return + } + if s.isMinuteDisabled(minute) { + return + } + s.selectedMinute = &minute +} + +func (s *DateTimePicker) confirmSelection(b *bot.Bot) { + if s.selectedDate == nil { + s.Enter(b, bot.EditMessage) + return + } + if s.IncludeTime && (s.selectedHour == nil || s.selectedMinute == nil) { + s.view = pickerViewTimeCombined + s.Enter(b, bot.EditMessage) + return + } + hour := 0 + minute := 0 + if s.selectedHour != nil { + hour = *s.selectedHour + } + if s.selectedMinute != nil { + minute = *s.selectedMinute + } + value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation) + if target, ok := s.BackState.(DateTimeSelectionTarget); ok { + target.SetDateTimeSelection(s.Key, value) + } + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } +} + +func (s *DateTimePicker) hourButton(hour int) echotron.InlineKeyboardButton { + label := fmt.Sprintf("%02d", hour) + if s.isHourDisabled(hour) { + return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"} + } + if s.selectedHour != nil && *s.selectedHour == hour { + label = "● " + label + } + return echotron.InlineKeyboardButton{ + Text: label, + CallbackData: fmt.Sprintf("%shour:%02d", dtpCallbackPrefix, hour), + } +} + +func (s *DateTimePicker) minuteButton(minute int) echotron.InlineKeyboardButton { + label := fmt.Sprintf("%02d", minute) + if s.isMinuteDisabled(minute) { + return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"} + } + if s.selectedMinute != nil && *s.selectedMinute == minute { + label = "● " + label + } + return echotron.InlineKeyboardButton{ + Text: label, + CallbackData: fmt.Sprintf("%smin:%02d", dtpCallbackPrefix, minute), + } +} + +func (s *DateTimePicker) isHourDisabled(hour int) bool { + if s.AllowPast { + return false + } + if s.selectedDate == nil { + return false + } + today := time.Now().In(MskLocation) + date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation) + todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation) + if date.After(todayDate) { + return false + } + return hour < today.Hour() +} + +func (s *DateTimePicker) isMinuteDisabled(minute int) bool { + if s.AllowPast { + return false + } + if s.selectedDate == nil || s.selectedHour == nil { + return false + } + today := time.Now().In(MskLocation) + date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation) + todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation) + if date.After(todayDate) { + return false + } + if *s.selectedHour > today.Hour() { + return false + } + return minute < today.Minute() +} + +func (s *DateTimePicker) prevMonth() { + if s.AllowPast { + if s.month == time.January { + s.month = time.December + s.year-- + } else { + s.month-- + } + return + } + + now := time.Now().In(MskLocation) + if s.year == now.Year() && s.month == now.Month() { + return + } + if s.month == time.January { + s.month = time.December + s.year-- + } else { + s.month-- + } +} + +func (s *DateTimePicker) nextMonth() { + if s.month == time.December { + s.month = time.January + s.year++ + } else { + s.month++ + } +} + +func (s *DateTimePicker) monthPrevButton() echotron.InlineKeyboardButton { + if s.AllowPast { + return echotron.InlineKeyboardButton{Text: "←", CallbackData: dtpCallbackPrefix + "month_prev"} + } + + now := time.Now().In(MskLocation) + if s.year == now.Year() && s.month == now.Month() { + return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"} + } + return echotron.InlineKeyboardButton{Text: "←", CallbackData: dtpCallbackPrefix + "month_prev"} +} + +func daysInMonth(year int, month time.Month) int { + return time.Date(year, month+1, 0, 0, 0, 0, 0, MskLocation).Day() +} + +func monthName(month time.Month) string { + names := []string{ + "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", + "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь", + } + if int(month) < 1 || int(month) > len(names) { + return "" + } + return names[int(month)-1] +} + +func parseInt(value string) int { + result := 0 + for _, r := range value { + if r < '0' || r > '9' { + return 0 + } + result = result*10 + int(r-'0') + } + return result +} + +func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) { + if u.Message == nil || strings.TrimSpace(u.Message.Text) == "" { + return + } + + input := strings.TrimSpace(u.Message.Text) + switch s.view { + case pickerViewCalendar: + value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute, s.AllowPast) + if !hasDate { + s.Enter(b, bot.NewMessage) + return + } + s.selectedDate = &value + if hasTime { + hour := value.Hour() + minute := value.Minute() + s.selectedHour = &hour + s.selectedMinute = &minute + } + if s.IncludeTime { + s.view = pickerViewTimeCombined + } else { + s.view = pickerViewConfirm + } + s.Enter(b, bot.NewMessage) + case pickerViewTimeCombined: + value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute, s.AllowPast) + if !hasTime { + s.Enter(b, bot.NewMessage) + return + } + if hasDate { + s.selectedDate = &value + } + hour := value.Hour() + minute := value.Minute() + s.selectedHour = &hour + s.selectedMinute = &minute + s.Enter(b, bot.NewMessage) + default: + value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute, s.AllowPast) + if !hasDate && !hasTime { + s.Enter(b, bot.NewMessage) + return + } + s.selectedDate = &value + hour := value.Hour() + minute := value.Minute() + s.selectedHour = &hour + s.selectedMinute = &minute + s.view = pickerViewConfirm + s.Enter(b, bot.NewMessage) + } +} + +func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *DateTimePicker) Exit() {} + +var whenParser *when.Parser + +func init() { + whenParser = when.New(nil) + whenParser.Add(ru.All...) + whenParser.Add(common.All...) +} + +var ( + timeIndicator = regexp.MustCompile(`\d{1,2}[:.]\d{2}|утр|вечер|днём|ночь|час|минут|полдень|полночь`) + dateIndicator = regexp.MustCompile(`\d{1,2}[./-]\d|сегодня|завтра|вчера|послезавтра|понедельн|вторник|сред[уыа]|четверг|пятниц|суббот|воскресен|янв|фев|мар|апр|ма[йя]|июн|июл|авг|сен|окт|ноя|дек|через.*дн|через.*недел|через.*месяц|через.*год`) +) + +func detectTime(matched string) bool { + return timeIndicator.MatchString(matched) +} + +func detectDate(matched string) bool { + return dateIndicator.MatchString(matched) +} + +func parseDateTimeInput( + input string, + _ *time.Time, + _ *int, + _ *int, + allowPast bool, +) (time.Time, bool, bool) { + now := time.Now().In(MskLocation) + + r, err := whenParser.Parse(input, now) + if err != nil || r == nil { + return time.Time{}, false, false + } + + result := r.Time.In(MskLocation) + matched := strings.ToLower(r.Text) + + hasDate := detectDate(matched) + hasTime := detectTime(matched) + + if !allowPast && result.Before(now) { + return time.Time{}, false, false + } + + return result, hasDate, hasTime +} + +func WeekdayName(day time.Weekday) string { + switch day { + case time.Monday: + return "Пн" + case time.Tuesday: + return "Вт" + case time.Wednesday: + return "Ср" + case time.Thursday: + return "Чт" + case time.Friday: + return "Пт" + case time.Saturday: + return "Сб" + case time.Sunday: + return "Вс" + default: + return "" + } +} + +func formatCompactDateTime(value time.Time) string { + weekday := WeekdayName(value.Weekday()) + month := MonthShort(value.Month()) + return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04")) +} + +func MonthShort(month time.Month) string { + months := []string{ + "янв", "фев", "мар", "апр", "май", "июн", + "июл", "авг", "сен", "окт", "ноя", "дек", + } + if int(month) < 1 || int(month) > len(months) { + return "" + } + return months[int(month)-1] +} diff --git a/tg_bot/screens/ui/message_format.go b/tg_bot/screens/ui/message_format.go new file mode 100644 index 0000000..a3fbb35 --- /dev/null +++ b/tg_bot/screens/ui/message_format.go @@ -0,0 +1,305 @@ +package ui + +import ( + "fmt" + "sort" + "strings" + + "github.com/NicoNex/echotron/v3" +) + +type htmlTag struct { + open string + close string +} + +var simpleHTMLTags = map[echotron.MessageEntityType]htmlTag{ + echotron.BoldEntity: {open: "", close: ""}, + echotron.ItalicEntity: {open: "", close: ""}, + echotron.UnderlineEntity: {open: "", close: ""}, + echotron.StrikethroughEntity: {open: "", close: ""}, + echotron.CodeEntity: {open: "", close: ""}, + echotron.PreEntity: {open: "
", close: "
"}, +} + +// FormatMessageHTML converts Telegram entities to HTML and escapes the rest. +func FormatMessageHTML(message *echotron.Message) string { + if message == nil { + return "" + } + + // Telegram sends formatting as entities, so we rebuild the HTML from offsets. + text := message.Text + entities := message.Entities + if text == "" { + text = message.Caption + entities = message.CaptionEntities + } + + if text == "" { + return "" + } + + if len(entities) == 0 { + return EscapeHTML(text) + } + + type tagSpan struct { + open string + close string + start int + end int + len int + } + + runes := []rune(text) + positions := make([]int, len(runes)+1) + utf16Count := 0 + for i, r := range runes { + positions[i] = utf16Count + if r > 0xFFFF { + utf16Count += 2 + } else { + utf16Count++ + } + } + positions[len(runes)] = utf16Count + + utf16ToRuneIndex := func(utf16Index int) (int, bool) { + i := sort.Search(len(positions), func(i int) bool { return positions[i] >= utf16Index }) + if i < len(positions) && positions[i] == utf16Index { + return i, true + } + return 0, false + } + + toRuneRange := func(offset, length int) (int, int, bool) { + start, ok := utf16ToRuneIndex(offset) + if !ok { + return 0, 0, false + } + end, ok := utf16ToRuneIndex(offset + length) + if !ok || end > len(runes) || end < start { + return 0, 0, false + } + return start, end, true + } + + // URL ranges are used to prevent nested tags inside links. + urlRanges := make([][2]int, 0, len(entities)) + + for _, entity := range entities { + if entity == nil { + continue + } + if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity { + continue + } + start, end, ok := toRuneRange(entity.Offset, entity.Length) + if !ok { + continue + } + urlRanges = append(urlRanges, [2]int{start, end}) + if entity.Type == echotron.UrlEntity { + _ = string(runes[start:end]) + } + } + + isInsideURL := func(offset, length int) bool { + for _, urlRange := range urlRanges { + if offset > urlRange[0] && offset+length <= urlRange[1] { + return true + } + if offset < urlRange[1] && offset+length > urlRange[0] && (offset != urlRange[0] || length != urlRange[1]-urlRange[0]) { + return true + } + } + return false + } + + var spans []tagSpan + for _, entity := range entities { + if entity == nil { + continue + } + start, end, ok := toRuneRange(entity.Offset, entity.Length) + if !ok { + continue + } + + if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity && isInsideURL(start, end-start) { + continue + } + + var openTag, closeTag string + if tag, ok := simpleHTMLTags[entity.Type]; ok { + openTag, closeTag = tag.open, tag.close + } else if entity.Type == echotron.UrlEntity { + entityText := string(runes[start:end]) + openTag = fmt.Sprintf("", entityText) + closeTag = "" + } else if entity.Type == echotron.TextLinkEntity { + if entity.URL != "" { + openTag = fmt.Sprintf("", entity.URL) + closeTag = "" + } + } + + if openTag != "" && closeTag != "" { + spans = append(spans, tagSpan{ + open: openTag, + close: closeTag, + start: start, + end: end, + len: end - start, + }) + } + } + + opens := make(map[int][]tagSpan) + closes := make(map[int][]tagSpan) + for _, span := range spans { + opens[span.start] = append(opens[span.start], span) + closes[span.end] = append(closes[span.end], span) + } + + // Build the final text with tags inserted and HTML escaped. + var result strings.Builder + for i := 0; i <= len(runes); i++ { + if closing, ok := closes[i]; ok { + sort.Slice(closing, func(a, b int) bool { return closing[a].len < closing[b].len }) + for _, span := range closing { + result.WriteString(span.close) + } + } + if opening, ok := opens[i]; ok { + sort.Slice(opening, func(a, b int) bool { return opening[a].len > opening[b].len }) + for _, span := range opening { + result.WriteString(span.open) + } + } + if i < len(runes) { + switch runes[i] { + case '<': + result.WriteString("<") + case '>': + result.WriteString(">") + case '&': + result.WriteString("&") + default: + result.WriteRune(runes[i]) + } + } + } + + return normalizeHTMLTags(result.String()) +} + +func normalizeHTMLTags(input string) string { + if input == "" { + return input + } + + allowed := map[string]bool{ + "a": true, + "b": true, + "i": true, + "u": true, + "s": true, + "code": true, + "pre": true, + } + + var out strings.Builder + out.Grow(len(input)) + stack := make([]string, 0, 8) + + for i := 0; i < len(input); { + if input[i] != '<' { + out.WriteByte(input[i]) + i++ + continue + } + + end := strings.IndexByte(input[i:], '>') + if end == -1 { + out.WriteByte(input[i]) + i++ + continue + } + + end += i + tag := input[i+1 : end] + if tag == "" { + out.WriteString(input[i : end+1]) + i = end + 1 + continue + } + + isClosing := tag[0] == '/' + tagName := tag + if isClosing { + tagName = tag[1:] + } + if space := strings.IndexByte(tagName, ' '); space != -1 { + tagName = tagName[:space] + } + tagName = strings.TrimSpace(tagName) + + if !allowed[tagName] { + out.WriteString(input[i : end+1]) + i = end + 1 + continue + } + + if isClosing { + // Find the tag in stack + foundIndex := -1 + for j := len(stack) - 1; j >= 0; j-- { + if stack[j] == tagName { + foundIndex = j + break + } + } + + if foundIndex >= 0 { + // Close all tags from top of stack down to foundIndex + for j := len(stack) - 1; j > foundIndex; j-- { + out.WriteString("") + } + // Close the found tag + out.WriteString(input[i : end+1]) + // Remove closed tags from stack + stack = stack[:foundIndex] + } + } else { + stack = append(stack, tagName) + out.WriteString(input[i : end+1]) + } + + i = end + 1 + } + + for i := len(stack) - 1; i >= 0; i-- { + out.WriteString("") + } + + return out.String() +} + +// EscapeHTML escapes HTML special characters for safe output. +func EscapeHTML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +// SanitizeHTML normalizes tag nesting to prevent invalid HTML in Telegram parse mode. +func SanitizeHTML(input string) string { + return normalizeHTMLTags(input) +} diff --git a/tg_bot/screens/ui/pagination.go b/tg_bot/screens/ui/pagination.go new file mode 100644 index 0000000..4c01c67 --- /dev/null +++ b/tg_bot/screens/ui/pagination.go @@ -0,0 +1,219 @@ +package ui + +import ( + "fmt" + + "github.com/NicoNex/echotron/v3" +) + +// PaginationConfig конфигурация для построения навигационного ряда пагинации +type PaginationConfig struct { + CurrentPage int + TotalPages int // Для API пагинации (из page.Pages) + TotalItems int // Для локальной пагинации (len(array)) + ItemsPerPage int // Для расчета TotalPages из TotalItems + PrevCallback string // По умолчанию "prev" + NextCallback string // По умолчанию "next" + MiddleButtons []echotron.InlineKeyboardButton // Опциональные кнопки в центре (например, "+ Добавить") +} + +// BuildNavigationRow создает навигационный ряд с динамической шириной. +// Показывает ряд только если страниц > 1. +// Возвращает nil если пагинация не нужна. +func BuildNavigationRow(config PaginationConfig) []echotron.InlineKeyboardButton { + // Вычисляем TotalPages из TotalItems если не задан + totalPages := config.TotalPages + if totalPages == 0 && config.TotalItems > 0 && config.ItemsPerPage > 0 { + totalPages = CalculatePages(config.TotalItems, config.ItemsPerPage) + } + + // Устанавливаем значения по умолчанию для callbacks + prevCallback := config.PrevCallback + if prevCallback == "" { + prevCallback = "prev" + } + nextCallback := config.NextCallback + if nextCallback == "" { + nextCallback = "next" + } + + var row []echotron.InlineKeyboardButton + + // Левая стрелка (только если есть предыдущая страница) + if totalPages > 1 && config.CurrentPage > 0 { + row = append(row, echotron.InlineKeyboardButton{ + Text: "←", + CallbackData: prevCallback, + }) + } + + // Средние кнопки (если есть) + if len(config.MiddleButtons) > 0 { + row = append(row, config.MiddleButtons...) + } + + // Правая стрелка (только если есть следующая страница) + if totalPages > 1 && config.CurrentPage+1 < totalPages { + row = append(row, echotron.InlineKeyboardButton{ + Text: "→", + CallbackData: nextCallback, + }) + } + + if len(row) == 0 { + return nil + } + + return row +} + +// CalculatePages вычисляет количество страниц из общего количества элементов +func CalculatePages(totalItems, itemsPerPage int) int { + if itemsPerPage <= 0 { + return 0 + } + return (totalItems + itemsPerPage - 1) / itemsPerPage +} + +// GetPageBounds возвращает start/end индексы для текущей страницы. +// Автоматически сбрасывает на первую страницу если currentPage выходит за границы. +func GetPageBounds(currentPage, itemsPerPage, totalItems int) (start, end int) { + start = currentPage * itemsPerPage + if start >= totalItems { + start = 0 + } + end = start + itemsPerPage + if end > totalItems { + end = totalItems + } + return start, end +} + +// FormatPageInfo форматирует текст индикатора страницы для заголовка +func FormatPageInfo(currentPage, totalPages int) string { + if totalPages <= 1 { + return "" + } + return fmt.Sprintf(" (стр. %d/%d)", currentPage+1, totalPages) +} + +// ElementLayoutConfig конфигурация для автоматической раскладки элементов с пагинацией +type ElementLayoutConfig struct { + CurrentPage int // Текущая страница (0-indexed) + ItemsPerPage int // Элементов на странице + ItemsPerRow int // Элементов в одном ряду + EmptyButton *echotron.InlineKeyboardButton // Кнопка-заполнитель (опционально, по умолчанию " ") +} + +// BuildElementRows автоматически раскладывает элементы с пагинацией. +// Принимает все элементы, возвращает ряды для текущей страницы с правильной раскладкой. +// Автоматически заполняет пустыми кнопками если страниц > 1 (для консистентности высоты). +// Возвращает 2D массив кнопок готовый к добавлению в клавиатуру. +// +// Пример использования: +// +// config := ui.ElementLayoutConfig{ +// CurrentPage: 0, +// ItemsPerPage: 6, +// ItemsPerRow: 2, +// } +// rows := ui.BuildElementRows(config, allButtons) +// keyboard = append(keyboard, rows...) +func BuildElementRows(config ElementLayoutConfig, allItems []echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton { + if len(allItems) == 0 { + return nil + } + + // Параметры по умолчанию + if config.ItemsPerPage <= 0 { + config.ItemsPerPage = 6 + } + if config.ItemsPerRow <= 0 { + config.ItemsPerRow = 2 + } + + // Кнопка-заполнитель по умолчанию + emptyButton := echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"} + if config.EmptyButton != nil { + emptyButton = *config.EmptyButton + } + + // Вычисляем границы страницы + totalPages := CalculatePages(len(allItems), config.ItemsPerPage) + start, end := GetPageBounds(config.CurrentPage, config.ItemsPerPage, len(allItems)) + + // Получаем элементы текущей страницы + pageItems := allItems[start:end] + + // Если страниц >= 2, заполняем до ItemsPerPage пустыми кнопками + // Это обеспечивает одинаковую высоту клавиатуры на всех страницах + if totalPages >= 2 { + for len(pageItems) < config.ItemsPerPage { + pageItems = append(pageItems, emptyButton) + } + } + + // Раскладываем элементы по рядам + var rows [][]echotron.InlineKeyboardButton + for i := 0; i < len(pageItems); i += config.ItemsPerRow { + var row []echotron.InlineKeyboardButton + + // Добавляем элементы в ряд + for j := 0; j < config.ItemsPerRow && i+j < len(pageItems); j++ { + row = append(row, pageItems[i+j]) + } + + // Если в ряду меньше элементов чем ItemsPerRow, заполняем пустыми + for len(row) < config.ItemsPerRow { + row = append(row, emptyButton) + } + + rows = append(rows, row) + } + + return rows +} + +// BuildPageRows раскладывает элементы текущей страницы без собственной пагинации. +// Автоматически заполняет пустыми кнопками если страниц > 1 (для консистентности высоты). +func BuildPageRows(pageItems []echotron.InlineKeyboardButton, itemsPerRow, itemsPerPage, totalPages int) [][]echotron.InlineKeyboardButton { + if len(pageItems) == 0 { + return nil + } + + // Параметры по умолчанию + if itemsPerPage <= 0 { + itemsPerPage = 6 + } + if itemsPerRow <= 0 { + itemsPerRow = 2 + } + + // Кнопка-заполнитель по умолчанию + emptyButton := echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"} + + // Если страниц >= 2, заполняем до ItemsPerPage пустыми кнопками + if totalPages >= 2 { + for len(pageItems) < itemsPerPage { + pageItems = append(pageItems, emptyButton) + } + } + + // Раскладываем элементы по рядам + var rows [][]echotron.InlineKeyboardButton + for i := 0; i < len(pageItems); i += itemsPerRow { + var row []echotron.InlineKeyboardButton + + for j := 0; j < itemsPerRow && i+j < len(pageItems); j++ { + row = append(row, pageItems[i+j]) + } + + for len(row) < itemsPerRow { + row = append(row, emptyButton) + } + + rows = append(rows, row) + } + + return rows +} diff --git a/tg_bot/screens/workspace_menu.go b/tg_bot/screens/workspace_menu.go new file mode 100644 index 0000000..7d795d8 --- /dev/null +++ b/tg_bot/screens/workspace_menu.go @@ -0,0 +1,134 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" + "github.com/rs/zerolog/log" +) + +type WorkspaceMenu struct { + CurrentPage int + BackState bot.State +} + +const msgWorkspaceEmpty = ` +У вас пока нет рабочих пространств. +` + +const workspacesPerPage = 6 + +func (s *WorkspaceMenu) Enter(b *bot.Bot, mode bot.RenderMode) { + workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT) + if err != nil { + log.Error().Err(err).Msg("Failed to get workspaces for workspace menu") + b.SendNew("❌ Не удалось загрузить рабочие пространства", Keyboard(Row(Button("← Назад", "back")))) + return + } + + if len(workspaces) == 0 { + kb := Keyboard(Row(Button("← Назад", "back"))) + b.Render(msgWorkspaceEmpty, kb, mode) + return + } + + var allButtons []echotron.InlineKeyboardButton + for _, ws := range workspaces { + label := ws.Name + if ws.ID == b.Session.WorkspaceID { + label = "● " + label + } + allButtons = append(allButtons, Button(label, fmt.Sprintf("workspace_select:%s", ws.ID))) + } + + elementRows := ui.BuildElementRows(ui.ElementLayoutConfig{ + CurrentPage: s.CurrentPage, + ItemsPerPage: workspacesPerPage, + ItemsPerRow: 2, + }, allButtons) + + var buttons [][]echotron.InlineKeyboardButton + buttons = append(buttons, elementRows...) + + if navRow := ui.BuildNavigationRow(ui.PaginationConfig{ + CurrentPage: s.CurrentPage, + TotalItems: len(allButtons), + ItemsPerPage: workspacesPerPage, + }); navRow != nil { + buttons = append(buttons, navRow) + } + + buttons = append(buttons, Row(Button("← Назад", "back"))) + + totalPages := ui.CalculatePages(len(allButtons), workspacesPerPage) + text := fmt.Sprintf(`Рабочие пространства%s + +Выберите текущее рабочее пространство.`, ui.FormatPageInfo(s.CurrentPage, totalPages)) + + b.Render(text, Keyboard(buttons...), mode) +} + +func (s *WorkspaceMenu) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "prev": + if s.CurrentPage > 0 { + s.CurrentPage-- + } + s.Enter(b, bot.EditMessage) + + case data == "next": + s.CurrentPage++ + s.Enter(b, bot.EditMessage) + + case data == "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + return + } + b.SetState(&MainMenu{}, bot.EditMessage) + + case strings.HasPrefix(data, "workspace_select:"): + parts := strings.Split(data, ":") + if len(parts) != 2 { + return + } + workspaceID := parts[1] + workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT) + if err != nil { + log.Error().Err(err).Msg("Failed to get workspaces for selection") + b.SendNew("❌ Не удалось загрузить рабочие пространства", Keyboard(Row(Button("← Назад", "back")))) + return + } + for _, ws := range workspaces { + if ws.ID == workspaceID { + b.Session.WorkspaceID = ws.ID + break + } + } + + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + return + } + b.SetState(&MainMenu{}, bot.EditMessage) + + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *WorkspaceMenu) HandleMessage(_ *bot.Bot, _ *echotron.Update) {} + +func (s *WorkspaceMenu) Handle(_ *bot.Bot, _ *echotron.Update) {} + +func (s *WorkspaceMenu) Exit() {} diff --git a/tg_parser/Dockerfile b/tg_parser/Dockerfile new file mode 100644 index 0000000..2f5e100 --- /dev/null +++ b/tg_parser/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.25-alpine AS build + +WORKDIR /app/tg_parser + +# Modules layer +COPY tg_parser/go.mod tg_parser/go.sum ./ +COPY pkg /app/pkg +RUN go mod download + +# Build layer +COPY tg_parser /app/tg_parser +RUN CGO_ENABLED=0 GOOS=linux go build -o /parser . + +FROM alpine:latest AS run + +COPY --from=build /parser /parser + +CMD ["/parser"] diff --git a/tg_parser/cmd/auth/main.go b/tg_parser/cmd/auth/main.go new file mode 100644 index 0000000..755b287 --- /dev/null +++ b/tg_parser/cmd/auth/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "strconv" + "strings" + + "github.com/gotd/td/session" + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/auth" + "github.com/gotd/td/tg" +) + +func main() { + apiID := mustEnvInt("TELEGRAM__API_ID") + apiHash := mustEnv("TELEGRAM__API_HASH") + sessionFile := mustEnv("TELEGRAM__SESSION_FILE") + + phone := strings.TrimSpace(os.Getenv("TELEGRAM__PHONE")) + if phone == "" { + phone = prompt("Phone (e.g. +79991234567): ") + } + + password := strings.TrimSpace(os.Getenv("TELEGRAM__PASSWORD")) + if password == "" { + password = prompt("2FA password (empty if not set): ") + } + + ctx := context.Background() + + client := telegram.NewClient(apiID, apiHash, telegram.Options{ + SessionStorage: &session.FileStorage{Path: sessionFile}, + }) + + err := client.Run(ctx, func(ctx context.Context) error { + return client.Auth().IfNecessary(ctx, auth.NewFlow( + auth.Constant(phone, password, auth.CodeAuthenticatorFunc( + func(ctx context.Context, sentCode *tg.AuthSentCode) (string, error) { + return prompt("Enter code: "), nil + }, + )), + auth.SendCodeOptions{}, + )) + }) + if err != nil { + fmt.Fprintf(os.Stderr, "auth failed: %v\n", err) + os.Exit(1) + } + + fmt.Println("✓ Authorized") +} + +func mustEnv(key string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + fmt.Fprintf(os.Stderr, "missing required env: %s\n", key) + os.Exit(1) + } + return value +} + +func mustEnvInt(key string) int { + raw := mustEnv(key) + v, err := strconv.Atoi(raw) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid int in %s: %v\n", key, err) + os.Exit(1) + } + return v +} + +func prompt(label string) string { + fmt.Print(label) + reader := bufio.NewReader(os.Stdin) + value, _ := reader.ReadString('\n') + return strings.TrimSpace(value) +} diff --git a/tg_parser/config/config.go b/tg_parser/config/config.go new file mode 100644 index 0000000..bc9c0f0 --- /dev/null +++ b/tg_parser/config/config.go @@ -0,0 +1,48 @@ +package config + +import ( + "errors" + "fmt" + "os" + + "github.com/TelegramExchange/pkg/postgres" + "github.com/TelegramExchange/pkg/telegram" + "github.com/joho/godotenv" + "github.com/kelseyhightower/envconfig" + + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/controller/worker" +) + +type HTTP struct { + Addr string `envconfig:"HTTP__ADDR" default:":8080"` +} + +type LoggerConfig struct { + Level string `default:"info" envconfig:"PARSER__LOGGER__LEVEL"` + PrettyConsole bool `default:"true" envconfig:"PARSER__LOGGER__PRETTY_CONSOLE"` +} + +type Config struct { + Logger LoggerConfig + Postgres postgres.Config + Telegram telegram.Config + ChannelWorker worker.ChannelConfig + ViewsWorker worker.ViewsConfig + HTTP HTTP +} + +func New() (Config, error) { + var config Config + + err := godotenv.Load(".env") + if err != nil && !errors.Is(err, os.ErrNotExist) { + return config, fmt.Errorf("godotenv.Load: %w", err) + } + + err = envconfig.Process("", &config) + if err != nil { + return config, fmt.Errorf("envconfig.Process: %w", err) + } + + return config, nil +} diff --git a/tg_parser/go.mod b/tg_parser/go.mod new file mode 100644 index 0000000..d56b364 --- /dev/null +++ b/tg_parser/go.mod @@ -0,0 +1,54 @@ +module github.com/TelegramExchange/tgex-backend/tg_parser + +go 1.25.0 + +require ( + github.com/gotd/td v0.136.0 + github.com/jackc/pgx/v5 v5.7.6 + github.com/joho/godotenv v1.5.1 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/rs/zerolog v1.34.0 + github.com/TelegramExchange/pkg v0.0.0 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-faster/jx v1.2.0 // indirect + github.com/go-faster/xor v1.0.0 // indirect + github.com/go-faster/yaml v0.4.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gotd/ige v0.2.2 // indirect + github.com/gotd/neo v0.1.5 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ogen-go/ogen v1.16.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/tools v0.39.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + rsc.io/qr v0.2.0 // indirect +) + +replace github.com/TelegramExchange/pkg => ../pkg diff --git a/tg_parser/go.sum b/tg_parser/go.sum new file mode 100644 index 0000000..e2a6a74 --- /dev/null +++ b/tg_parser/go.sum @@ -0,0 +1,130 @@ +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI= +github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE= +github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38= +github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ= +github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I= +github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk= +github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0= +github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ= +github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= +github.com/gotd/td v0.136.0 h1:f7vx/1rlvP59L5EKR820XpMRO2k267wW8/F0rAWbepc= +github.com/gotd/td v0.136.0/go.mod h1:mStcqs/9FXhNhWnPTguptSwqkQbRIwXLw3SCSpzPJxM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ogen-go/ogen v1.16.0 h1:fKHEYokW/QrMzVNXId74/6RObRIUs9T2oroGKtR25Iw= +github.com/ogen-go/ogen v1.16.0/go.mod h1:s3nWiMzybSf8fhxckyO+wtto92+QHpEL8FmkPnhL3jI= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/tg_parser/internal/adapter/database/create_post.go b/tg_parser/internal/adapter/database/create_post.go new file mode 100644 index 0000000..0a2fe11 --- /dev/null +++ b/tg_parser/internal/adapter/database/create_post.go @@ -0,0 +1,45 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + + "github.com/TelegramExchange/pkg/transaction" + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" +) + +func (d *Database) CreatePost(ctx context.Context, post domain.Post) error { + query := `INSERT INTO post (id, channel_id, message_id, text, published_at) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (channel_id, message_id) DO UPDATE +SET text = EXCLUDED.text, + published_at = COALESCE(post.published_at, EXCLUDED.published_at), + updated_at = CURRENT_TIMESTAMP;` + + txOrPool := transaction.TryExtractTX(ctx) + + dto := createPostDTO{ + ID: pgtype.UUID{Bytes: post.ID, Valid: true}, + ChannelID: pgtype.UUID{Bytes: post.ChannelID, Valid: true}, + MessageID: pgtype.Int4{Int32: int32(post.MessageID), Valid: true}, + Text: pgtype.Text{String: post.Text, Valid: true}, + PublishedAt: pgtype.Timestamptz{Time: post.PublishedAt, Valid: !post.PublishedAt.IsZero()}, + } + + _, err := txOrPool.Exec(ctx, query, dto.ID, dto.ChannelID, dto.MessageID, dto.Text, dto.PublishedAt) + if err != nil { + return fmt.Errorf("txOrPool.Exec: %w", err) + } + + return nil +} + +type createPostDTO struct { + ID pgtype.UUID + ChannelID pgtype.UUID + MessageID pgtype.Int4 + Text pgtype.Text + PublishedAt pgtype.Timestamptz +} diff --git a/tg_parser/internal/adapter/database/create_views_snapshot.go b/tg_parser/internal/adapter/database/create_views_snapshot.go new file mode 100644 index 0000000..ed00999 --- /dev/null +++ b/tg_parser/internal/adapter/database/create_views_snapshot.go @@ -0,0 +1,40 @@ +package database + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/TelegramExchange/pkg/transaction" + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" +) + +func (d *Database) CreateViewsSnapshot(ctx context.Context, snapshot domain.ViewsSnapshot) error { + query := `INSERT INTO post_views_history (id, views_count, fetched_at, post_id) +VALUES ($1, $2, $3, $4);` + + txOrPool := transaction.TryExtractTX(ctx) + + dto := createViewsSnapshotDTO{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, + ViewsCount: pgtype.Int4{Int32: int32(snapshot.ViewsCount), Valid: true}, + FetchedAt: pgtype.Timestamptz{Time: snapshot.FetchedAt, Valid: true}, + PostID: pgtype.UUID{Bytes: snapshot.PostID, Valid: true}, + } + + _, err := txOrPool.Exec(ctx, query, dto.ID, dto.ViewsCount, dto.FetchedAt, dto.PostID) + if err != nil { + return fmt.Errorf("txOrPool.Exec: %w", err) + } + + return nil +} + +type createViewsSnapshotDTO struct { + ID pgtype.UUID + ViewsCount pgtype.Int4 + FetchedAt pgtype.Timestamptz + PostID pgtype.UUID +} diff --git a/tg_parser/internal/adapter/database/database.go b/tg_parser/internal/adapter/database/database.go new file mode 100644 index 0000000..65305af --- /dev/null +++ b/tg_parser/internal/adapter/database/database.go @@ -0,0 +1,7 @@ +package database + +type Database struct{} + +func New() *Database { + return &Database{} +} diff --git a/tg_parser/internal/adapter/database/delete_post.go b/tg_parser/internal/adapter/database/delete_post.go new file mode 100644 index 0000000..b5fc1dc --- /dev/null +++ b/tg_parser/internal/adapter/database/delete_post.go @@ -0,0 +1,37 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + + "github.com/TelegramExchange/pkg/transaction" + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" +) + +func (d *Database) DeletePost(ctx context.Context, p domain.Post) error { + query := `UPDATE post +SET deleted_from_channel_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP +WHERE channel_id = $1 AND message_id = $2;` + + txOrPool := transaction.TryExtractTX(ctx) + + dto := deletePostDTO{ + ChannelID: pgtype.UUID{Bytes: p.ChannelID, Valid: true}, + MessageID: pgtype.Int4{Int32: int32(p.MessageID), Valid: true}, + } + + _, err := txOrPool.Exec(ctx, query, dto.ChannelID, dto.MessageID) + if err != nil { + return fmt.Errorf("txOrPool.Exec: %w", err) + } + + return nil +} + +type deletePostDTO struct { + ChannelID pgtype.UUID + MessageID pgtype.Int4 +} diff --git a/tg_parser/internal/adapter/database/get_channels.go b/tg_parser/internal/adapter/database/get_channels.go new file mode 100644 index 0000000..9d9004a --- /dev/null +++ b/tg_parser/internal/adapter/database/get_channels.go @@ -0,0 +1,88 @@ +package database + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/rs/zerolog/log" + + "github.com/TelegramExchange/pkg/transaction" + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" +) + +func (d *Database) GetChannels(ctx context.Context) []domain.Channel { + query := `SELECT + id, + telegram_id, + username, + title, + access_hash, + pts, + invite_link, + is_accessible +FROM channel +WHERE deleted_at IS NULL + AND is_accessible = true;` + + txOrPool := transaction.TryExtractTX(ctx) + + rows, err := txOrPool.Query(ctx, query) + if err != nil { + log.Error().Err(err).Msg("txOrPool.Query failed") + return []domain.Channel{} + } + defer rows.Close() + + channels := make([]domain.Channel, 0) + + for rows.Next() { + var dto getChannelsDTO + + err = rows.Scan(dto.destination()...) + if err != nil { + log.Error().Err(err).Msg("rows.Scan failed") + return []domain.Channel{} + } + + channels = append(channels, dto.toDomain()) + } + + return channels +} + +type getChannelsDTO struct { + ID pgtype.UUID + TelegramID pgtype.Int8 + Username pgtype.Text + Title pgtype.Text + AccessHash pgtype.Int8 + Pts pgtype.Int4 + InviteLink pgtype.Text + IsAccessible pgtype.Bool +} + +func (dto *getChannelsDTO) destination() []any { + return []any{ + &dto.ID, + &dto.TelegramID, + &dto.Username, + &dto.Title, + &dto.AccessHash, + &dto.Pts, + &dto.InviteLink, + &dto.IsAccessible, + } +} + +func (dto *getChannelsDTO) toDomain() domain.Channel { + return domain.Channel{ + ID: dto.ID.Bytes, + TelegramID: domain.NormalizeChatID(dto.TelegramID.Int64), + Username: dto.Username.String, + Title: dto.Title.String, + AccessHash: dto.AccessHash.Int64, + Pts: int(dto.Pts.Int32), + InviteLink: dto.InviteLink.String, + IsAccessible: dto.IsAccessible.Bool, + } +} diff --git a/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go b/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go new file mode 100644 index 0000000..c09ceb0 --- /dev/null +++ b/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go @@ -0,0 +1,89 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + + "github.com/TelegramExchange/pkg/transaction" + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" +) + +func (d *Database) GetChannelsWithTrackedPosts(ctx context.Context) ([]domain.Channel, error) { + query := `SELECT DISTINCT + c.id, + c.telegram_id, + c.username, + c.title, + c.access_hash, + c.pts, + c.invite_link, + c.is_accessible +FROM channel c +INNER JOIN placement p ON p.channel_id = c.id +INNER JOIN placement_post pp ON pp.placement_id = p.id +INNER JOIN post po ON po.id = pp.post_id +WHERE po.deleted_from_channel_at IS NULL + AND c.is_accessible = true;` + + txOrPool := transaction.TryExtractTX(ctx) + + rows, err := txOrPool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("txOrPool.Query: %w", err) + } + defer rows.Close() + + channels := make([]domain.Channel, 0) + + for rows.Next() { + var dto getChannelsWithTrackedPostsDTO + + err = rows.Scan(dto.destination()...) + if err != nil { + return nil, fmt.Errorf("rows.Scan: %w", err) + } + + channels = append(channels, dto.toDomain()) + } + + return channels, nil +} + +type getChannelsWithTrackedPostsDTO struct { + ID pgtype.UUID + TelegramID pgtype.Int8 + Username pgtype.Text + Title pgtype.Text + AccessHash pgtype.Int8 + Pts pgtype.Int4 + InviteLink pgtype.Text + IsAccessible pgtype.Bool +} + +func (dto *getChannelsWithTrackedPostsDTO) destination() []any { + return []any{ + &dto.ID, + &dto.TelegramID, + &dto.Username, + &dto.Title, + &dto.AccessHash, + &dto.Pts, + &dto.InviteLink, + &dto.IsAccessible, + } +} + +func (dto *getChannelsWithTrackedPostsDTO) toDomain() domain.Channel { + return domain.Channel{ + ID: dto.ID.Bytes, + TelegramID: domain.NormalizeChatID(dto.TelegramID.Int64), + Username: dto.Username.String, + Title: dto.Title.String, + AccessHash: dto.AccessHash.Int64, + Pts: int(dto.Pts.Int32), + InviteLink: dto.InviteLink.String, + IsAccessible: dto.IsAccessible.Bool, + } +} diff --git a/tg_parser/internal/adapter/database/get_tracked_posts.go b/tg_parser/internal/adapter/database/get_tracked_posts.go new file mode 100644 index 0000000..f78fd0d --- /dev/null +++ b/tg_parser/internal/adapter/database/get_tracked_posts.go @@ -0,0 +1,91 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgtype" + + "github.com/TelegramExchange/pkg/transaction" + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" +) + +func (d *Database) GetTrackedPosts(ctx context.Context, channel domain.Channel) ([]domain.Post, error) { + query := `SELECT DISTINCT + p.id, + p.channel_id, + p.message_id, + p.text, + CASE + WHEN c.username IS NOT NULL THEN CONCAT('https://t.me/', c.username, '/', p.message_id) + WHEN c.telegram_id IS NOT NULL THEN CONCAT('https://t.me/c/', (-c.telegram_id - 1000000000000), '/', p.message_id) + ELSE '' + END as link, + COALESCE( + (SELECT pvh.views_count + FROM post_views_history pvh + WHERE pvh.post_id = p.id + ORDER BY pvh.fetched_at DESC + LIMIT 1), + 0 + ) as views +FROM post p +INNER JOIN channel c ON c.id = p.channel_id +INNER JOIN placement_post pp ON pp.post_id = p.id +WHERE p.channel_id = $1 + AND p.deleted_from_channel_at IS NULL;` + + txOrPool := transaction.TryExtractTX(ctx) + + rows, err := txOrPool.Query(ctx, query, pgtype.UUID{Bytes: channel.ID, Valid: true}) + if err != nil { + return nil, fmt.Errorf("txOrPool.Query: %w", err) + } + defer rows.Close() + + posts := make([]domain.Post, 0) + + for rows.Next() { + var dto getTrackedPostsDTO + + err = rows.Scan(dto.destination()...) + if err != nil { + return nil, fmt.Errorf("rows.Scan: %w", err) + } + + posts = append(posts, dto.toDomain()) + } + + return posts, nil +} + +type getTrackedPostsDTO struct { + ID pgtype.UUID + ChannelID pgtype.UUID + MessageID pgtype.Int4 + Text pgtype.Text + Link pgtype.Text + Views pgtype.Int4 +} + +func (dto *getTrackedPostsDTO) destination() []any { + return []any{ + &dto.ID, + &dto.ChannelID, + &dto.MessageID, + &dto.Text, + &dto.Link, + &dto.Views, + } +} + +func (dto *getTrackedPostsDTO) toDomain() domain.Post { + return domain.Post{ + ID: dto.ID.Bytes, + ChannelID: dto.ChannelID.Bytes, + MessageID: int(dto.MessageID.Int32), + Text: dto.Text.String, + Link: dto.Link.String, + Views: int(dto.Views.Int32), + } +} diff --git a/tg_parser/internal/adapter/database/update_channel.go b/tg_parser/internal/adapter/database/update_channel.go new file mode 100644 index 0000000..992ef17 --- /dev/null +++ b/tg_parser/internal/adapter/database/update_channel.go @@ -0,0 +1,124 @@ +package database + +import ( + "context" + "fmt" + "strings" + + "github.com/TelegramExchange/pkg/transaction" + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" + "github.com/jackc/pgx/v5/pgtype" +) + +func (d *Database) UpdateChannelIfNotAccessible(ctx context.Context, channel domain.Channel) error { + query := `UPDATE channel +SET telegram_id = $2, + username = $3, + title = $4, + access_hash = $5, + pts = 0, + is_accessible = TRUE, + invite_link = $6, + updated_at = CURRENT_TIMESTAMP +WHERE telegram_id = $1 + AND is_accessible = FALSE;` + + txOrPool := transaction.TryExtractTX(ctx) + + username := strings.TrimSpace(channel.Username) + var usernameDTO pgtype.Text + if username != "" { + usernameDTO = pgtype.Text{String: username, Valid: true} + } else { + usernameDTO = pgtype.Text{Valid: false} + } + + inviteLink := strings.TrimSpace(channel.InviteLink) + var inviteLinkDTO pgtype.Text + if inviteLink != "" { + inviteLinkDTO = pgtype.Text{String: inviteLink, Valid: true} + } else { + inviteLinkDTO = pgtype.Text{Valid: false} + } + + result, err := txOrPool.Exec(ctx, query, + channel.TelegramID, + pgtype.Int8{Int64: channel.TelegramID, Valid: true}, + usernameDTO, + pgtype.Text{String: channel.Title, Valid: true}, + pgtype.Int8{Int64: channel.AccessHash, Valid: true}, + inviteLinkDTO, + ) + if err != nil { + return fmt.Errorf("txOrPool.Exec: %w", err) + } + + // Log if no rows were updated (channel either doesn't exist or is already accessible) + rowsAffected := result.RowsAffected() + if rowsAffected == 0 { + // Channel doesn't exist or is already accessible - this is fine + return nil + } + + return nil +} + +func (d *Database) UpdateChannel(ctx context.Context, channel domain.Channel) error { + query := `UPDATE channel +SET telegram_id = $2, + username = $3, + title = $4, + access_hash = $5, + pts = $6, + is_accessible = $7, + invite_link = $8, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1;` + + txOrPool := transaction.TryExtractTX(ctx) + + username := strings.TrimSpace(channel.Username) + var usernameDTO pgtype.Text + if username != "" { + usernameDTO = pgtype.Text{String: username, Valid: true} + } else { + usernameDTO = pgtype.Text{Valid: false} + } + + inviteLink := strings.TrimSpace(channel.InviteLink) + var inviteLinkDTO pgtype.Text + if inviteLink != "" { + inviteLinkDTO = pgtype.Text{String: inviteLink, Valid: true} + } else { + inviteLinkDTO = pgtype.Text{Valid: false} + } + + dto := updateChannelDTO{ + ID: pgtype.UUID{Bytes: channel.ID, Valid: true}, + TelegramID: pgtype.Int8{Int64: channel.TelegramID, Valid: true}, + Username: usernameDTO, + Title: pgtype.Text{String: channel.Title, Valid: true}, + AccessHash: pgtype.Int8{Int64: channel.AccessHash, Valid: true}, + Pts: pgtype.Int4{Int32: int32(channel.Pts), Valid: true}, + IsAccessible: pgtype.Bool{Bool: channel.IsAccessible, Valid: true}, + InviteLink: inviteLinkDTO, + } + + _, err := txOrPool.Exec(ctx, query, dto.ID, dto.TelegramID, dto.Username, dto.Title, dto.AccessHash, dto.Pts, dto.IsAccessible, dto.InviteLink) + if err != nil { + return fmt.Errorf("txOrPool.Exec: %w", err) + } + + return nil +} + +type updateChannelDTO struct { + ID pgtype.UUID + TelegramID pgtype.Int8 + Username pgtype.Text + Title pgtype.Text + AccessHash pgtype.Int8 + Pts pgtype.Int4 + IsAccessible pgtype.Bool + InviteLink pgtype.Text +} diff --git a/tg_parser/internal/adapter/telegram/get_active_views.go b/tg_parser/internal/adapter/telegram/get_active_views.go new file mode 100644 index 0000000..80018d6 --- /dev/null +++ b/tg_parser/internal/adapter/telegram/get_active_views.go @@ -0,0 +1,37 @@ +package telegram + +import ( + "context" + "fmt" + + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" + "github.com/gotd/td/tg" +) + +func (t *Telegram) UpdatePostsViews(ctx context.Context, channel domain.Channel, posts []domain.Post) error { + ids := make([]int, len(posts)) + for i, p := range posts { + ids[i] = p.MessageID + } + + req := &tg.MessagesGetMessagesViewsRequest{ + Peer: &tg.InputPeerChannel{ + ChannelID: channel.ChannelID(), + AccessHash: channel.AccessHash, + }, + ID: ids, + Increment: false, + } + + resp, err := t.API().MessagesGetMessagesViews(ctx, req) + if err != nil { + return fmt.Errorf("get messages views: %w", err) + } + + // Telegram гарантирует, что resp.Views соответствует порядку ids + for i, v := range resp.Views { + posts[i].Views = v.Views + } + + return nil +} diff --git a/tg_parser/internal/adapter/telegram/get_channel_diff.go b/tg_parser/internal/adapter/telegram/get_channel_diff.go new file mode 100644 index 0000000..28f02b4 --- /dev/null +++ b/tg_parser/internal/adapter/telegram/get_channel_diff.go @@ -0,0 +1,125 @@ +package telegram + +import ( + "context" + "fmt" + "time" + + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" + "github.com/gotd/td/tg" +) + +func (t *Telegram) GetChannelDiff(ctx context.Context, channel domain.Channel, limit int) (domain.ChannelDiff, error) { + req := &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ + ChannelID: channel.ChannelID(), + AccessHash: channel.AccessHash, + }, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: channel.Pts, + Limit: limit, + } + + rawDiff, err := t.API().UpdatesGetChannelDifference(ctx, req) + if err != nil { + return domain.ChannelDiff{}, fmt.Errorf("get difference: %w", err) + } + + result := domain.ChannelDiff{} + + switch d := rawDiff.(type) { + case *tg.UpdatesChannelDifferenceEmpty: + result.NewPts = d.Pts + + case *tg.UpdatesChannelDifferenceTooLong: + if dialog, ok := d.Dialog.(*tg.Dialog); ok { + result.NewPts = dialog.Pts + } + result.NewPosts = extractPosts(channel, d.Messages) + result.UpdatedChannel = extractChannelMeta(channel, d.Chats) + + case *tg.UpdatesChannelDifference: + result.NewPts = d.Pts + result.NewPosts = extractPosts(channel, d.NewMessages) + result.DeletedPosts = extractDeletedPosts(channel, d.OtherUpdates) + result.UpdatedChannel = extractChannelMeta(channel, d.Chats) + + default: + return domain.ChannelDiff{}, fmt.Errorf("unexpected rawDiff type: %T", rawDiff) + } + + return result, nil +} + +func extractPosts(channel domain.Channel, msgs []tg.MessageClass) []domain.Post { + posts := make([]domain.Post, 0, len(msgs)) + + for _, raw := range msgs { + m, ok := raw.(*tg.Message) + if !ok { + continue + } + + text := messageToHTML(m.Message, m.Entities) + publishedAt := time.Unix(int64(m.Date), 0).UTC() + p := domain.NewPost(channel, m.ID, text, m.Views, publishedAt) + posts = append(posts, p) + } + + return posts +} + +func extractDeletedPosts(channel domain.Channel, updates []tg.UpdateClass) []domain.Post { + var deleted []domain.Post + + for _, upd := range updates { + if u, ok := upd.(*tg.UpdateDeleteChannelMessages); ok { + for _, msgID := range u.Messages { + p := domain.NewPost(channel, msgID, "", 0, time.Time{}) + deleted = append(deleted, p) + } + } + } + + return deleted +} + +func extractChannelMeta(currentChannel domain.Channel, chats []tg.ChatClass) *domain.Channel { + for _, chat := range chats { + ch, ok := chat.(*tg.Channel) + if !ok { + continue + } + + if ch.ID != currentChannel.ChannelID() { + continue + } + + // Preserve username if Telegram doesn't provide a new one (private channels may not have username) + username := currentChannel.Username + if usernameVal, ok := ch.GetUsername(); ok && usernameVal != "" { + username = usernameVal + } + + // Keep current access hash if new one is not provided or is zero + accessHash := currentChannel.AccessHash + if accessHashVal, ok := ch.GetAccessHash(); ok && accessHashVal != 0 { + accessHash = accessHashVal + } + + updated := domain.Channel{ + ID: currentChannel.ID, + TelegramID: domain.ChatIDFromChannelID(ch.ID), + Username: username, + Title: ch.Title, + AccessHash: accessHash, + InviteLink: currentChannel.InviteLink, // Preserve invite link (never returned in channel diff) + Pts: currentChannel.Pts, + IsAccessible: currentChannel.IsAccessible, + } + + return &updated + } + + return nil +} diff --git a/tg_parser/internal/adapter/telegram/history.go b/tg_parser/internal/adapter/telegram/history.go new file mode 100644 index 0000000..28e5d75 --- /dev/null +++ b/tg_parser/internal/adapter/telegram/history.go @@ -0,0 +1,49 @@ +package telegram + +import ( + "context" + "fmt" + "time" + + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" + "github.com/gotd/td/tg" + "github.com/rs/zerolog/log" +) + +func (t *Telegram) GetChannelHistory(ctx context.Context, channel domain.Channel, limit int) ([]domain.Post, error) { + log.Info().Msg(channel.String()) + + req := &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerChannel{ + ChannelID: channel.ChannelID(), + AccessHash: channel.AccessHash, + }, + Limit: limit, + } + + resp, err := t.API().MessagesGetHistory(ctx, req) + if err != nil { + return nil, fmt.Errorf("resp: %w", err) + } + + messages, ok := resp.(*tg.MessagesChannelMessages) + if !ok { + return nil, nil + } + + posts := make([]domain.Post, 0, len(messages.Messages)) + + for _, raw := range messages.Messages { + m, ok := raw.(*tg.Message) + if !ok { + continue + } + + text := messageToHTML(m.Message, m.Entities) + publishedAt := time.Unix(int64(m.Date), 0).UTC() + p := domain.NewPost(channel, m.ID, text, m.Views, publishedAt) + posts = append(posts, p) + } + + return posts, nil +} diff --git a/tg_parser/internal/adapter/telegram/message_html.go b/tg_parser/internal/adapter/telegram/message_html.go new file mode 100644 index 0000000..3ddc4e5 --- /dev/null +++ b/tg_parser/internal/adapter/telegram/message_html.go @@ -0,0 +1,197 @@ +package telegram + +import ( + "html" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "github.com/gotd/td/tg" +) + +type htmlEntity struct { + start int + end int + openTag string + closeTag string + length int +} + +type htmlEvent struct { + pos int + tag string + isStart bool + length int +} + +func messageToHTML(text string, entities []tg.MessageEntityClass) string { + if text == "" { + return "" + } + + if len(entities) == 0 { + return html.EscapeString(text) + } + + ranges := make([]htmlEntity, 0, len(entities)) + needed := make([]int, 0, len(entities)*2) + + for _, raw := range entities { + offset, length, openTag, closeTag, ok := htmlEntityMeta(raw) + if !ok { + continue + } + + start := offset + end := offset + length + if length <= 0 { + continue + } + + ranges = append(ranges, htmlEntity{ + start: start, + end: end, + openTag: openTag, + closeTag: closeTag, + length: length, + }) + needed = append(needed, start, end) + } + + if len(ranges) == 0 { + return html.EscapeString(text) + } + + positions := utf16PositionsToBytes(text, needed) + events := make([]htmlEvent, 0, len(ranges)*2) + + for _, r := range ranges { + startByte, okStart := positions[r.start] + endByte, okEnd := positions[r.end] + if !okStart || !okEnd || startByte > endByte { + continue + } + + events = append(events, htmlEvent{ + pos: startByte, + tag: r.openTag, + isStart: true, + length: r.length, + }) + events = append(events, htmlEvent{ + pos: endByte, + tag: r.closeTag, + isStart: false, + length: r.length, + }) + } + + sort.SliceStable(events, func(i, j int) bool { + if events[i].pos != events[j].pos { + return events[i].pos < events[j].pos + } + if events[i].isStart != events[j].isStart { + return !events[i].isStart + } + if events[i].isStart { + return events[i].length > events[j].length + } + return events[i].length < events[j].length + }) + + var b strings.Builder + last := 0 + for _, ev := range events { + if ev.pos > last { + b.WriteString(html.EscapeString(text[last:ev.pos])) + } + b.WriteString(ev.tag) + last = ev.pos + } + if last < len(text) { + b.WriteString(html.EscapeString(text[last:])) + } + + return b.String() +} + +func htmlEntityMeta(entity tg.MessageEntityClass) (offset int, length int, openTag string, closeTag string, ok bool) { + switch e := entity.(type) { + case *tg.MessageEntityBold: + return e.Offset, e.Length, "", "", true + case *tg.MessageEntityItalic: + return e.Offset, e.Length, "", "", true + case *tg.MessageEntityUnderline: + return e.Offset, e.Length, "", "", true + case *tg.MessageEntityStrike: + return e.Offset, e.Length, "", "", true + case *tg.MessageEntityCode: + return e.Offset, e.Length, "", "", true + case *tg.MessageEntityPre: + if e.Language != "" { + lang := html.EscapeString(e.Language) + return e.Offset, e.Length, `
`, "
", true + } + return e.Offset, e.Length, "
", "
", true + case *tg.MessageEntityTextURL: + url := html.EscapeString(e.URL) + return e.Offset, e.Length, ``, "", true + case *tg.MessageEntityMentionName: + userID := html.EscapeString(strconv.FormatInt(e.UserID, 10)) + return e.Offset, e.Length, ``, "", true + case *tg.MessageEntitySpoiler: + return e.Offset, e.Length, ``, "", true + case *tg.MessageEntityBlockquote: + if e.Collapsed { + return e.Offset, e.Length, `
`, "
", true + } + return e.Offset, e.Length, "
", "
", true + case *tg.MessageEntityCustomEmoji: + id := html.EscapeString(strconv.FormatInt(e.DocumentID, 10)) + return e.Offset, e.Length, ``, "", true + default: + return 0, 0, "", "", false + } +} + +func utf16PositionsToBytes(text string, needed []int) map[int]int { + result := make(map[int]int, len(needed)) + needSet := make(map[int]struct{}, len(needed)) + for _, n := range needed { + needSet[n] = struct{}{} + } + + utf16Pos := 0 + if _, ok := needSet[0]; ok { + result[0] = 0 + } + + for i, r := range text { + if _, ok := needSet[utf16Pos]; ok { + result[utf16Pos] = i + } + + step := utf16RuneLen(r) + if step == 2 { + if _, ok := needSet[utf16Pos+1]; ok { + result[utf16Pos+1] = i + } + } + utf16Pos += step + } + + if _, ok := needSet[utf16Pos]; ok { + result[utf16Pos] = len(text) + } + + return result +} + +func utf16RuneLen(r rune) int { + const surrSelf = 0x10000 + if r >= surrSelf && r <= utf8.MaxRune { + return 2 + } + return 1 +} diff --git a/tg_parser/internal/adapter/telegram/pts.go b/tg_parser/internal/adapter/telegram/pts.go new file mode 100644 index 0000000..cb9d91c --- /dev/null +++ b/tg_parser/internal/adapter/telegram/pts.go @@ -0,0 +1,33 @@ +package telegram + +import ( + "context" + "fmt" + + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" + "github.com/gotd/td/tg" +) + +func (t *Telegram) GetChannelPTS(ctx context.Context, channel domain.Channel) (int, error) { + req := []tg.InputDialogPeerClass{ + &tg.InputDialogPeer{ + Peer: &tg.InputPeerChannel{ + ChannelID: channel.ChannelID(), + AccessHash: channel.AccessHash, + }, + }, + } + + dialogs, err := t.API().MessagesGetPeerDialogs(ctx, req) + if err != nil { + return 0, fmt.Errorf("peer dialogs: %w", err) + } + + if len(dialogs.Dialogs) > 0 { + if d, ok := dialogs.Dialogs[0].(*tg.Dialog); ok { + return d.Pts, nil + } + } + + return 0, nil +} diff --git a/tg_parser/internal/adapter/telegram/resolve.go b/tg_parser/internal/adapter/telegram/resolve.go new file mode 100644 index 0000000..a637eec --- /dev/null +++ b/tg_parser/internal/adapter/telegram/resolve.go @@ -0,0 +1,58 @@ +package telegram + +import ( + "context" + "fmt" + + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" + "github.com/gotd/td/tg" +) + +func (t *Telegram) ParseChannelMeta(ctx context.Context, username string) (domain.Channel, error) { + resolved, err := t.API().ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{ + Username: username, + }) + if err != nil { + return domain.Channel{}, fmt.Errorf("resolve username: %w", err) + } + + if len(resolved.Chats) == 0 { + return domain.Channel{}, fmt.Errorf("no chats in resolve result") + } + + ch, ok := resolved.Chats[0].(*tg.Channel) + if !ok { + return domain.Channel{}, fmt.Errorf("not a channel") + } + + req := []tg.InputDialogPeerClass{ + &tg.InputDialogPeer{ + Peer: &tg.InputPeerChannel{ + ChannelID: ch.ID, + AccessHash: ch.AccessHash, + }, + }, + } + + dialogs, err := t.API().MessagesGetPeerDialogs(ctx, req) + if err != nil { + return domain.Channel{}, fmt.Errorf("peer dialogs: %w", err) + } + + pts := 0 + if len(dialogs.Dialogs) > 0 { + d, ok := dialogs.Dialogs[0].(*tg.Dialog) + if ok { + pts = d.Pts + } + } + + return domain.Channel{ + TelegramID: domain.ChatIDFromChannelID(ch.ID), + Username: username, + Title: ch.Title, + AccessHash: ch.AccessHash, + Pts: pts, + IsAccessible: true, + }, nil +} diff --git a/tg_parser/internal/adapter/telegram/resolve_invite.go b/tg_parser/internal/adapter/telegram/resolve_invite.go new file mode 100644 index 0000000..406627d --- /dev/null +++ b/tg_parser/internal/adapter/telegram/resolve_invite.go @@ -0,0 +1,133 @@ +package telegram + +import ( + "context" + "fmt" + + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" + "github.com/gotd/td/telegram/deeplink" + "github.com/gotd/td/tg" +) + +func (t *Telegram) ParseChannelMetaByInvite(ctx context.Context, inviteLink string) (domain.Channel, error) { + link, err := deeplink.Parse(inviteLink) + if err != nil { + return domain.Channel{}, fmt.Errorf("parse invite link: %w", err) + } + if link.Type != deeplink.Join { + return domain.Channel{}, fmt.Errorf("invite link is not a join link") + } + hash := link.Args.Get("invite") + if hash == "" { + return domain.Channel{}, fmt.Errorf("invite link missing hash") + } + + info, err := t.API().MessagesCheckChatInvite(ctx, hash) + if err != nil { + return domain.Channel{}, fmt.Errorf("check invite: %w", err) + } + + var channel *tg.Channel + + switch v := info.(type) { + case *tg.ChatInviteAlready: + channel = extractChannelFromChat(v.Chat) + case *tg.ChatInvite: + updates, err := t.API().MessagesImportChatInvite(ctx, hash) + if err != nil { + return domain.Channel{}, fmt.Errorf("import invite: %w", err) + } + channel = extractChannelFromUpdates(updates) + case *tg.ChatInvitePeek: + // ChatInvitePeek means we can preview the channel without joining (public channels) + channel = extractChannelFromChat(v.Chat) + default: + return domain.Channel{}, fmt.Errorf("unexpected invite response: %T", v) + } + + if channel == nil { + return domain.Channel{}, fmt.Errorf("no channel in invite response") + } + if channel.AccessHash == 0 { + return domain.Channel{}, fmt.Errorf("channel access hash missing") + } + + pts, err := getChannelPTS(ctx, t.API(), channel) + if err != nil { + return domain.Channel{}, fmt.Errorf("get peer dialogs: %w", err) + } + + username := "" + if usernameVal, ok := channel.GetUsername(); ok && usernameVal != "" { + username = usernameVal + } + + return domain.Channel{ + TelegramID: domain.ChatIDFromChannelID(channel.ID), + Username: username, + Title: channel.Title, + AccessHash: channel.AccessHash, + Pts: pts, + InviteLink: inviteLink, + IsAccessible: true, + }, nil +} + +func extractChannelFromChat(chat tg.ChatClass) *tg.Channel { + switch v := chat.(type) { + case *tg.Channel: + return v + case *tg.ChannelForbidden: + return &tg.Channel{ + ID: v.ID, + AccessHash: v.AccessHash, + Title: v.Title, + } + default: + return nil + } +} + +func extractChannelFromUpdates(updates tg.UpdatesClass) *tg.Channel { + var chats []tg.ChatClass + switch v := updates.(type) { + case *tg.Updates: + chats = v.Chats + case *tg.UpdatesCombined: + chats = v.Chats + default: + return nil + } + + for _, chat := range chats { + if channel := extractChannelFromChat(chat); channel != nil { + return channel + } + } + + return nil +} + +func getChannelPTS(ctx context.Context, api *tg.Client, channel *tg.Channel) (int, error) { + req := []tg.InputDialogPeerClass{ + &tg.InputDialogPeer{ + Peer: &tg.InputPeerChannel{ + ChannelID: channel.ID, + AccessHash: channel.AccessHash, + }, + }, + } + + dialogs, err := api.MessagesGetPeerDialogs(ctx, req) + if err != nil { + return 0, err + } + + if len(dialogs.Dialogs) > 0 { + if d, ok := dialogs.Dialogs[0].(*tg.Dialog); ok { + return d.Pts, nil + } + } + + return 0, nil +} diff --git a/tg_parser/internal/adapter/telegram/telegram.go b/tg_parser/internal/adapter/telegram/telegram.go new file mode 100644 index 0000000..409497f --- /dev/null +++ b/tg_parser/internal/adapter/telegram/telegram.go @@ -0,0 +1,20 @@ +package telegram + +import ( + "github.com/TelegramExchange/pkg/telegram" + "github.com/gotd/td/tg" +) + +type Telegram struct { + client *telegram.Client +} + +func New(client *telegram.Client) *Telegram { + return &Telegram{ + client: client, + } +} + +func (t *Telegram) API() *tg.Client { + return t.client.API() +} diff --git a/tg_parser/internal/app/app.go b/tg_parser/internal/app/app.go new file mode 100644 index 0000000..9e77243 --- /dev/null +++ b/tg_parser/internal/app/app.go @@ -0,0 +1,91 @@ +package app + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/rs/zerolog/log" + + "github.com/TelegramExchange/pkg/postgres" + "github.com/TelegramExchange/pkg/telegram" + "github.com/TelegramExchange/pkg/transaction" + "github.com/TelegramExchange/tgex-backend/tg_parser/config" + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/adapter/database" + tgadapter "github.com/TelegramExchange/tgex-backend/tg_parser/internal/adapter/telegram" + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/controller/httpserver" + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/controller/worker" + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/usecase" +) + +func Run(ctx context.Context, c config.Config) error { + // Telegram client + tgClient, err := telegram.New(c.Telegram) + if err != nil { + return fmt.Errorf("telegram.New: %w", err) + } + + // PostgreSQL + pgPool, err := postgres.New(ctx, c.Postgres) + if err != nil { + return fmt.Errorf("broker.New: %w", err) + } + defer pgPool.Close() + + transaction.Init(pgPool) + + // Adapters + tg := tgadapter.New(tgClient) + db := database.New() + + // UseCase + uc := usecase.New(tg, db) + + // Controllers + channelWorker := worker.NewChannelWorker(uc, c.ChannelWorker) + viewsWorker := worker.NewViewsWorker(uc, c.ViewsWorker) + + httpServer := httpserver.New(uc, c.HTTP.Addr) + serverErr := make(chan error, 1) + go func() { + err := httpServer.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + serverErr <- err + } + }() + + log.Info().Str("addr", c.HTTP.Addr).Msg("HTTP server started") + log.Info().Msg("App started") + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + select { + case <-sig: + case err := <-serverErr: + return fmt.Errorf("http server: %w", err) + } + + log.Info().Msg("App got signal to stop") + + // Controllers + viewsWorker.Stop() + channelWorker.Stop() + + ctxShutdown, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := httpServer.Shutdown(ctxShutdown); err != nil { + log.Error().Err(err).Msg("HTTP server shutdown failed") + } + + // Adapters + tgClient.Close() + + log.Info().Msg("App stopped") + + return nil +} diff --git a/tg_parser/internal/controller/httpserver/server.go b/tg_parser/internal/controller/httpserver/server.go new file mode 100644 index 0000000..61e73dc --- /dev/null +++ b/tg_parser/internal/controller/httpserver/server.go @@ -0,0 +1,152 @@ +package httpserver + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/usecase" + "github.com/gotd/td/tgerr" + "github.com/rs/zerolog/log" +) + +type Server struct { + httpServer *http.Server +} + +func New(uc *usecase.UseCase, addr string) *Server { + mux := http.NewServeMux() + mux.HandleFunc("/fetch-telegram-channel", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + username := strings.TrimSpace(r.URL.Query().Get("username")) + username = strings.TrimPrefix(username, "@") + if username == "" { + http.Error(w, "missing username", http.StatusBadRequest) + return + } + + channel, err := uc.FetchChannelMeta(r.Context(), username) + if err != nil { + if isNotFoundError(err) { + http.Error(w, "channel not found", http.StatusNotFound) + return + } + log.Error().Err(err).Str("username", username).Msg("fetch channel meta failed") + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + resp := struct { + ID string `json:"id"` + TelegramID int64 `json:"telegram_id"` + Username string `json:"username"` + Title string `json:"title"` + AccessHash int64 `json:"access_hash"` + Pts int `json:"pts"` + }{ + ID: channel.ID.String(), + TelegramID: channel.TelegramID, + Username: channel.Username, + Title: channel.Title, + AccessHash: channel.AccessHash, + Pts: channel.Pts, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Error().Err(err).Msg("encode channel response") + } + }) + mux.HandleFunc("/resolve-channel-by-invite", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var payload struct { + InviteLink string `json:"invite_link"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + + inviteLink := strings.TrimSpace(payload.InviteLink) + if inviteLink == "" { + http.Error(w, "missing invite_link", http.StatusBadRequest) + return + } + + channel, err := uc.FetchChannelMetaByInvite(r.Context(), inviteLink) + if err != nil { + if isNotFoundError(err) { + http.Error(w, "channel not found", http.StatusNotFound) + return + } + log.Error().Err(err).Msg("resolve channel by invite failed") + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + resp := struct { + ID string `json:"id"` + TelegramID int64 `json:"telegram_id"` + Username string `json:"username"` + Title string `json:"title"` + AccessHash int64 `json:"access_hash"` + Pts int `json:"pts"` + }{ + ID: channel.ID.String(), + TelegramID: channel.TelegramID, + Username: channel.Username, + Title: channel.Title, + AccessHash: channel.AccessHash, + Pts: channel.Pts, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Error().Err(err).Msg("encode channel response") + } + }) + + return &Server{ + httpServer: &http.Server{ + Addr: addr, + Handler: mux, + }, + } +} + +func (s *Server) ListenAndServe() error { + return s.httpServer.ListenAndServe() +} + +func (s *Server) Shutdown(ctx context.Context) error { + return s.httpServer.Shutdown(ctx) +} + +func isNotFoundError(err error) bool { + if tgerr.Is( + err, + "USERNAME_NOT_OCCUPIED", + "USERNAME_INVALID", + "CHANNEL_INVALID", + "CHANNEL_PRIVATE", + "INVITE_HASH_INVALID", + "INVITE_HASH_EXPIRED", + "INVITE_HASH_EMPTY", + ) { + return true + } + + msg := err.Error() + return strings.Contains(msg, "no chats in resolve result") || + strings.Contains(msg, "not a channel") || + strings.Contains(msg, "invite link") +} diff --git a/tg_parser/internal/controller/worker/channel_worker.go b/tg_parser/internal/controller/worker/channel_worker.go new file mode 100644 index 0000000..663ca5f --- /dev/null +++ b/tg_parser/internal/controller/worker/channel_worker.go @@ -0,0 +1,85 @@ +package worker + +import ( + "context" + "sync" + "time" + + "github.com/rs/zerolog/log" + + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/usecase" +) + +type ChannelConfig struct { + MaxWorkers int `envconfig:"WORKER__MAX_WORKERS" default:"1"` + ChannelDelay time.Duration `envconfig:"WORKER__CHANNEL_DELAY" default:"500ms"` + RequestTimeout time.Duration `envconfig:"WORKER__REQUEST_TIMEOUT" default:"10s"` + MessagesLimit int `envconfig:"WORKER__MESSAGES_LIMIT" default:"20"` + PollInterval time.Duration `envconfig:"WORKER__POLL_INTERVAL" default:"120s"` +} + +type ChannelWorker struct { + usecase *usecase.UseCase + config ChannelConfig + stop chan struct{} + done chan struct{} +} + +func NewChannelWorker(uc *usecase.UseCase, cfg ChannelConfig) *ChannelWorker { + w := &ChannelWorker{ + usecase: uc, + config: cfg, + stop: make(chan struct{}), + done: make(chan struct{}), + } + + go w.run() + + return w +} + +func (w *ChannelWorker) run() { + log.Info().Msg("channel worker: started") + + var wg sync.WaitGroup + + wg.Add(w.config.MaxWorkers) + + for range w.config.MaxWorkers { + go w.spawnWorker(&wg) + } + + wg.Wait() + + log.Info().Msg("channel worker: stopped") + + close(w.done) +} + +func (w *ChannelWorker) spawnWorker(wg *sync.WaitGroup) { + defer wg.Done() + + limiter := time.NewTicker(w.config.ChannelDelay) + defer limiter.Stop() + + poll := time.NewTicker(w.config.PollInterval) + defer poll.Stop() + + for { + select { + case <-w.stop: + return + + case <-poll.C: + err := w.usecase.FetchChannels(context.Background()) + if err != nil { + log.Error().Err(err).Msg("usecase.FetchChannels error") + } + } + } +} + +func (w *ChannelWorker) Stop() { + close(w.stop) + <-w.done +} diff --git a/tg_parser/internal/controller/worker/views_worker.go b/tg_parser/internal/controller/worker/views_worker.go new file mode 100644 index 0000000..a8eca60 --- /dev/null +++ b/tg_parser/internal/controller/worker/views_worker.go @@ -0,0 +1,65 @@ +package worker + +import ( + "context" + "time" + + "github.com/rs/zerolog/log" + + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/usecase" +) + +type ViewsConfig struct { + Interval time.Duration `envconfig:"WORKER__VIEWS_INTERVAL" default:"1800s"` + RequestTimeout time.Duration `envconfig:"WORKER__REQUEST_TIMEOUT" default:"10s"` +} + +type ViewsWorker struct { + usecase *usecase.UseCase + config ViewsConfig + stop chan struct{} + done chan struct{} +} + +func NewViewsWorker(uc *usecase.UseCase, cfg ViewsConfig) *ViewsWorker { + w := &ViewsWorker{ + usecase: uc, + config: cfg, + stop: make(chan struct{}), + done: make(chan struct{}), + } + + go w.run() + + return w +} + +func (w *ViewsWorker) run() { + log.Info().Msg("views worker: started") + + for { + select { + case <-w.stop: + log.Info().Msg("views worker: stopped") + close(w.done) + return + case <-time.After(w.config.Interval): + log.Debug().Dur("interval", w.config.Interval).Msg("views worker: refresh tick") + ctx, cancel := context.WithTimeout(context.Background(), w.config.RequestTimeout) + + err := w.usecase.FetchViews(ctx) + if err != nil { + log.Error().Err(err).Msg("views worker: FetchViews error") + } else { + log.Debug().Msg("views worker: refresh finished") + } + + cancel() + } + } +} + +func (w *ViewsWorker) Stop() { + close(w.stop) + <-w.done +} diff --git a/tg_parser/internal/domain/channel.go b/tg_parser/internal/domain/channel.go new file mode 100644 index 0000000..bc092ec --- /dev/null +++ b/tg_parser/internal/domain/channel.go @@ -0,0 +1,68 @@ +package domain + +import ( + "fmt" + + "github.com/google/uuid" +) + +type Channel struct { + ID uuid.UUID + TelegramID int64 + Username string + Title string + AccessHash int64 + Pts int + InviteLink string + IsAccessible bool +} + +const channelIDOffset int64 = 1000000000000 + +// ChannelID returns the positive channel identifier expected by Telegram API. +func (c Channel) ChannelID() int64 { + return ChannelIDFromChatID(c.TelegramID) +} + +// ChannelIDFromChatID converts stored chat IDs (Bot API style) to channel IDs used by TDLib. +func ChannelIDFromChatID(chatID int64) int64 { + if chatID >= 0 { + return chatID + } + + return -chatID - channelIDOffset +} + +// ChatIDFromChannelID converts Telegram channel IDs to Bot API style chat IDs (-100...). +func ChatIDFromChannelID(channelID int64) int64 { + return -channelIDOffset - channelID +} + +// NormalizeChatID ensures that TelegramID is stored in chat-id form (negative). +func NormalizeChatID(id int64) int64 { + if id < 0 { + return id + } + + return ChatIDFromChannelID(id) +} + +func (c Channel) String() string { + return fmt.Sprintf( + "Channel{id=%s telegram_id=%d username=%q title=%q access_hash=%d pts=%d invite_link=%t}", + c.ID, + c.TelegramID, + c.Username, + c.Title, + c.AccessHash, + c.Pts, + c.InviteLink != "", + ) +} + +type ChannelDiff struct { + NewPts int + NewPosts []Post + DeletedPosts []Post + UpdatedChannel *Channel +} diff --git a/tg_parser/internal/domain/post.go b/tg_parser/internal/domain/post.go new file mode 100644 index 0000000..7f5d452 --- /dev/null +++ b/tg_parser/internal/domain/post.go @@ -0,0 +1,36 @@ +package domain + +import ( + "fmt" + "time" + + "github.com/google/uuid" +) + +type Post struct { + ID uuid.UUID + ChannelID uuid.UUID + MessageID int + Text string + Link string + Views int + PublishedAt time.Time +} + +func NewPost(channel Channel, messageID int, text string, views int, publishedAt time.Time) Post { + link := "" + if channel.Username != "" { + link = fmt.Sprintf("https://t.me/%s/%d", channel.Username, messageID) + } else if channel.TelegramID != 0 { + link = fmt.Sprintf("https://t.me/c/%d/%d", ChannelIDFromChatID(channel.TelegramID), messageID) + } + return Post{ + ID: uuid.New(), + ChannelID: channel.ID, + MessageID: messageID, + Text: text, + Link: link, + Views: views, + PublishedAt: publishedAt, + } +} diff --git a/tg_parser/internal/domain/views_snapshot.go b/tg_parser/internal/domain/views_snapshot.go new file mode 100644 index 0000000..a265e9a --- /dev/null +++ b/tg_parser/internal/domain/views_snapshot.go @@ -0,0 +1,14 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +type ViewsSnapshot struct { + ViewsCount int + FetchedAt time.Time + + PostID uuid.UUID +} diff --git a/tg_parser/internal/usecase/fetch_channel_meta.go b/tg_parser/internal/usecase/fetch_channel_meta.go new file mode 100644 index 0000000..cdf07de --- /dev/null +++ b/tg_parser/internal/usecase/fetch_channel_meta.go @@ -0,0 +1,22 @@ +package usecase + +import ( + "context" + "fmt" + + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" +) + +func (uc *UseCase) FetchChannelMeta(ctx context.Context, username string) (domain.Channel, error) { + channel, err := uc.telegram.ParseChannelMeta(ctx, username) + if err != nil { + return domain.Channel{}, fmt.Errorf("uc.telegram.ParseChannelMeta: %s", err) + } + + err = uc.database.UpdateChannelIfNotAccessible(ctx, channel) + if err != nil { + return domain.Channel{}, fmt.Errorf("uc.database.UpdateChannelIfNotAccessible: %s", err) + } + + return channel, nil +} diff --git a/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go b/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go new file mode 100644 index 0000000..12bdfdd --- /dev/null +++ b/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go @@ -0,0 +1,22 @@ +package usecase + +import ( + "context" + "fmt" + + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" +) + +func (uc *UseCase) FetchChannelMetaByInvite(ctx context.Context, inviteLink string) (domain.Channel, error) { + channel, err := uc.telegram.ParseChannelMetaByInvite(ctx, inviteLink) + if err != nil { + return domain.Channel{}, fmt.Errorf("uc.telegram.ParseChannelMetaByInvite: %s", err) + } + + err = uc.database.UpdateChannelIfNotAccessible(ctx, channel) + if err != nil { + return domain.Channel{}, fmt.Errorf("uc.database.UpdateChannelIfNotAccessible: %s", err) + } + + return channel, nil +} diff --git a/tg_parser/internal/usecase/fetch_channels.go b/tg_parser/internal/usecase/fetch_channels.go new file mode 100644 index 0000000..a4d9a11 --- /dev/null +++ b/tg_parser/internal/usecase/fetch_channels.go @@ -0,0 +1,177 @@ +package usecase + +import ( + "context" + "errors" + "fmt" + + "github.com/TelegramExchange/pkg/transaction" + "github.com/gotd/td/tgerr" + "github.com/rs/zerolog/log" + + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" +) + +func (uc *UseCase) FetchChannels(ctx context.Context) error { + channels := uc.database.GetChannels(ctx) + log.Debug().Msg("start fetch channels") + + for _, c := range channels { + if c.Pts == 0 { + go uc.initializeChannel(ctx, c) + continue + } + + err := uc.processChannel(ctx, c) + if err != nil { + if isPrivateChannelError(err) { + log.Warn().Int64("telegram_id", c.TelegramID).Msg("Private channel, attempting rejoin") + go uc.rejoinChannel(ctx, c) + + return nil + } + return fmt.Errorf("uc.processChannel: %w", err) + } + } + + return nil +} + +func isPrivateChannelError(err error) bool { + return tgerr.Is(err, "CHANNEL_PRIVATE", "CHANNEL_INVALID", "CHANNEL_FORBIDDEN") +} + +func (uc *UseCase) processChannel(ctx context.Context, channel domain.Channel) error { + diff, err := uc.telegram.GetChannelDiff(ctx, channel, 20) + if err != nil { + return fmt.Errorf("telegram.GetChannelDiff: %w", err) + } + + for _, p := range diff.NewPosts { + log.Info().Msgf("New post: %s - Views: %d, Text: %.10s...", p.Link, p.Views, p.Text) + + err = uc.database.CreatePost(ctx, p) + if err != nil { + return fmt.Errorf("database.CreatePost: %w", err) + } + } + + for _, p := range diff.DeletedPosts { + log.Info().Msgf("Deleted post: %s - Views: %d", p.Link, p.Views) + + err = uc.database.DeletePost(ctx, p) + if err != nil { + return fmt.Errorf("database.DeletePost: %w", err) + } + } + + // Update channel metadata if changed + if diff.UpdatedChannel != nil { + diff.UpdatedChannel.Pts = diff.NewPts + err = uc.database.UpdateChannel(ctx, *diff.UpdatedChannel) + if err != nil { + return fmt.Errorf("database.UpdateChannel: %w", err) + } + + return nil + } + + channel.Pts = diff.NewPts + err = uc.database.UpdateChannel(ctx, channel) + if err != nil { + return fmt.Errorf("database.UpdateChannel: %w", err) + } + + return nil +} + +func (uc *UseCase) rejoinChannel(ctx context.Context, channel domain.Channel) { + defer func() { + err := uc.database.UpdateChannel(ctx, channel) + if err != nil { + log.Err(err).Msg("database.UpdateChannel (rejoinChannel)") + } + }() + + if channel.InviteLink == "" { + log.Warn().Int64("telegram_id", channel.TelegramID).Msg("No invite link stored, marking inaccessible") + channel.IsAccessible = false + + return + } + + updated, err := uc.telegram.ParseChannelMetaByInvite(ctx, channel.InviteLink) + if err != nil { + log.Warn().Err(err).Int64("telegram_id", channel.TelegramID).Msg("Invite rejoin failed, marking inaccessible") + channel.IsAccessible = false + + return + } + + channel.TelegramID = updated.TelegramID + channel.Title = updated.Title + channel.AccessHash = 0 + channel.Pts = 0 + channel.IsAccessible = true +} + +func (uc *UseCase) initializeChannel(ctx context.Context, channel domain.Channel) { + log.Debug().Stringer("ch", channel).Msg("init channel") + var ( + updated domain.Channel + err error + ) + + switch { + case channel.Username != "": + updated, err = uc.telegram.ParseChannelMeta(ctx, channel.Username) + case channel.InviteLink != "": + updated, err = uc.telegram.ParseChannelMetaByInvite(ctx, channel.InviteLink) + default: + err = errors.New("channel state invalid") + } + if err != nil { + if isPrivateChannelError(err) { + channel.IsAccessible = false + + err = uc.database.UpdateChannel(ctx, channel) + if err != nil { + log.Err(err).Msg("initializeChannel.uc.database.UpdateChannel") + } + } + + log.Error().Err(err).Msg("initializeChannel.ParseChannel") + return + } + + channel.Title = updated.Title + channel.AccessHash = updated.AccessHash + channel.Pts = updated.Pts + + const initPosts = 20 + posts, err := uc.telegram.GetChannelHistory(ctx, channel, initPosts) + if err != nil { + log.Error().Err(err).Msg("telegram.GetChannelHistory") + return + } + + err = transaction.Wrap(ctx, func(ctx context.Context) error { + err = uc.database.UpdateChannel(ctx, channel) + if err != nil { + return fmt.Errorf("database.UpdateChannel: %w", err) + } + + for _, p := range posts { + log.Info().Msgf("New post (init): %s - Views: %d", p.Link, p.Views) + + err = uc.database.CreatePost(ctx, p) + if err != nil { + return fmt.Errorf("database.CreatePost: %w", err) + } + } + return nil + }) + if err != nil { + log.Error().Err(err).Msg("transaction.Wrap") + } +} diff --git a/tg_parser/internal/usecase/fetch_views.go b/tg_parser/internal/usecase/fetch_views.go new file mode 100644 index 0000000..338cadd --- /dev/null +++ b/tg_parser/internal/usecase/fetch_views.go @@ -0,0 +1,54 @@ +package usecase + +import ( + "context" + "time" + + "github.com/rs/zerolog/log" + + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" +) + +func (uc *UseCase) FetchViews(ctx context.Context) error { + channels, err := uc.database.GetChannelsWithTrackedPosts(ctx) + if err != nil { + return err + } + + for _, channel := range channels { + posts, err := uc.database.GetTrackedPosts(ctx, channel) + if err != nil { + return err + } + + err = uc.telegram.UpdatePostsViews(ctx, channel, posts) + if err != nil { + if isPrivateChannelError(err) { + channel.IsAccessible = false + err = uc.database.UpdateChannel(ctx, channel) + if err != nil { + log.Err(err).Msg("initializeChannel.uc.database.UpdateChannel") + } + return nil + } + return err + } + + for _, p := range posts { + v := domain.ViewsSnapshot{ + ViewsCount: p.Views, + FetchedAt: time.Now().UTC(), + PostID: p.ID, + } + + err = uc.database.CreateViewsSnapshot(ctx, v) + if err != nil { + return err + } + + log.Info().Msgf("New ViewsSnapshot: %s - Post ID: %d, Views: %d", p.Link, p.MessageID, p.Views) + } + } + + return nil +} diff --git a/tg_parser/internal/usecase/usecase.go b/tg_parser/internal/usecase/usecase.go new file mode 100644 index 0000000..2e50e75 --- /dev/null +++ b/tg_parser/internal/usecase/usecase.go @@ -0,0 +1,41 @@ +package usecase + +import ( + "context" + + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" +) + +type Telegram interface { + ParseChannelMeta(ctx context.Context, username string) (domain.Channel, error) + ParseChannelMetaByInvite(ctx context.Context, inviteLink string) (domain.Channel, error) + GetChannelPTS(ctx context.Context, channel domain.Channel) (int, error) + GetChannelHistory(ctx context.Context, channel domain.Channel, limit int) ([]domain.Post, error) + GetChannelDiff(ctx context.Context, channel domain.Channel, limit int) (domain.ChannelDiff, error) + UpdatePostsViews(ctx context.Context, channel domain.Channel, posts []domain.Post) error +} + +type Database interface { + CreatePost(ctx context.Context, post domain.Post) error + + CreateViewsSnapshot(ctx context.Context, snapshot domain.ViewsSnapshot) error + + UpdateChannelIfNotAccessible(ctx context.Context, channel domain.Channel) error + GetChannels(ctx context.Context) []domain.Channel + UpdateChannel(ctx context.Context, channel domain.Channel) error + GetChannelsWithTrackedPosts(ctx context.Context) ([]domain.Channel, error) + GetTrackedPosts(ctx context.Context, channel domain.Channel) ([]domain.Post, error) + DeletePost(ctx context.Context, p domain.Post) error +} + +type UseCase struct { + telegram Telegram + database Database +} + +func New(telegram Telegram, database Database) *UseCase { + return &UseCase{ + telegram: telegram, + database: database, + } +} diff --git a/tg_parser/main.go b/tg_parser/main.go new file mode 100644 index 0000000..316714b --- /dev/null +++ b/tg_parser/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "os" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "github.com/TelegramExchange/tgex-backend/tg_parser/config" + "github.com/TelegramExchange/tgex-backend/tg_parser/internal/app" +) + +func main() { + + c, err := config.New() + if err != nil { + log.Fatal().Err(err).Msg("config.New") + } + + initLogger(c.Logger) + log.Info().Msg("parser starting") + log.Info().Msg("parser initialized") + + ctx := context.Background() + + if err := app.Run(ctx, c); err != nil { + log.Error().Err(err).Msg("app.Run") + } +} + +func initLogger(c config.LoggerConfig) { + zerolog.TimeFieldFormat = time.RFC3339 + + level := zerolog.InfoLevel + if parsedLevel, err := zerolog.ParseLevel(c.Level); err == nil { + level = parsedLevel + } + zerolog.SetGlobalLevel(level) + + log.Logger = zerolog.New(os.Stdout).With(). + Timestamp(). + Logger(). + Level(level) + + if c.PrettyConsole { + log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: "15:04:05"}). + With(). + Timestamp(). + Logger(). + Level(level) + } + + log.Info().Msg("Logger initialized") +} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..e790d0b --- /dev/null +++ b/uv.lock @@ -0,0 +1,1294 @@ +version = 1 +revision = 2 +requires-python = ">=3.13" + +[[package]] +name = "aerich" +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "asyncclick" }, + { name = "dictdiffer" }, + { name = "tortoise-orm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/60/5d3885f531fab2cecec67510e7b821efc403940ed9eefd034b2c21350f3c/aerich-0.9.2.tar.gz", hash = "sha256:02d58658714eebe396fe7bd9f9401db3a60a44dc885910ad3990920d0357317d", size = 74231, upload_time = "2025-10-10T05:53:49.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/1a/956c6b1e35881bb9835a33c8db1565edcd133f8e45321010489092a0df40/aerich-0.9.2-py3-none-any.whl", hash = "sha256:d0f007acb21f6559f1eccd4e404fb039cf48af2689e0669afa62989389c0582d", size = 46451, upload_time = "2025-10-10T05:53:48.71Z" }, +] + +[[package]] +name = "aioboto3" +version = "15.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiobotocore", extra = ["boto3"] }, + { name = "aiofiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/01/92e9ab00f36e2899315f49eefcd5b4685fbb19016c7f19a9edf06da80bb0/aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979", size = 255069, upload_time = "2025-10-30T13:37:16.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/3e/e8f5b665bca646d43b916763c901e00a07e40f7746c9128bdc912a089424/aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6", size = 35913, upload_time = "2025-10-30T13:37:14.549Z" }, +] + +[[package]] +name = "aiobotocore" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "multidict" }, + { name = "python-dateutil" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/94/2e4ec48cf1abb89971cb2612d86f979a6240520f0a659b53a43116d344dc/aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc", size = 120560, upload_time = "2025-10-28T22:33:21.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/d275ec4ce5cd0096665043995a7d76f5d0524853c76a3d04656de49f8808/aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f", size = 86039, upload_time = "2025-10-28T22:33:19.949Z" }, +] + +[package.optional-dependencies] +boto3 = [ + { name = "boto3" }, +] + +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload_time = "2024-06-24T11:02:03.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload_time = "2024-06-24T11:02:01.529Z" }, +] + +[[package]] +name = "aiogram" +version = "3.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "certifi" }, + { name = "magic-filter" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/2c/fe0845a97f6126357d20163ede8f76bc161f73122123c6548ca19d9a12c7/aiogram-3.22.0.tar.gz", hash = "sha256:c483f81e37aeea8e7f592c9bd14f6acc80d9b7a2698e296a45bf47ff60a98510", size = 1520414, upload_time = "2025-08-17T16:20:45.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/e5/9f9fae7b50ed502e33121dd62a7e9b076d00630eaafe1dd7fda64f7e8625/aiogram-3.22.0-py3-none-any.whl", hash = "sha256:1c6eceb078ff62cf0556a5466cf3e7e8119678c26cc56803b7ac5f73633934a8", size = 698216, upload_time = "2025-08-17T16:20:43.354Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload_time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload_time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.12.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload_time = "2025-07-29T05:52:32.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload_time = "2025-07-29T05:51:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload_time = "2025-07-29T05:51:21.165Z" }, + { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload_time = "2025-07-29T05:51:22.948Z" }, + { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload_time = "2025-07-29T05:51:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload_time = "2025-07-29T05:51:27.145Z" }, + { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload_time = "2025-07-29T05:51:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload_time = "2025-07-29T05:51:31.285Z" }, + { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload_time = "2025-07-29T05:51:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload_time = "2025-07-29T05:51:35.195Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload_time = "2025-07-29T05:51:37.215Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload_time = "2025-07-29T05:51:39.328Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload_time = "2025-07-29T05:51:41.356Z" }, + { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload_time = "2025-07-29T05:51:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload_time = "2025-07-29T05:51:45.643Z" }, + { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload_time = "2025-07-29T05:51:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload_time = "2025-07-29T05:51:50.718Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload_time = "2025-07-29T05:51:52.549Z" }, +] + +[[package]] +name = "aioitertools" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload_time = "2025-11-06T22:17:07.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload_time = "2025-11-06T22:17:06.502Z" }, +] + +[[package]] +name = "aiolimiter" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/23/b52debf471f7a1e42e362d959a3982bdcb4fe13a5d46e63d28868807a79c/aiolimiter-1.2.1.tar.gz", hash = "sha256:e02a37ea1a855d9e832252a105420ad4d15011505512a1a1d814647451b5cca9", size = 7185, upload_time = "2024-12-08T15:31:51.496Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/ba/df6e8e1045aebc4778d19b8a3a9bc1808adb1619ba94ca354d9ba17d86c3/aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7", size = 6711, upload_time = "2024-12-08T15:31:49.874Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload_time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload_time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload_time = "2025-02-03T07:30:16.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload_time = "2025-02-03T07:30:13.6Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/a6/dc46877b911e40c00d395771ea710d5e77b6de7bacd5fdcd78d70cc5a48f/annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda", size = 5535, upload_time = "2025-10-24T14:57:10.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/b7/cf592cb5de5cb3bade3357f8d2cf42bf103bbe39f459824b4939fd212911/annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580", size = 5488, upload_time = "2025-10-24T14:57:09.462Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload_time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload_time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload_time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload_time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "asyncclick" +version = "8.3.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/ca/25e426d16bd0e91c1c9259112cecd17b2c2c239bdd8e5dba430f3bd5e3ef/asyncclick-8.3.0.7.tar.gz", hash = "sha256:8a80d8ac613098ee6a9a8f0248f60c66c273e22402cf3f115ed7f071acfc71d3", size = 277634, upload_time = "2025-10-11T08:35:44.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/d9/782ffcb4c97b889bc12d8276637d2739b99520390ee8fec77c07416c5d12/asyncclick-8.3.0.7-py3-none-any.whl", hash = "sha256:7607046de39a3f315867cad818849f973e29d350c10d92f251db3ff7600c6c7d", size = 109925, upload_time = "2025-10-11T08:35:43.378Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746, upload_time = "2024-10-20T00:30:41.127Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373, upload_time = "2024-10-20T00:29:55.165Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745, upload_time = "2024-10-20T00:29:57.14Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103, upload_time = "2024-10-20T00:29:58.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471, upload_time = "2024-10-20T00:30:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253, upload_time = "2024-10-20T00:30:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720, upload_time = "2024-10-20T00:30:04.501Z" }, + { url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404, upload_time = "2024-10-20T00:30:06.537Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623, upload_time = "2024-10-20T00:30:09.024Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload_time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload_time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload_time = "2025-09-29T10:05:42.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload_time = "2025-09-29T10:05:43.771Z" }, +] + +[[package]] +name = "boto3" +version = "1.40.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/f9/6ef8feb52c3cce5ec3967a535a6114b57ac7949fd166b0f3090c2b06e4e5/boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12", size = 111535, upload_time = "2025-10-28T19:26:57.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/24/3bf865b07d15fea85b63504856e137029b6acbc73762496064219cdb265d/boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c", size = 139321, upload_time = "2025-10-28T19:26:55.007Z" }, +] + +[[package]] +name = "botocore" +version = "1.40.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a3/81d3a47c2dbfd76f185d3b894f2ad01a75096c006a2dd91f237dca182188/botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd", size = 14393956, upload_time = "2025-10-28T19:26:46.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/c5/f6ce561004db45f0b847c2cd9b19c67c6bf348a82018a48cb718be6b58b0/botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7", size = 14055973, upload_time = "2025-10-28T19:26:42.15Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload_time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload_time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload_time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload_time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dictdiffer" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/7b/35cbccb7effc5d7e40f4c55e2b79399e1853041997fcda15c9ff160abba0/dictdiffer-0.9.0.tar.gz", hash = "sha256:17bacf5fbfe613ccf1b6d512bd766e6b21fb798822a133aa86098b8ac9997578", size = 31513, upload_time = "2021-07-22T13:24:29.276Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/ef/4cb333825d10317a36a1154341ba37e6e9c087bac99c1990ef07ffdb376f/dictdiffer-0.9.0-py2.py3-none-any.whl", hash = "sha256:442bfc693cfcadaf46674575d2eba1c53b42f5e404218ca2c2ff549f2df56595", size = 16754, upload_time = "2021-07-22T13:24:26.783Z" }, +] + +[[package]] +name = "fastapi" +version = "0.121.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/e3/77a2df0946703973b9905fd0cde6172c15e0781984320123b4f5079e7113/fastapi-0.121.0.tar.gz", hash = "sha256:06663356a0b1ee93e875bbf05a31fb22314f5bed455afaaad2b2dad7f26e98fa", size = 342412, upload_time = "2025-11-03T10:25:54.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/2c/42277afc1ba1a18f8358561eee40785d27becab8f80a1f945c0a3051c6eb/fastapi-0.121.0-py3-none-any.whl", hash = "sha256:8bdf1b15a55f4e4b0d6201033da9109ea15632cb76cf156e7b8b4019f2172106", size = 109183, upload_time = "2025-11-03T10:25:53.27Z" }, +] + +[[package]] +name = "fastapi-pagination" +version = "0.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/be/e5edfb47d0253b5dc019ad0430cc26b34f9b29a21456abfb8ea0cff40782/fastapi_pagination-0.15.3.tar.gz", hash = "sha256:0667c3e31eb0c47f15e2d4d0a971490beed9b65a1079158b5ad0115488a370e2", size = 571922, upload_time = "2025-12-11T21:53:43.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/99/11a13d3b2ff6fb716fbe8a1da8f15da332472f13bea84377e1d070cf3a6c/fastapi_pagination-0.15.3-py3-none-any.whl", hash = "sha256:6c0e8b3265270bfa46a580f7a3a24559b0825917625b5c4ed3a1cd60a173552f", size = 56231, upload_time = "2025-12-11T21:53:44.257Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload_time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload_time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload_time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload_time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload_time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload_time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload_time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload_time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload_time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload_time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload_time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload_time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload_time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload_time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload_time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload_time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload_time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload_time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload_time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload_time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload_time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload_time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload_time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload_time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload_time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload_time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload_time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload_time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload_time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload_time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload_time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload_time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload_time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload_time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload_time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload_time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload_time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload_time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload_time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload_time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload_time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload_time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload_time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload_time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload_time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload_time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload_time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload_time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload_time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload_time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload_time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload_time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload_time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload_time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload_time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload_time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload_time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload_time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload_time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload_time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload_time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload_time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload_time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload_time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload_time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload_time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload_time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload_time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload_time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload_time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload_time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload_time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload_time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload_time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload_time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload_time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "iso8601" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload_time = "2023-10-03T00:25:39.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload_time = "2023-10-03T00:25:32.304Z" }, +] + +[[package]] +name = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload_time = "2022-06-17T18:00:12.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload_time = "2022-06-17T18:00:10.251Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload_time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload_time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload_time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload_time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload_time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload_time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload_time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload_time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload_time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload_time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload_time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload_time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload_time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload_time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload_time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload_time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload_time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload_time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload_time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload_time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload_time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload_time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload_time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload_time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload_time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload_time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload_time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload_time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload_time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload_time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload_time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload_time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload_time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload_time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload_time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload_time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload_time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload_time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload_time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload_time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload_time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload_time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload_time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload_time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload_time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload_time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload_time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload_time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload_time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload_time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload_time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload_time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload_time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload_time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload_time = "2025-09-22T04:03:36.249Z" }, +] + +[[package]] +name = "magic-filter" +version = "1.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/08/da7c2cc7398cc0376e8da599d6330a437c01d3eace2f2365f300e0f3f758/magic_filter-1.0.12.tar.gz", hash = "sha256:4751d0b579a5045d1dc250625c4c508c18c3def5ea6afaf3957cb4530d03f7f9", size = 11071, upload_time = "2023-10-01T12:33:19.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/75/f620449f0056eff0ec7c1b1e088f71068eb4e47a46eb54f6c065c6ad7675/magic_filter-1.0.12-py3-none-any.whl", hash = "sha256:e5929e544f310c2b1f154318db8c5cdf544dd658efa998172acd2e4ba0f6c6a6", size = 11335, upload_time = "2023-10-01T12:33:17.711Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload_time = "2025-10-06T14:52:30.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload_time = "2025-10-06T14:49:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload_time = "2025-10-06T14:49:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload_time = "2025-10-06T14:49:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload_time = "2025-10-06T14:49:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload_time = "2025-10-06T14:49:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload_time = "2025-10-06T14:50:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload_time = "2025-10-06T14:50:02.955Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload_time = "2025-10-06T14:50:04.446Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload_time = "2025-10-06T14:50:05.98Z" }, + { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload_time = "2025-10-06T14:50:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload_time = "2025-10-06T14:50:09.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload_time = "2025-10-06T14:50:10.714Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload_time = "2025-10-06T14:50:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload_time = "2025-10-06T14:50:14.16Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload_time = "2025-10-06T14:50:15.639Z" }, + { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload_time = "2025-10-06T14:50:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload_time = "2025-10-06T14:50:18.264Z" }, + { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload_time = "2025-10-06T14:50:19.853Z" }, + { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload_time = "2025-10-06T14:50:21.223Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload_time = "2025-10-06T14:50:22.871Z" }, + { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload_time = "2025-10-06T14:50:24.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload_time = "2025-10-06T14:50:25.716Z" }, + { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload_time = "2025-10-06T14:50:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload_time = "2025-10-06T14:50:29.82Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload_time = "2025-10-06T14:50:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload_time = "2025-10-06T14:50:33.26Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload_time = "2025-10-06T14:50:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload_time = "2025-10-06T14:50:36.436Z" }, + { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload_time = "2025-10-06T14:50:37.953Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload_time = "2025-10-06T14:50:39.574Z" }, + { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload_time = "2025-10-06T14:50:41.612Z" }, + { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload_time = "2025-10-06T14:50:43.972Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload_time = "2025-10-06T14:50:45.648Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload_time = "2025-10-06T14:50:47.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload_time = "2025-10-06T14:50:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload_time = "2025-10-06T14:50:50.16Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload_time = "2025-10-06T14:50:51.92Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload_time = "2025-10-06T14:50:53.275Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload_time = "2025-10-06T14:50:54.911Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload_time = "2025-10-06T14:50:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload_time = "2025-10-06T14:50:57.991Z" }, + { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload_time = "2025-10-06T14:50:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload_time = "2025-10-06T14:51:01.183Z" }, + { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload_time = "2025-10-06T14:51:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload_time = "2025-10-06T14:51:04.724Z" }, + { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload_time = "2025-10-06T14:51:06.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload_time = "2025-10-06T14:51:08.091Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload_time = "2025-10-06T14:51:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload_time = "2025-10-06T14:51:12.466Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload_time = "2025-10-06T14:51:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload_time = "2025-10-06T14:51:16.072Z" }, + { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload_time = "2025-10-06T14:51:17.544Z" }, + { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload_time = "2025-10-06T14:51:18.875Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload_time = "2025-10-06T14:51:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload_time = "2025-10-06T14:51:21.588Z" }, + { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload_time = "2025-10-06T14:51:22.93Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload_time = "2025-10-06T14:51:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload_time = "2025-10-06T14:51:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload_time = "2025-10-06T14:51:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload_time = "2025-10-06T14:51:29.664Z" }, + { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload_time = "2025-10-06T14:51:31.684Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload_time = "2025-10-06T14:51:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload_time = "2025-10-06T14:51:36.189Z" }, + { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload_time = "2025-10-06T14:51:41.291Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload_time = "2025-10-06T14:51:43.55Z" }, + { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload_time = "2025-10-06T14:51:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload_time = "2025-10-06T14:51:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload_time = "2025-10-06T14:51:48.541Z" }, + { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload_time = "2025-10-06T14:51:50.355Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload_time = "2025-10-06T14:51:51.883Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload_time = "2025-10-06T14:51:53.672Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload_time = "2025-10-06T14:51:55.415Z" }, + { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload_time = "2025-10-06T14:52:29.272Z" }, +] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload_time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload_time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload_time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload_time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload_time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload_time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload_time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload_time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload_time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload_time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload_time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload_time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload_time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload_time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload_time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload_time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload_time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload_time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload_time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload_time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload_time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload_time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload_time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload_time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload_time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload_time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload_time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload_time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload_time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload_time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload_time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload_time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload_time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload_time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload_time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload_time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload_time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload_time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload_time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload_time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload_time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload_time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload_time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload_time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload_time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload_time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload_time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload_time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload_time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload_time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload_time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload_time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload_time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload_time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload_time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload_time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload_time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload_time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload_time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload_time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload_time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload_time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload_time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload_time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload_time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload_time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload_time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload_time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload_time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload_time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload_time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload_time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload_time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload_time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload_time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload_time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload_time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload_time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload_time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload_time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload_time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload_time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload_time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload_time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/54/ecab642b3bed45f7d5f59b38443dcb36ef50f85af192e6ece103dbfe9587/pydantic-2.11.10.tar.gz", hash = "sha256:dc280f0982fbda6c38fada4e476dc0a4f3aeaf9c6ad4c28df68a666ec3c61423", size = 788494, upload_time = "2025-10-04T10:40:41.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/1f/73c53fcbfb0b5a78f91176df41945ca466e71e9d9d836e5c522abda39ee7/pydantic-2.11.10-py3-none-any.whl", hash = "sha256:802a655709d49bd004c31e865ef37da30b540786a46bfce02333e0e24b5fe29a", size = 444823, upload_time = "2025-10-04T10:40:39.055Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload_time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload_time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload_time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload_time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload_time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload_time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload_time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload_time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload_time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload_time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload_time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload_time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload_time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload_time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload_time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload_time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload_time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload_time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload_time = "2025-09-24T14:19:11.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload_time = "2025-09-24T14:19:10.015Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload_time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload_time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload_time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload_time = "2024-11-28T03:43:27.893Z" }, +] + +[[package]] +name = "pypika-tortoise" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/86ec1bccb2609d20349def444ef9dfe84aeccc984caa62f4634d50fee164/pypika_tortoise-0.6.3.tar.gz", hash = "sha256:6e17f00e77e78468836cb5c63eb6dc01445f83b1167e4f29f1c678949179c079", size = 80689, upload_time = "2025-11-26T22:07:08.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/6a/da5ba6830dd16cea2804163a2cecc1b2a85b8e06c61f0abb0477069d013d/pypika_tortoise-0.6.3-py3-none-any.whl", hash = "sha256:762e508093f4d73d3654cdde5bce8f92f8f41d999993c44d972d4f1703a663df", size = 46918, upload_time = "2025-11-26T22:07:07.052Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload_time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload_time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload_time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload_time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload_time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload_time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload_time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload_time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload_time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload_time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload_time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload_time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/55/cccfca45157a2031dcbb5a462a67f7cf27f8b37d4b3b1cd7438f0f5c1df6/ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3", size = 5587844, upload_time = "2025-11-06T22:07:45.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/b9/67240254166ae1eaa38dec32265e9153ac53645a6c6670ed36ad00722af8/ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518", size = 12606781, upload_time = "2025-11-06T22:07:01.841Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/09b3ab245d8652eafe5256ab59718641429f68681ee713ff06c5c549f156/ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4", size = 12946765, upload_time = "2025-11-06T22:07:05.858Z" }, + { url = "https://files.pythonhosted.org/packages/14/bb/1564b000219144bf5eed2359edc94c3590dd49d510751dad26202c18a17d/ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33", size = 11928120, upload_time = "2025-11-06T22:07:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/a3/92/d5f1770e9988cc0742fefaa351e840d9aef04ec24ae1be36f333f96d5704/ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2", size = 12370877, upload_time = "2025-11-06T22:07:10.015Z" }, + { url = "https://files.pythonhosted.org/packages/e2/29/e9282efa55f1973d109faf839a63235575519c8ad278cc87a182a366810e/ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5", size = 12408538, upload_time = "2025-11-06T22:07:13.085Z" }, + { url = "https://files.pythonhosted.org/packages/8e/01/930ed6ecfce130144b32d77d8d69f5c610e6d23e6857927150adf5d7379a/ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e", size = 13141942, upload_time = "2025-11-06T22:07:15.386Z" }, + { url = "https://files.pythonhosted.org/packages/6a/46/a9c89b42b231a9f487233f17a89cbef9d5acd538d9488687a02ad288fa6b/ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8", size = 14544306, upload_time = "2025-11-06T22:07:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/78/96/9c6cf86491f2a6d52758b830b89b78c2ae61e8ca66b86bf5a20af73d20e6/ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649", size = 14210427, upload_time = "2025-11-06T22:07:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/71/f4/0666fe7769a54f63e66404e8ff698de1dcde733e12e2fd1c9c6efb689cb5/ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850", size = 13658488, upload_time = "2025-11-06T22:07:22.32Z" }, + { url = "https://files.pythonhosted.org/packages/ee/79/6ad4dda2cfd55e41ac9ed6d73ef9ab9475b1eef69f3a85957210c74ba12c/ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5", size = 13354908, upload_time = "2025-11-06T22:07:24.347Z" }, + { url = "https://files.pythonhosted.org/packages/b5/60/f0b6990f740bb15c1588601d19d21bcc1bd5de4330a07222041678a8e04f/ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132", size = 13587803, upload_time = "2025-11-06T22:07:26.327Z" }, + { url = "https://files.pythonhosted.org/packages/c9/da/eaaada586f80068728338e0ef7f29ab3e4a08a692f92eb901a4f06bbff24/ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67", size = 12279654, upload_time = "2025-11-06T22:07:28.46Z" }, + { url = "https://files.pythonhosted.org/packages/66/d4/b1d0e82cf9bf8aed10a6d45be47b3f402730aa2c438164424783ac88c0ed/ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469", size = 12357520, upload_time = "2025-11-06T22:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/53e2b42cc82804617e5c7950b7079d79996c27e99c4652131c6a1100657f/ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde", size = 12719431, upload_time = "2025-11-06T22:07:33.831Z" }, + { url = "https://files.pythonhosted.org/packages/a2/94/80e3d74ed9a72d64e94a7b7706b1c1ebaa315ef2076fd33581f6a1cd2f95/ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349", size = 13464394, upload_time = "2025-11-06T22:07:35.905Z" }, + { url = "https://files.pythonhosted.org/packages/54/1a/a49f071f04c42345c793d22f6cf5e0920095e286119ee53a64a3a3004825/ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff", size = 12493429, upload_time = "2025-11-06T22:07:38.43Z" }, + { url = "https://files.pythonhosted.org/packages/bc/22/e58c43e641145a2b670328fb98bc384e20679b5774258b1e540207580266/ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c", size = 13635380, upload_time = "2025-11-06T22:07:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/30/bd/4168a751ddbbf43e86544b4de8b5c3b7be8d7167a2a5cb977d274e04f0a1/ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb", size = 12663065, upload_time = "2025-11-06T22:07:42.603Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload_time = "2025-09-09T19:23:31.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload_time = "2025-09-09T19:23:30.041Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload_time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload_time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload_time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload_time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload_time = "2025-08-27T15:39:51.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload_time = "2025-08-27T15:39:50.179Z" }, +] + +[[package]] +name = "starlette" +version = "0.49.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload_time = "2025-11-01T15:12:26.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload_time = "2025-11-01T15:12:24.387Z" }, +] + +[[package]] +name = "tgex-backend" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "aerich" }, + { name = "aioboto3" }, + { name = "aiogram" }, + { name = "aiolimiter" }, + { name = "asyncpg" }, + { name = "beautifulsoup4" }, + { name = "fastapi" }, + { name = "fastapi-pagination" }, + { name = "httpx" }, + { name = "lxml" }, + { name = "pydantic-settings" }, + { name = "pyjwt" }, + { name = "python-multipart" }, + { name = "tortoise-orm" }, + { name = "tortoise-orm-stubs" }, + { name = "types-aiobotocore-s3" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "ty" }, + { name = "vulture" }, +] + +[package.metadata] +requires-dist = [ + { name = "aerich", specifier = ">=0.9.2" }, + { name = "aioboto3", specifier = ">=13.3.0" }, + { name = "aiogram", specifier = ">=3.16.0" }, + { name = "aiolimiter", specifier = ">=1.2.1" }, + { name = "asyncpg", specifier = ">=0.30.0" }, + { name = "beautifulsoup4", specifier = ">=4.14.2" }, + { name = "fastapi", specifier = ">=0.121.0" }, + { name = "fastapi-pagination", specifier = ">=0.15.3" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "lxml", specifier = ">=6.0.2" }, + { name = "pydantic-settings", specifier = ">=2.11.0" }, + { name = "pyjwt", specifier = ">=2.10.1" }, + { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "tortoise-orm", specifier = ">=0.25.1" }, + { name = "tortoise-orm-stubs", specifier = ">=1.0.2" }, + { name = "types-aiobotocore-s3", specifier = ">=2.15.2" }, + { name = "uvicorn", specifier = ">=0.38.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.18.2" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, + { name = "ruff", specifier = ">=0.14.4" }, + { name = "ty", specifier = ">=0.0.1a25" }, + { name = "vulture", specifier = ">=2.14" }, +] + +[[package]] +name = "tortoise-orm" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiosqlite" }, + { name = "iso8601", marker = "python_full_version < '4'" }, + { name = "pypika-tortoise", marker = "python_full_version < '4'" }, + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/9b/de966810021fa773fead258efd8deea2bb73bb12479e27f288bd8ceb8763/tortoise_orm-0.25.1.tar.gz", hash = "sha256:4d5bfd13d5750935ffe636a6b25597c5c8f51c47e5b72d7509d712eda1a239fe", size = 128341, upload_time = "2025-06-05T10:43:31.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/55/2bda7f4445f4c07b734385b46d1647a388d05160cf5b8714a713e8709378/tortoise_orm-0.25.1-py3-none-any.whl", hash = "sha256:df0ef7e06eb0650a7e5074399a51ee6e532043308c612db2cac3882486a3fd9f", size = 167723, upload_time = "2025-06-05T10:43:29.309Z" }, +] + +[[package]] +name = "tortoise-orm-stubs" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tortoise-orm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/49/45b06cda907e55226b8ed4ddc71d13ff61505bfe366d72276462eeee9d2b/tortoise_orm_stubs-1.0.2.tar.gz", hash = "sha256:f4d6a810f295bebd83aa71b05ebd2decd883517f3c9530bd2376b9209b0777c6", size = 4559, upload_time = "2023-11-20T14:48:26.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/b1/f0b111dcf9381987f8acb143dd95b77934a3e9120a6c63b2cf4255c2934c/tortoise_orm_stubs-1.0.2-py3-none-any.whl", hash = "sha256:5ae3c2b0eb0286669563634b98202bbdf46349966b1c85659f3160de4fb655d6", size = 4681, upload_time = "2023-11-20T14:48:22.536Z" }, +] + +[[package]] +name = "ty" +version = "0.0.1a25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/6b/e73bc3c1039ea72936158a08313155a49e5aa5e7db5205a149fe516a4660/ty-0.0.1a25.tar.gz", hash = "sha256:5550b24b9dd0e0f8b4b2c1f0fcc608a55d0421dd67b6c364bc7bf25762334511", size = 4403670, upload_time = "2025-10-29T19:40:23.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/3b/4457231238a2eeb04cba4ba7cc33d735be68ee46ca40a98ae30e187de864/ty-0.0.1a25-py3-none-linux_armv6l.whl", hash = "sha256:d35b2c1f94a014a22875d2745aa0432761d2a9a8eb7212630d5caf547daeef6d", size = 8878803, upload_time = "2025-10-29T19:39:42.243Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fa/a328713dd310018fc7a381693d8588185baa2fdae913e01a6839187215df/ty-0.0.1a25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:192edac94675a468bac7f6e04687a77a64698e4e1fe01f6a048bf9b6dde5b703", size = 8695667, upload_time = "2025-10-29T19:39:45.179Z" }, + { url = "https://files.pythonhosted.org/packages/22/e8/5707939118992ced2bf5385adc3ede7723c1b717b07ad14c495eea1e47b4/ty-0.0.1a25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:949523621f336e01bc7d687b7bd08fe838edadbdb6563c2c057ed1d264e820cf", size = 8159012, upload_time = "2025-10-29T19:39:47.011Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fb/ff313aa71602225cd78f1bce3017713d6d1b1c1e0fa8101ead4594a60d95/ty-0.0.1a25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f78f621458c05e59e890061021198197f29a7b51a33eda82bbb036e7ed73d7", size = 8433675, upload_time = "2025-10-29T19:39:48.443Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8d/cc7e7fb57215a15b575a43ed042bdd92971871e0decec1b26d2e7d969465/ty-0.0.1a25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d9656fca8062a2c6709c30d76d662c96d2e7dbfee8f70e55ec6b6afd67b5d447", size = 8668456, upload_time = "2025-10-29T19:39:50.412Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6d/d7bf5909ed2dcdcbc1e2ca7eea80929893e2d188d9c36b3fcb2b36532ff6/ty-0.0.1a25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9f3bbf523b49935bbd76e230408d858dce0d614f44f5807bbbd0954f64e0f01", size = 9023543, upload_time = "2025-10-29T19:39:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b8/72bcefb4be32e5a84f0b21de2552f16cdb4cae3eb271ac891c8199c26b1a/ty-0.0.1a25-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f13ea9815f4a54a0a303ca7bf411b0650e3c2a24fc6c7889ffba2c94f5e97a6a", size = 9700013, upload_time = "2025-10-29T19:39:57.283Z" }, + { url = "https://files.pythonhosted.org/packages/90/0d/cf7e794b840cf6b0bbecb022e593c543f85abad27a582241cf2095048cb1/ty-0.0.1a25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eab6e33ebe202a71a50c3d5a5580e3bc1a85cda3ffcdc48cec3f1c693b7a873b", size = 9372574, upload_time = "2025-10-29T19:40:04.532Z" }, + { url = "https://files.pythonhosted.org/packages/1e/71/2d35e7d51b48eabd330e2f7b7e0bce541cbd95950c4d2f780e85f3366af1/ty-0.0.1a25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6b9a31da43424cdab483703a54a561b93aabba84630788505329fc5294a9c62", size = 9535726, upload_time = "2025-10-29T19:40:06.548Z" }, + { url = "https://files.pythonhosted.org/packages/57/d3/01ecc23bbd8f3e0dfbcf9172d06d84e88155c5f416f1491137e8066fd859/ty-0.0.1a25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a90d897a7c1a5ae9b41a4c7b0a42262a06361476ad88d783dbedd7913edadbc", size = 9003380, upload_time = "2025-10-29T19:40:08.683Z" }, + { url = "https://files.pythonhosted.org/packages/de/f9/cde9380d8a1a6ca61baeb9aecb12cbec90d489aa929be55cd78ad5c2ccd9/ty-0.0.1a25-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:93c7e7ab2859af0f866d34d27f4ae70dd4fb95b847387f082de1197f9f34e068", size = 8401833, upload_time = "2025-10-29T19:40:10.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/39/0acf3625b0c495011795a391016b572f97a812aca1d67f7a76621fdb9ebf/ty-0.0.1a25-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a247061bd32bae3865a236d7f8b6c9916c80995db30ae1600999010f90623a9", size = 8706761, upload_time = "2025-10-29T19:40:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/25/73/7de1648f3563dd9d416d36ab5f1649bfd7b47a179135027f31d44b89a246/ty-0.0.1a25-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1711dd587eccf04fd50c494dc39babe38f4cb345bc3901bf1d8149cac570e979", size = 8792426, upload_time = "2025-10-29T19:40:14.553Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8a/b6e761a65eac7acd10b2e452f49b2d8ae0ea163ca36bb6b18b2dadae251b/ty-0.0.1a25-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f4c9b0cf7995e2e3de9bab4d066063dea92019f2f62673b7574e3612643dd35", size = 9103991, upload_time = "2025-10-29T19:40:16.332Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/9324ae947fcc4322470326cf8276a3fc2f08dc82adec1de79d963fdf7af5/ty-0.0.1a25-py3-none-win32.whl", hash = "sha256:168fc8aee396d617451acc44cd28baffa47359777342836060c27aa6f37e2445", size = 8387095, upload_time = "2025-10-29T19:40:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2b/cb12cbc7db1ba310aa7b1de9b4e018576f653105993736c086ee67d2ec02/ty-0.0.1a25-py3-none-win_amd64.whl", hash = "sha256:a2fad3d8e92bb4d57a8872a6f56b1aef54539d36f23ebb01abe88ac4338efafb", size = 9059225, upload_time = "2025-10-29T19:40:20.278Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c1/f6be8cdd0bf387c1d8ee9d14bb299b7b5d2c0532f550a6693216a32ec0c5/ty-0.0.1a25-py3-none-win_arm64.whl", hash = "sha256:dde2962d448ed87c48736e9a4bb13715a4cced705525e732b1c0dac1d4c66e3d", size = 8536832, upload_time = "2025-10-29T19:40:22.014Z" }, +] + +[[package]] +name = "types-aiobotocore-s3" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/f9/76c84023add0e6b8b647eb8d085538b1a180bd560a0b5d115d5fea79cd11/types_aiobotocore_s3-3.1.0.tar.gz", hash = "sha256:2f61d2f785fcbad9af2a01b3162b50436f95bea5440e0b9b848e6f60a23a3602", size = 76650, upload_time = "2026-01-03T02:07:22.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/08/9ef8235e3b7fd1bfd843a6047a9518c15852e853df76b14c0bd3df7b38f5/types_aiobotocore_s3-3.1.0-py3-none-any.whl", hash = "sha256:b019d2db117a0f17df0f60c3eec547ae98a17ce4d03e73ba5a3cfe77d7f30291", size = 84332, upload_time = "2026-01-03T02:02:28.847Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload_time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload_time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload_time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload_time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload_time = "2025-12-11T15:56:40.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload_time = "2025-12-11T15:56:38.584Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload_time = "2025-10-18T13:46:44.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload_time = "2025-10-18T13:46:42.958Z" }, +] + +[[package]] +name = "vulture" +version = "2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/25/925f35db758a0f9199113aaf61d703de891676b082bd7cf73ea01d6000f7/vulture-2.14.tar.gz", hash = "sha256:cb8277902a1138deeab796ec5bef7076a6e0248ca3607a3f3dee0b6d9e9b8415", size = 58823, upload_time = "2024-12-08T17:39:43.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/56/0cc15b8ff2613c1d5c3dc1f3f576ede1c43868c1bc2e5ccaa2d4bcd7974d/vulture-2.14-py2.py3-none-any.whl", hash = "sha256:d9a90dba89607489548a49d557f8bac8112bd25d3cbc8aeef23e860811bd5ed9", size = 28915, upload_time = "2024-12-08T17:39:40.573Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload_time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload_time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload_time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload_time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload_time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload_time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload_time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload_time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload_time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload_time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload_time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload_time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload_time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload_time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload_time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload_time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload_time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload_time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload_time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload_time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload_time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload_time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload_time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload_time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload_time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload_time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload_time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload_time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload_time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload_time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload_time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload_time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload_time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload_time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload_time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload_time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload_time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload_time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload_time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload_time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload_time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload_time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload_time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload_time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload_time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload_time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload_time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload_time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload_time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload_time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload_time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload_time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload_time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload_time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload_time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload_time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload_time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload_time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload_time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload_time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload_time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload_time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload_time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload_time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload_time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload_time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload_time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload_time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload_time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload_time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload_time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload_time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload_time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload_time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload_time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload_time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload_time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload_time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload_time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload_time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload_time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload_time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload_time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload_time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload_time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload_time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload_time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload_time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload_time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload_time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload_time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload_time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload_time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload_time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload_time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload_time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload_time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload_time = "2025-10-06T14:12:53.872Z" }, +]