From 9a3639b5ffdd97de0a102c6b7cdb5ef865797c1f Mon Sep 17 00:00:00 2001 From: Redsandyg Date: Mon, 2 Jun 2025 13:22:06 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B8=D1=82=20=D1=81=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 2 + .gitignore | 119 ++++++++++++++ README.md | 62 +++++++ main.py | 355 ++++++++++++++++++++++++++++++++++++++++ my_links_mock_data.json | 17 ++ my_ststs_mock_data.json | 22 +++ 6 files changed, 577 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 README.md create mode 100644 main.py create mode 100644 my_links_mock_data.json create mode 100644 my_ststs_mock_data.json diff --git a/.env b/.env new file mode 100644 index 0000000..8752c29 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +TG_BOT_TOKEN=7727936968:AAELs0gg5VNGzg6wMnG66CkpYaEv0K1Tp5w +API_URL=http://127.0.0.1:8000 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb0d8d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,119 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +*.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 +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# poetry +poetry.lock + +# mypy +.mypy_cache/ +.dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# VS Code +.vscode/ + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# SQLite database +*.db + +# MacOS +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# Other +*.swp +*.swo \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fba91d6 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Главный экран +Появляется после ввода команды /start + +Текст сообщения: Официальное приветствие. Коротко описание что это бот для партнерки + +Кнопки: + 1. Мои ссылки — просмотр ваших партнерских ссылок с пагинацией + 2. Промо материалы — переход по внешней ссылке + 3. Партнерское соглашение — переход по внешней ссылке + 4. Задать вопрос — всплывающее сообщение (функция не реализована) + 5. Моя статистика — просмотр общей статистики и статистики по рефералам с пагинацией + 6. Создать заявку на вывод средств — всплывающее сообщение (функция не реализована) + +--- + +## Формат файлов данных + +### partner-tg/my_links_mock_data.json +Массив объектов: +``` +[ + { + "link": "stores-apple.com?ref=", // ссылка с уникальным идентификатором + "name": "Название ссылки" // описание/название ссылки + }, + ... +] +``` + +- `link` — уникальная партнерская ссылка +- `name` — описание/название ссылки + +### partner-tg/my_ststs_mock_data.json +Объект: +``` +{ + "totalSales": int, // общее количество продаж + "totalIncome": float, // общий доход + "availableWithdrawal": float, // доступно к выводу + "refData": [ // список данных по рефералам + { + "name": str, // название реферальной ссылки + "sales": int, // количество продаж по ссылке + "income": float // доход по ссылке + }, + ... + ] +} +``` + +- `totalSales` — общее количество продаж +- `totalIncome` — общий доход +- `availableWithdrawal` — сумма, доступная к выводу +- `refData` — массив статистики по каждой реферальной ссылке + +--- + +## Основные функции бота +- Все экраны реализованы через редактирование исходного сообщения (edit_text), а не отправку новых сообщений. +- Для списков и таблиц реализована пагинация (по 10 элементов на страницу). +- Кнопки навигации и возврата всегда присутствуют на соответствующих экранах. +- Для не реализованных функций выводится всплывающее сообщение (show_alert). \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..ac3dfe1 --- /dev/null +++ b/main.py @@ -0,0 +1,355 @@ +import asyncio +import os +from aiogram import Bot, Dispatcher, types, F +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.filters import CommandStart +from dotenv import load_dotenv +import json +import aiohttp +from aiogram.fsm.context import FSMContext +from aiogram.fsm.storage.memory import MemoryStorage +from aiogram.fsm.state import State, StatesGroup + +load_dotenv() + +API_TOKEN = os.getenv('TG_BOT_TOKEN') +API_URL = os.getenv('API_URL') + + +bot = Bot(token=API_TOKEN) +dp = Dispatcher() +storage = MemoryStorage() +dp.fsm.storage = storage + +# Текст приветствия для главного экрана +WELCOME_TEXT = ( + '👋 Добро пожаловать!\n\n' + 'Это официальный бот для партнерской программы.\n' + 'Здесь вы найдете свои партнерские ссылки, промо материалы, статистику и сможете задать вопросы.' +) + +# Количество ссылок/рефералов на одной странице (для пагинации) +LINKS_PER_PAGE = 10 +PROMO_URL = 'https://telegra.ph/Test-05-24-363' +PARTNER_AGREEMENT_URL = 'https://telegra.ph/Test-05-24-363' + +# Клавиатура главного экрана +main_keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text='Мои ссылки', callback_data='links'), + InlineKeyboardButton(text='Промо материалы', url=PROMO_URL) + ], + [ + InlineKeyboardButton(text='Партнерское соглашение', url=PARTNER_AGREEMENT_URL), + InlineKeyboardButton(text='Задать вопрос', callback_data='question') + ], + [ + InlineKeyboardButton(text='Моя статистика', callback_data='stats'), + InlineKeyboardButton(text='Создать заявку на вывод средств', callback_data='withdraw') + ], +]) + +# Вместо загрузки из файла будем хранить кэш токенов пользователей +user_tokens = {} + +# ===================== +# Формат my_links_mock_data.json: +# [ +# { +# "ref": "", # ссылка с уникальным идентификатором +# "description": "Описание/название ссылки" # описание/название ссылки +# }, +# ... +# ] +# ===================== + +async def get_links_from_api(token: str): + headers = {"Authorization": f"Bearer {token}"} + async with aiohttp.ClientSession() as session: + async with session.get(f'{API_URL}/ref', headers=headers) as resp: + if resp.status == 200: + return await resp.json() + return [] + +async def render_links_page(page: int = 0, token: str = None): + """ + Формирует текст и клавиатуру для страницы с партнерскими ссылками. + Получает данные с API. + """ + links = await get_links_from_api(token) + total = len(links) + start = page * LINKS_PER_PAGE + end = start + LINKS_PER_PAGE + page_links = links[start:end] + header = f"{'ref':<36} | description" + sep = '-' * 36 + '-|-' + '-' * 30 + rows = [] + for item in page_links: + ref = item['ref'] + description = item['description'] + url = f"https://{item['ref']}" + rows.append(f"{ref:<36} | {description}") + if not rows: + table = 'Нет ссылок.' + else: + table = '\n'.join([header, sep] + rows) + text = f"🔗 Ваши партнерские ссылки (стр. {page+1}):\n\n
{table}
" + nav_buttons = [] + if start > 0: + nav_buttons.append(InlineKeyboardButton(text='⬅️ Предыдущая', callback_data=f'links_page_{page-1}')) + if end < total: + nav_buttons.append(InlineKeyboardButton(text='Следующая ➡️', callback_data=f'links_page_{page+1}')) + keyboard_rows = [] + if nav_buttons: + keyboard_rows.append(nav_buttons) + keyboard_rows.append([InlineKeyboardButton(text='Создать новую', callback_data='create_link')]) + keyboard_rows.append([InlineKeyboardButton(text='Назад', callback_data='back_to_main')]) + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + return text, keyboard + +@dp.message(CommandStart()) +async def send_welcome(message: types.Message): + """Обрабатывает команду /start, авторизует пользователя и показывает главный экран.""" + tg_id = message.from_user.id + chat_id = message.chat.id + name = message.from_user.full_name + login = message.from_user.username + token = None + error = None + async with aiohttp.ClientSession() as session: + # 1. Пробуем получить токен + try: + async with session.post(f'{API_URL}/token', json={'tg_id': tg_id}) as resp: + if resp.status == 200: + data = await resp.json() + token = data.get('access_token') + else: + # 2. Если не ок — регистрируем + async with session.post(f'{API_URL}/register', json={'tg_id': tg_id, 'chat_id': chat_id, 'name': name, 'login': login}) as reg_resp: + if reg_resp.status == 200: + # 3. Пробуем снова получить токен + async with session.post(f'{API_URL}/token', json={'tg_id': tg_id}) as resp2: + if resp2.status == 200: + data = await resp2.json() + token = data.get('access_token') + else: + error = 'Ошибка авторизации (token после регистрации)' + else: + error = 'Ошибка регистрации пользователя' + except Exception as e: + error = f'Ошибка соединения с сервером авторизации: {e}' + if token: + user_tokens[tg_id] = token + await message.answer(WELCOME_TEXT, reply_markup=main_keyboard) + else: + await message.answer(f'Ошибка авторизации: {error}', reply_markup=None) + +@dp.callback_query(F.data == 'links') +async def show_links(callback: types.CallbackQuery): + """Показывает страницу с партнерскими ссылками (первая страница).""" + tg_id = callback.from_user.id + token = user_tokens.get(tg_id) + text, keyboard = await render_links_page(0, token) + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML', disable_web_page_preview=True) + await callback.answer() + +@dp.callback_query(lambda c: c.data and c.data.startswith('links_page_')) +async def paginate_links(callback: types.CallbackQuery): + """Пагинация по партнерским ссылкам.""" + page = int(callback.data.split('_')[-1]) + tg_id = callback.from_user.id + token = user_tokens.get(tg_id) + text, keyboard = await render_links_page(page, token) + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML', disable_web_page_preview=True) + await callback.answer() + +@dp.callback_query(F.data == 'back_to_main') +async def back_to_main(callback: types.CallbackQuery): + """Возврат на главный экран.""" + await callback.message.edit_text(WELCOME_TEXT, reply_markup=main_keyboard) + await callback.answer() + +# Состояния для FSM +class LinkStates(StatesGroup): + waiting_for_description = State() + link_created = State() + +@dp.callback_query(F.data == 'create_link') +async def create_link(callback: types.CallbackQuery, state: FSMContext): + sent = await callback.message.edit_text( + 'Введите описание новой ссылке:', + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text='Назад', callback_data='back_to_links')]] + ) + ) + # Сохраняем id сообщения, чтобы потом убрать у него кнопки + await state.set_state(LinkStates.waiting_for_description) + await state.update_data(desc_msg_id=sent.message_id) + await callback.answer() + +# Обработка кнопки "Назад" из состояния создания ссылки +@dp.callback_query(F.data == 'back_to_links', LinkStates.waiting_for_description) +async def back_to_links_from_create(callback: types.CallbackQuery, state: FSMContext): + tg_id = callback.from_user.id + token = user_tokens.get(tg_id) + text, keyboard = await render_links_page(0, token) + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML', disable_web_page_preview=True) + await state.clear() + await callback.answer() + +# Обработка ввода описания новой ссылки +@dp.message(LinkStates.waiting_for_description) +async def process_new_link_description(message: types.Message, state: FSMContext): + description = message.text.strip() + tg_id = message.from_user.id + token = user_tokens.get(tg_id) + headers = {"Authorization": f"Bearer {token}"} + data = await state.get_data() + desc_msg_id = data.get('desc_msg_id') + # Убираем кнопки у сообщения с просьбой ввести описание + if desc_msg_id: + try: + await message.bot.edit_message_reply_markup( + chat_id=message.chat.id, + message_id=desc_msg_id, + reply_markup=None + ) + except Exception: + pass + async with aiohttp.ClientSession() as session: + async with session.post(f'{API_URL}/ref/add', json={"description": description}, headers=headers) as resp: + if resp.status == 200: + data = await resp.json() + ref = data.get('ref') + text = f"Новая ссылка успешно создана!\n\n
ref: {ref}\ndescription: {description}
" + keyboard = InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text='Назад', callback_data='back_to_links_from_success')]] + ) + await message.answer(text, reply_markup=keyboard, parse_mode='HTML') + await state.set_state(LinkStates.link_created) + else: + await message.answer('Ошибка при создании ссылки. Попробуйте еще раз.') + +# Обработка кнопки "Назад" после успешного создания ссылки +@dp.callback_query(F.data == 'back_to_links_from_success', LinkStates.link_created) +async def back_to_links_from_success(callback: types.CallbackQuery, state: FSMContext): + tg_id = callback.from_user.id + token = user_tokens.get(tg_id) + text, keyboard = await render_links_page(0, token) + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML', disable_web_page_preview=True) + await state.clear() + await callback.answer() + +@dp.callback_query(F.data == 'withdraw') +async def not_implemented_withdraw(callback: types.CallbackQuery): + await callback.answer('Функция вывода средств пока не реализована.', show_alert=True) + +@dp.callback_query(F.data == 'question') +async def not_implemented_question(callback: types.CallbackQuery): + await callback.answer('Функция "Задать вопрос" пока не реализована.', show_alert=True) + +# ===================== +# Формат my_ststs_mock_data.json: +# { +# "totalSales": int, # общее количество продаж +# "totalIncome": float, # общий доход +# "availableWithdrawal": float, # доступно к выводу +# "refData": [ # список данных по рефералам +# { +# "name": str, # название реферальной ссылки +# "sales": int, # количество продаж по ссылке +# "income": float # доход по ссылке +# }, +# ... +# ] +# } +# ===================== + +async def get_stat_from_api(token: str): + headers = {"Authorization": f"Bearer {token}"} + async with aiohttp.ClientSession() as session: + async with session.get(f'{API_URL}/stat', headers=headers) as resp: + if resp.status == 200: + return await resp.json() + return None + +async def get_ref_stat_from_api(token: str): + headers = {"Authorization": f"Bearer {token}"} + async with aiohttp.ClientSession() as session: + async with session.get(f'{API_URL}/ref/stat', headers=headers) as resp: + if resp.status == 200: + data = await resp.json() + return data.get('refData', []) + return [] + +async def render_stats_page(page: int = 0, token: str = None): + """ + Формирует текст и клавиатуру для страницы со статистикой. + Показывает общую статистику и таблицу по рефералам (по 10 на страницу). + """ + stats = await get_stat_from_api(token) + ref_data = await get_ref_stat_from_api(token) + total = len(ref_data) + start = page * LINKS_PER_PAGE + end = start + LINKS_PER_PAGE + page_refs = ref_data[start:end] + # Общая статистика + if stats: + stat_text = ( + f"Ваша статистика\n" + f"Всего продаж: {stats.get('totalSales', 0)}\n" + f"Всего заработано: {stats.get('totalIncome', 0):.1f}\n" + f"Доступно к выводу: {stats.get('availableWithdrawal', 0):.1f}\n" + ) + else: + stat_text = "Ваша статистика\nОшибка получения данных." + # Таблица по рефералам + header = f"{'description':<30} | {'sales':<5} | income" + sep = '-' * 30 + '-|-' + '-' * 5 + '-|-' + '-' * 10 + rows = [] + for item in page_refs: + description = item.get('description', '')[:30] + sales = item.get('sales', 0) + income = item.get('income', 0) + rows.append(f"{description:<30} | {sales:<5} | {income:.1f}") + if not rows: + table = 'Нет данных.' + else: + table = '\n'.join([header, sep] + rows) + text = stat_text + '\n
' + table + '
' + # Кнопки навигации и перехода + nav_buttons = [] + if start > 0: + nav_buttons.append(InlineKeyboardButton(text='⬅️ Предыдущая', callback_data=f'stats_page_{page-1}')) + if end < total: + nav_buttons.append(InlineKeyboardButton(text='Следующая ➡️', callback_data=f'stats_page_{page+1}')) + keyboard_rows = [] + if nav_buttons: + keyboard_rows.append(nav_buttons) + keyboard_rows.append([InlineKeyboardButton(text='Мои ссылки', callback_data='links')]) + keyboard_rows.append([InlineKeyboardButton(text='Назад', callback_data='back_to_main')]) + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows) + return text, keyboard + +@dp.callback_query(F.data == 'stats') +async def show_stats(callback: types.CallbackQuery): + tg_id = callback.from_user.id + token = user_tokens.get(tg_id) + text, keyboard = await render_stats_page(0, token) + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML', disable_web_page_preview=True) + await callback.answer() + +@dp.callback_query(lambda c: c.data and c.data.startswith('stats_page_')) +async def paginate_stats(callback: types.CallbackQuery): + page = int(callback.data.split('_')[-1]) + tg_id = callback.from_user.id + token = user_tokens.get(tg_id) + text, keyboard = await render_stats_page(page, token) + await callback.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML', disable_web_page_preview=True) + await callback.answer() + +async def main(): + await dp.start_polling(bot) + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/my_links_mock_data.json b/my_links_mock_data.json new file mode 100644 index 0000000..0ae94b6 --- /dev/null +++ b/my_links_mock_data.json @@ -0,0 +1,17 @@ +[ + {"ref": "7e2a1b2c-3d4e-5f6a-7b8c-9d0e1f2a3b4c", "description": "Реклама в Telegram от 01.05.2025"}, + {"ref": "2f3e4d5c-6b7a-8c9d-0e1f-2a3b4c5d6e7f", "description": "Пост в Instagram от 15.04.2025"}, + {"ref": "8c7b6a5d-4e3f-2a1b-0c9d-8e7f6a5b4c3d", "description": "Рассылка в ВК от 24.02.2025"}, + {"ref": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", "description": "Промо в Facebook от 10.03.2025"}, + {"ref": "9d8c7b6a-5e4f-3a2b-1c0d-9e8f7a6b5c4d", "description": "Пост в Twitter от 05.03.2025"}, + {"ref": "3b2a1c0d-9e8f-7a6b-5c4d-3e2f1a0b9c8d", "description": "Реклама в YouTube от 12.04.2025"}, + {"ref": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a", "description": "Промо в TikTok от 20.04.2025"}, + {"ref": "5c4d3e2f-1a0b-9c8d-7e6f-5a4b3c2d1e0f", "description": "Пост в LinkedIn от 18.03.2025"}, + {"ref": "6a7b8c9d-0e1f-2a3b-4c5d-6e7f8a9b0c1d", "description": "Реклама в Одноклассниках от 22.03.2025"}, + {"ref": "7b8c9d0e-1f2a-3b4c-5d6e-7f8a9b0c1d2e", "description": "Промо в Pinterest от 30.03.2025"}, + {"ref": "8a9b0c1d-2e3f-4a5b-6c7d-8e9f0a1b2c3d", "description": "Пост в Reddit от 02.04.2025"}, + {"ref": "9b0c1d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e", "description": "Реклама в Dzen от 11.04.2025"}, + {"ref": "0c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f", "description": "Промо в Medium от 08.04.2025"}, + {"ref": "1d2e3f4a-5b6c-7d8e-9f0a-1b2c3d4e5f6a", "description": "Пост в WhatsApp от 14.04.2025"}, + {"ref": "2e3f4a5b-6c7d-8e9f-0a1b-2c3d4e5f6a7b", "description": "Реклама в Discord от 21.04.2025"} +] \ No newline at end of file diff --git a/my_ststs_mock_data.json b/my_ststs_mock_data.json new file mode 100644 index 0000000..3a240b8 --- /dev/null +++ b/my_ststs_mock_data.json @@ -0,0 +1,22 @@ +{ + "totalSales": 123, + "totalIncome": 45678.90, + "availableWithdrawal": 12345.67, + "refData": [ + {"description": "Реклама в Telegram от 01.05.2025", "sales": 12, "income": 1234.56}, + {"description": "Пост в Instagram от 15.04.2025", "sales": 8, "income": 987.65}, + {"description": "Рассылка в ВК от 24.02.2025", "sales": 15, "income": 1500.00}, + {"description": "Промо в Facebook от 10.03.2025", "sales": 5, "income": 500.00}, + {"description": "Пост в Twitter от 05.03.2025", "sales": 7, "income": 700.00}, + {"description": "Реклама в YouTube от 12.04.2025", "sales": 10, "income": 1100.00}, + {"description": "Промо в TikTok от 20.04.2025", "sales": 6, "income": 600.00}, + {"description": "Пост в LinkedIn от 18.03.2025", "sales": 4, "income": 400.00}, + {"description": "Реклама в Одноклассниках от 22.03.2025", "sales": 9, "income": 900.00}, + {"description": "Промо в Pinterest от 30.03.2025", "sales": 3, "income": 300.00}, + {"description": "Пост в Reddit от 02.04.2025", "sales": 2, "income": 200.00}, + {"description": "Реклама в Dzen от 11.04.2025", "sales": 1, "income": 100.00}, + {"description": "Промо в Medium от 08.04.2025", "sales": 11, "income": 1100.00}, + {"description": "Пост в WhatsApp от 14.04.2025", "sales": 13, "income": 1300.00}, + {"description": "Реклама в Discord от 21.04.2025", "sales": 14, "income": 1400.00} + ] +} \ No newline at end of file