--- title: Telegram-асистент для закупівлі реклами: покрокове створення з кодом та промптами url: https://blog.krasovskiy.team/telegram-asystent-zakupivli-reklamy-instrukciya/ date: 2026-07-22 lang: uk source: blog.krasovskiy.team --- # Telegram-асистент для закупівлі реклами: покрокове створення з кодом та промптами Покрокова інструкція: як зібрати бота, який приймає голосові повідомлення, перетворює їх на завдання, веде таблицю закупівель, нагадує про заміри, аналізує канали перед покупкою та пише чернетки рекламних постів. Інструкція розрахована на людину, яка раніше не запускала ботів. Усі команди копіюються повністю. Якщо крок не вдався — в кінці кожного розділу є перевірка та розбір типових помилок. **Що потрібно мати до початку:** **Скільки коштуватиме на місяць:** сервер — від 4 доларів; Telegram — безкоштовно; Google Sheets — безкоштовно; розпізнавання голосу та розбір завдань — менше долара за звичайного навантаження. **Важливо про вибір сервера.** Частина хостингів (особливо шеред-хостинги для сайтів) блокують вихідні з'єднання до api.telegram.org. На такому сервері бот не працюватиме, і зрозуміти це складно — він просто мовчить. Перевірка однією командою є в кроці 3. ![Шлях голосового повідомлення: від диктування до рядка в таблиці](https://blog.krasovskiy.team/wp-content/uploads/2026/07/uk-02-arch.png) ## Частина 1. Процес, який ми автоматизуємо Бот марний, якщо незрозуміло, яку роботу він підтримує. Нижче — коротка версія процесу закупівлі. Повна версія з усіма шаблонами повідомлень, формулами та правилами безпеки лежить в окремому документі «Регламент: закупівля реклами в Telegram-каналах», його потрібно прочитати до початку роботи. Закупівля складається з семи етапів: Три місця тут ламаються найчастіше, і саме їх закриває бот: Бот бере на себе пам'ять і облік. Рішення та листування з людьми залишаємо людині. ## Частина 2. Підготовка ## Крок 1. Створюємо бота Відкриваємо Telegram, у пошуку знаходимо **@BotFather** — офіційний бот Telegram для створення ботів. У справжнього BotFather є позначка верифікації, у підробок її немає. > СКРІН 1: діалог з BotFather, стрілка на рядок з токеном. **Токен — це пароль від бота.** Хто ним володіє, той керує ботом. Не надсилаємо його в загальні чати, не комітимо в репозиторій. Якщо випадково засвітили — у BotFather команда `/revoke` випускає новий. Відразу налаштовуємо приватність, щоб бот не збирав зайвого: ``` start - запустить help - что умею task - добавить задачу текстом today - задачи на сегодня channel - анализ канала перед закупкой draft - черновик рекламного поста ``` ## Крок 2. Дізнаємося свій Telegram ID Бот має відповідати тільки своїм. Для цього потрібен числовий ID кожного співробітника. Знаходимо в пошуку бота **@userinfobot**, запускаємо, він надсилає ID — число виду `123456789`. > СКРІН 2: відповідь @userinfobot, стрілка на поле Id. Збираємо ID усіх, хто користуватиметься асистентом. Іншим бот відповідатиме відмовою. ## Крок 3. Готуємо сервер Підключаємося до сервера по SSH: ``` ssh root@АДРЕС_СЕРВЕРА ``` **Насамперед перевіряємо, що сервер взагалі може достукатися до Telegram:** ``` curl -s -o /dev/null -w "%{http_code}n" https://api.telegram.org ``` Має вивести `200`. Якщо команда висить або повертає `000` — цей сервер не підходить, Telegram з нього заблоковано. Міняємо хостинг, далі йти безглуздо. Створюємо окремого користувача — працювати ботом від root неправильно: ``` adduser --disabled-password --gecos "" assistant apt update && apt install -y python3-venv python3-pip ffmpeg su - assistant ``` Готуємо папку проекту та віртуальне оточення: ``` mkdir -p ~/tg-assistant && cd ~/tg-assistant python3 -m venv venv source venv/bin/activate pip install --upgrade pip pip install aiogram==3.15.0 requests gspread google-auth apscheduler telethon python-dotenv ``` **Перевірка кроку:** команда `python -c "import aiogram, gspread, telethon; print('ok')"` має вивести `ok`. ## Крок 4. Отримуємо ключі Асистенту потрібні дві здібності: **розпізнати голос** та **розібрати зміст**. Розбір змісту робить Mistral, розпізнавання — на вибір, детальне порівняння з вимірами у кроці 8. **Ключ Mistral (потрібен завжди):** > СКРІН 3: сторінка API Keys, стрілка на кнопку створення ключа. **Ключ OpenAI (рекомендується, для розпізнавання):** Розпізнавання коштує близько половини цента за хвилину мови. При двадцяти голосових на день це менше долара на місяць. Чому рекомендується саме він — у кроці 8, там виміри на одному й тому самому файлі. Якщо платити за другий сервіс не хочеться — працює й на одному ключі Mistral, а також є повністю безкоштовний варіант без зовнішніх сервісів взагалі. Обидва описані там само. ![П'ять аркушів таблиці — вся пам'ять асистента](https://blog.krasovskiy.team/wp-content/uploads/2026/07/uk-05-sheets.png) ## Крок 5. Готуємо таблицю Таблиця — це база даних асистента. Структура повторює регламент. Створюємо Google-таблицю з назвою `Закупка Telegram` і п’ятьма аркушами. Назви аркушів мають збігатися буква в букву: **Аркуш `Задачі`** — перший рядок: ``` Дата создания | Задача | Тип | Канал | Срок | Приоритет | Статус | Заметка ``` **Аркуш `Канали`**: ``` Название | Ссылка | Тематика | Подписчиков | Средний охват | ER % | Админ | Кошелёк | Прайс | Статус | Последний контакт | Заметки ``` **Аркуш `Розміщення`**: ``` ID | Канал | Дата брони | Дата выхода | Формат | Цена | Валюта | Кошелёк | Хеш | Хеш отправлен | Креатив | Инвайт-ссылка | Ссылка на пост | Статус | Охват 24ч | Подписчиков 7д | CPM | CPS | Вердикт ``` **Аркуш `Платежі`**: ``` Дата | Сумма | Валюта | Кошелёк | Хеш | Канал | Хеш отправлен ``` **Аркуш `Креативи`**: ``` ID | Текст | Медиа | Где использовался | Лучший CPS | Заметки ``` Тепер надаємо боту доступ до цієї таблиці. > СКРІН 4: сторінка створення сервісного акаунта, стрілка на «Створити ключ». > СКРІН 5: вікно доступу до таблиці з вставленою адресою сервісного акаунта. **Крок, який забувають усі.** Якщо не надати сервісному акаунту доступ до таблиці (пункт 6), бот падатиме з помилкою `SpreadsheetNotFound`, хоча таблиця існує. Сервісний акаунт — це окремий «користувач», він не бачить ваші файли автоматично. Завантажуємо файл ключа на сервер. Команда виконується **на своєму комп’ютері**, не на сервері: ``` scp ~/Downloads/имя-файла-ключа.json assistant@АДРЕС_СЕРВЕРА:~/tg-assistant/google.json ``` ## Частина 3. Збірка Усі файли створюються у папці `~/tg-assistant`. ## Крок 6. Налаштування Створюємо файл `.env`: ``` nano ~/tg-assistant/.env ``` Вміст (підставляємо свої значення): ``` BOT_TOKEN=8123456789:AAF... MISTRAL_KEY=... ALLOWED_IDS=123456789,987654321 SHEET_NAME=Закупка Telegram GOOGLE_CREDS=/home/assistant/tg-assistant/google.json CHANNEL_ID=@moy_kanal TG_API_ID= TG_API_HASH= ``` Збереження в nano: `Ctrl+O`, `Enter`, `Ctrl+X`. Закриваємо файл від сторонніх: ``` chmod 600 ~/tg-assistant/.env ~/tg-assistant/google.json ``` Файл `config.py`: ```python import os from dotenv import load_dotenv load_dotenv() BOT_TOKEN = os.environ["BOT_TOKEN"] MISTRAL_KEY = os.environ["MISTRAL_KEY"] ALLOWED_IDS = {int(x) for x in os.environ["ALLOWED_IDS"].split(",") if x.strip()} SHEET_NAME = os.environ["SHEET_NAME"] GOOGLE_CREDS = os.environ["GOOGLE_CREDS"] CHANNEL_ID = os.getenv("CHANNEL_ID", "") TG_API_ID = os.getenv("TG_API_ID", "") TG_API_HASH = os.getenv("TG_API_HASH", "") MISTRAL_URL = "https://api.mistral.ai/v1" MODEL_TEXT = "mistral-large-latest" MODEL_STT = "voxtral-mini-latest" ``` ## Крок 7. Скелет бота Файл `bot.py`: ```python import asyncio import logging from aiogram import Bot, Dispatcher, F from aiogram.filters import Command from aiogram.types import Message import config logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") bot = Bot(token=config.BOT_TOKEN) dp = Dispatcher() def allowed(message: Message) -> bool: return message.from_user.id in config.ALLOWED_IDS @dp.message(Command("start")) async def cmd_start(message: Message): if not allowed(message): await message.answer("Нет доступа. Обратитесь к администратору.") return await message.answer( "Ассистент закупки на связи.nn" "Надиктуйте голосовое — разберу на задачи.n" "/today — задачи на сегодняn" "/channel ссылка — анализ каналаn" "/draft — черновик рекламного поста" ) async def main(): await dp.start_polling(bot) if __name__ == "__main__": asyncio.run(main()) ``` Запускаємо: ``` cd ~/tg-assistant && source venv/bin/activate && python bot.py ``` **Перевірка кроку:** знаходимо свого бота в Telegram за username, тиснемо «Запустити». Має прийти привітання. У консолі сервера з’являться рядки логу. Зупинити бота — `Ctrl+C`. **Якщо відповіді немає:** ![Тестування шести варіантів розпізнавання на одному голосовому повідомленні](https://blog.krasovskiy.team/wp-content/uploads/2026/07/uk-03-stt.png) ## Крок 8. Голосове повідомлення в текст Тут легко помилитися з вибором сервісу, тому нижче — виміри, а не думки. Одне й те саме голосове — три доручення, дві назви каналів латиницею та вимовлений уголос формат «один двадцять чотири» — прогнано через чотири варіанти. ВаріантЩо вийшло на спірних місцяхMistral Voxtral`1.24`, `DigitalBourse`, `Telegram Pro IT`OpenAI whisper-1`1.24`, `DigitalBors`, `Telegram про айті`OpenAI gpt-4o-transcribe`1.24`, втрачено сполучник: «нейромережі гроші»**OpenAI gpt-4o-transcribe зі словником****`1/24`**, `Digital Bors`, `Telegram Pro IT`, «дві тисячі рублів»faster-whisper `small` локально`1.24`, «нагадаємо мені» замість «нагадай мені», «канал про нейромережу», `Digital Bores`faster-whisper `small` зі словником`1.24`, «Ніросетій гроші» — словник погіршив результат Висновок не в тому, що одна модель розумніша за іншу — за розбірливістю мови вони близькі. Різниця в одному: **OpenAI дозволяє передати словник термінів перед розпізнаванням, і це єдине, що виправило `1/24`.** Перевірено окремо: Mistral таку підказку приймає мовчки й ігнорує — три прогону, зі підказкою та без, дали посимвольно однаковий текст. Чому це важливо саме в закупівлі: `1.24` замість `1/24` — це формат розміщення, тобто те, за що платять гроші. Помилка тягнеться далі в завдання, у листування з адміном і в звіт. Підтримуємо три варіанти, перемикаються однією стрічкою в `.env`: **Чесність щодо безкоштовного варіанту.** У вимірах вище він прогнаний на моделі `small` — тій, що працює на дешевому сервері. Видно, що вона плутає закінчення та дієслова: «нагадаємо мені» замість «нагадай мені». Для завдань, де важливий зміст, а не буква, цього достатньо — розбір у кроці 9 все одно витягне потрібне. Але словник термінів йому не допомагає, а іноді шкодить: з підказкою назва каналу стала «Ніросетій гроші» замість «нейромережі та гроші». Модель `large-v3` помітно точніша, але вимагає близько 3 ГБ на диску та сервера від 4 ГБ пам’яті — на ній вимірів тут немає, перевіряйте на своїх голосових, перш ніж ставити в роботу. Дописуємо в `.env`: ``` STT_PROVIDER=openai OPENAI_KEY=sk-... ``` І в `config.py`: ``` STT_PROVIDER = os.getenv("STT_PROVIDER", "mistral") OPENAI_KEY = os.getenv("OPENAI_KEY", "") MODEL_STT_OPENAI = "gpt-4o-transcribe" MODEL_STT_LOCAL = "large-v3" ``` Файл `stt.py`: ```python import tempfile import requests import config # Словарь терминов. Распознавание опирается на него в спорных местах. # Дополняйте своими: названия ваших каналов, продуктов, имена админов. HINT = ( "Термины закупки рекламы в Telegram: формат 1/24, формат 2/48, посев, " "охват, инвайт-ссылка, CPM, CPS, хеш транзакции, слот, закреп, " "оплата в USDT, чёрный список." ) _local_model = None def _openai(audio: bytes, filename: str) -> str: response = requests.post( "https://api.openai.com/v1/audio/transcriptions", headers={"Authorization": f"Bearer {config.OPENAI_KEY}"}, files={"file": (filename, audio, "audio/ogg")}, data={"model": config.MODEL_STT_OPENAI, "prompt": HINT}, timeout=180, ) response.raise_for_status() return response.json().get("text", "").strip() def _mistral(audio: bytes, filename: str) -> str: response = requests.post( f"{config.MISTRAL_URL}/audio/transcriptions", headers={"Authorization": f"Bearer {config.MISTRAL_KEY}"}, files={"file": (filename, audio, "audio/ogg")}, data={"model": config.MODEL_STT}, timeout=180, ) response.raise_for_status() return response.json().get("text", "").strip() def _local(audio: bytes, filename: str) -> str: global _local_model from faster_whisper import WhisperModel if _local_model is None: _local_model = WhisperModel(config.MODEL_STT_LOCAL, device="cpu", compute_type="int8") with tempfile.NamedTemporaryFile(suffix=".ogg") as handle: handle.write(audio) handle.flush() segments, _ = _local_model.transcribe(handle.name, language="ru", initial_prompt=HINT) return " ".join(segment.text.strip() for segment in segments).strip() def transcribe(audio: bytes, filename: str = "voice.ogg") -> str: """Голосовое Telegram (ogg/opus) в текст. Конвертация не нужна.""" if config.STT_PROVIDER == "openai": return _openai(audio, filename) if config.STT_PROVIDER == "local": return _local(audio, filename) return _mistral(audio, filename) ``` Для варіанту `local` додаємо бібліотеку — це відкритий проєкт, модель завантажується сама при першому запуску: ``` pip install faster-whisper ``` Перший запуск буде довгим: модель `large-v3` важить близько трьох гігабайт і завантажується один раз. Якщо сервер слабкий, змініть `MODEL_STT_LOCAL` на `medium` або `small` — якість нижча, зате працює на гігабайті пам’яті. Окремо конвертувати голосові не потрібно в жодному з варіантів — формат Telegram приймається як є. Додаємо в `bot.py` обробник. Імпорти — нагору файлу, обробник — перед функцією `main`: ```python import stt @dp.message(F.voice) async def on_voice(message: Message): if not allowed(message): return note = await message.answer("Слушаю...") file = await bot.get_file(message.voice.file_id) buffer = await bot.download_file(file.file_path) text = stt.transcribe(buffer.read()) await note.edit_text(f"Расшифровка:nn{text}") ``` **Перевірка кроку:** перезапускаємо бота, надсилаємо голосове. Через кілька секунд приходить текст. **Особливість, про яку варто знати.** Назви каналів латиницею будь-яке розпізнавання пише по-своєму: `DigitalBourse`, `DigitalBors`, `Digital Bors` — це три варіанти однієї й тієї самої назви каналу з вимірів вище. Виправляти це в промті безглуздо, модель не знає, як правильно. Тому в правилах розбору стоїть прямий заборона «перенось як є, не виправляй»: нехай у завданні буде впізнаване спотворення, аніж упевнено вигадана чужа назва. Зі словником (варіант `openai`) терміни закупівлі розпізнаються вірно. На варіантах `mistral` і `local` формат виду `1.24` підчищається вже на наступному кроці — правилом 6 у промті розбору. Тому крок 9 потрібен у будь-якому випадку. ## Крок 9. Розбір тексту на завдання Це головний вузол асистента. Промт нижче — робочий, він пройшов перевірку на живих голосових, включаючи описані спотворення. Файл `prompts.py`: ```python import datetime DAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"] def date_table(today: datetime.date) -> str: """Готовый календарь для модели. Считать даты сама она не должна.""" lines = [ f"сегодня = {today.isoformat()} ({DAYS[today.weekday()]})", f"завтра = {(today + datetime.timedelta(1)).isoformat()}", f"послезавтра = {(today + datetime.timedelta(2)).isoformat()}", ] for index, name in enumerate(DAYS): shift = ((index - today.weekday()) % 7) or 7 lines.append(f"ближайший {name} = " f"{(today + datetime.timedelta(shift)).isoformat()}") monday = today + datetime.timedelta(days=7 - today.weekday()) lines.append(f"следующая неделя = с {monday.isoformat()} " f"по {(monday + datetime.timedelta(6)).isoformat()}") lines.append(f"через неделю = {(today + datetime.timedelta(7)).isoformat()}") return "n".join(lines) TASKS_SYSTEM = """Ты разбираешь надиктованные сообщения менеджера по закупке рекламы в Telegram-каналах и превращаешь их в задачи. Отвечаешь только валидным JSON, без markdown и пояснений. Календарь (бери даты только отсюда, сам не вычисляй): {calendar} Формат ответа: {{"tasks": [{{"title": "...", "type": "...", "channel": "...", "due": "YYYY-MM-DD", "priority": 1, "note": "..."}}], "questions": ["..."]}} Поле type — строго одно из: переговоры, оплата, креатив, контроль_выхода, замер_24ч, замер_7д, анализ_канала, отчёт, прочее. Правила: 1. Одна мысль — одна задача. Три поручения в сообщении — три объекта в tasks. 2. title — короткая формулировка в повелительном наклонении, без слов «надо», «нужно». 3. channel — ПОЛНОЕ название канала, все слова подряд, пока не начнётся следующее действие. «админу канала Нейросети и деньги, спросить цену» — название «Нейросети и деньги», а не «Нейросети». Слово «канал» отбрасывай. Если канал не назван — null. 4. due — только дата из календаря выше. Своей арифметики с датами не делай. Срок не назван — null. 5. priority: 1 — всё, что связано с деньгами (type = оплата), а также любой срок сегодня или завтра. 2 — задача со сроком дальше завтра. 3 — задача без срока. Оплата не может иметь приоритет ниже 1. 6. Нормализация форматов: «1.24», «1 24», «один двадцать четыре», «1/24» — записывай как 1/24. Аналогично 2/48. Формат указывай в note. 7. Распознавание речи искажает названия каналов и пишет их латиницей. Переноси как есть, не переводи и не исправляй. 8. Непонятно, что требуется — не выдумывай задачу, задай вопрос в questions.""" ``` Розбір кожного правила — навіщо воно потрібне: Файл `brain.py`: ```python import datetime import json import requests import config import prompts def ask(system: str, user: str, json_mode: bool = True) -> str: payload = { "model": config.MODEL_TEXT, "temperature": 0.2, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": user}, ], } if json_mode: payload["response_format"] = {"type": "json_object"} response = requests.post( f"{config.MISTRAL_URL}/chat/completions", headers={"Authorization": f"Bearer {config.MISTRAL_KEY}", "Content-Type": "application/json"}, json=payload, timeout=120, ) response.raise_for_status() return response.json()["choices"][0]["message"]["content"] def parse_tasks(text: str) -> dict: system = prompts.TASKS_SYSTEM.format( calendar=prompts.date_table(datetime.date.today()) ) try: return json.loads(ask(system, text)) except json.JSONDecodeError: return {"tasks": [], "questions": ["Не удалось разобрать сообщение."]} ``` ![Що бачить менеджер після того, як продиктував доручення](https://blog.krasovskiy.team/wp-content/uploads/2026/07/uk-04-dialog.png) ## Крок 10. Запис завдань у таблицю Файл `sheets.py`: ```python import datetime import gspread from google.oauth2.service_account import Credentials import config SCOPES = ["https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/drive"] _book = None def book(): global _book if _book is None: creds = Credentials.from_service_account_file( config.GOOGLE_CREDS, scopes=SCOPES) _book = gspread.authorize(creds).open(config.SHEET_NAME) return _book def add_tasks(tasks: list) -> int: sheet = book().worksheet("Задачи") today = datetime.date.today().isoformat() rows = [[today, task.get("title", ""), task.get("type", "прочее"), task.get("channel") or "", task.get("due") or "", task.get("priority", 3), "новая", task.get("note", "")] for task in tasks] if rows: sheet.append_rows(rows, value_input_option="USER_ENTERED") return len(rows) def tasks_for(date_iso: str) -> list: rows = book().worksheet("Задачи").get_all_records() return [row for row in rows if str(row.get("Срок", "")).startswith(date_iso) and row.get("Статус") != "готово"] def placements() -> list: return book().worksheet("Размещения").get_all_records() ``` Підключаємо до бота. Замінюємо обробник голосових на повний: ```python import brain import sheets PRIORITY_MARK = {1: "СРОЧНО", 2: "обычный", 3: "фон"} async def handle_text(message: Message, text: str): result = brain.parse_tasks(text) tasks = result.get("tasks", []) questions = result.get("questions", []) if tasks: sheets.add_tasks(tasks) lines = ["Записал в таблицу:", ""] for task in tasks: mark = PRIORITY_MARK.get(task.get("priority", 3), "фон") due = f", срок {task['due']}" if task.get("due") else "" channel = f" [{task['channel']}]" if task.get("channel") else "" lines.append(f"{mark}: {task['title']}{channel}{due}") await message.answer("n".join(lines)) if questions: await message.answer("Уточните:n" + "n".join(f"- {q}" for q in questions)) if not tasks and not questions: await message.answer("Задач не нашёл.") @dp.message(F.voice) async def on_voice(message: Message): if not allowed(message): return note = await message.answer("Слушаю...") file = await bot.get_file(message.voice.file_id) buffer = await bot.download_file(file.file_path) text = stt.transcribe(buffer.read()) await note.edit_text(f"Расшифровка:nn{text}") await handle_text(message, text) @dp.message(F.text & ~F.text.startswith("/")) async def on_text(message: Message): if not allowed(message): return await handle_text(message, message.text) @dp.message(Command("today")) async def cmd_today(message: Message): if not allowed(message): return today = datetime.date.today().isoformat() rows = sheets.tasks_for(today) if not rows: await message.answer("На сегодня задач нет.") return lines = ["Задачи на сегодня:", ""] for row in sorted(rows, key=lambda r: r.get("Приоритет", 3)): lines.append(f"- {row['Задача']} [{row.get('Канал', '')}]") await message.answer("n".join(lines)) ``` На початок `bot.py` додаємо `import datetime`. **Перевірка кроку:** надсилаємо голосове з трьома дорученнями одразу. Бот надсилає розшифровку, потім список завдань, а в таблиці на аркуші «Завдання» з’являються три нові рядки. Ось так виглядає реальна відповідь моделі на голосове з трьох доручень — це результат живої перевірки, а не приклад з голови: ``` { "tasks": [ {"title": "Спросить цену на 1/24 и свободные слоты на следующую неделю", "type": "переговоры", "channel": "Нейросети и деньги", "due": null, "priority": 3, "note": "Формат: 1/24"}, {"title": "Снять охват по вчерашнему посеву", "type": "замер_24ч", "channel": "DigitalBourse", "due": "2026-07-24", "priority": 1, "note": ""}, {"title": "Оплатить размещение", "type": "оплата", "channel": "Telegram Pro IT", "due": null, "priority": 1, "note": "2000 рублей, кошелёк в закрепе"} ], "questions": [] } ``` ## Крок 11. Нагадування Найцінніша функція. Замір, не знятий вчасно, відновити неможливо. Файл `reminders.py`: ```python import datetime import sheets MANAGER_CHAT = None # подставляется при запуске def _date(value: str): try: return datetime.date.fromisoformat(str(value)[:10]) except ValueError: return None def build_digest() -> str: today = datetime.date.today() out_today, need_24h, need_7d = [], [], [] for row in sheets.placements(): published = _date(row.get("Дата выхода")) if not published: continue channel = row.get("Канал", "без названия") if published == today and row.get("Статус") != "вышло": out_today.append(f"- {channel}: сегодня выход, проверить") if published + datetime.timedelta(1) == today and not row.get("Охват 24ч"): need_24h.append(f"- {channel}: снять охват за сутки") if published + datetime.timedelta(7) == today and not row.get("Подписчиков 7д"): need_7d.append(f"- {channel}: снять подписчиков по инвайт-ссылке") unpaid = [f"- {row.get('Канал')}: хеш {row.get('Хеш')}" for row in sheets.book().worksheet("Платежи").get_all_records() if str(row.get("Хеш отправлен", "")).strip().lower() in ("", "нет")] blocks = [] if out_today: blocks.append("ВЫХОД СЕГОДНЯn" + "n".join(out_today)) if need_24h: blocks.append("ЗАМЕР ЗА СУТКИn" + "n".join(need_24h)) if need_7d: blocks.append("ЗАМЕР ЗА НЕДЕЛЮn" + "n".join(need_7d)) if unpaid: blocks.append("ХЕШИ НЕ ОТПРАВЛЕНЫ АДМИНАМn" + "n".join(unpaid)) return "nn".join(blocks) ``` Підключаємо розклад у `bot.py`: ```python from apscheduler.schedulers.asyncio import AsyncIOScheduler import reminders async def send_digest(): text = reminders.build_digest() if not text: return for user_id in config.ALLOWED_IDS: try: await bot.send_message(user_id, "Сводка на сегодняnn" + text) except Exception as error: logging.warning("Не отправилось %s: %s", user_id, error) async def main(): scheduler = AsyncIOScheduler(timezone="Europe/Kyiv") scheduler.add_job(send_digest, "cron", hour=10, minute=0) scheduler.add_job(send_digest, "cron", hour=18, minute=0) scheduler.start() await dp.start_polling(bot) ``` Часовий пояс ставимо свій. Зведення приходить двічі на день: вранці — план, увечері — що не закрито. **Перевірка кроку:** тимчасово змінюємо час завдання на найближчу хвилину, перезапускаємо бота, чекаємо. Зведення має прийти. Потім повертаємо нормальний час. ## Крок 12. Аналіз каналу перед закупкою Читати чужі канали звичайний бот не може — Telegram не дає ботам доступ до каналів, де вони не адмін. Потрібен доступ від імені користувача. Отримуємо ключі: > СКРІН 6: сторінка my.telegram.org з полями api_id та api_hash. **Попередження, яке не можна пропускати.** Такий доступ працює від вашого особистого акаунта. Читати публічні канали та рахувати статистику — безпечно. **Розсилати повідомлення незнайомим людям з нього не можна.** Масова розсилка незнайомим — прямий тригер антиспаму: спочатку заборона писати тим, кого немає в контактах, потім блокування акаунта разом з усією перепискою щодо закупівель. Повідомлення адмінам надсилає людина вручну. Завжди. Файл `channels.py`: ```python import statistics from telethon.sync import TelegramClient import config def analyze(username: str, limit: int = 50) -> dict: """Считает статистику публичного канала по последним постам.""" with TelegramClient("assistant_session", int(config.TG_API_ID), config.TG_API_HASH) as client: entity = client.get_entity(username) views, reactions, ad_posts = [], [], 0 for post in client.iter_messages(entity, limit=limit): if post.views: views.append(post.views) count = 0 if getattr(post, "reactions", None): count = sum(r.count for r in post.reactions.results) reactions.append(count) text = (post.text or "").lower() if any(word in text for word in ("реклама", "партнёр", "erid", "промокод")): ad_posts += 1 subscribers = getattr(entity, "participants_count", 0) or 0 if not views: return {"error": "Нет постов с просмотрами. Канал закрыт или пуст."} average = statistics.mean(views) spread = (statistics.pstdev(views) / average * 100) if average else 0 engagement = (statistics.mean(reactions) / average * 100) if average else 0 return { "название": getattr(entity, "title", username), "подписчиков": subscribers, "средний охват": round(average), "охват к подписчикам, %": round(average / subscribers * 100, 1) if subscribers else 0, "ER, %": round(engagement, 2), "разброс просмотров, %": round(spread, 1), "рекламных постов из выборки": ad_posts, } def verdict(stats: dict) -> list: """Красные флаги. Считаются по правилам регламента.""" flags = [] if stats.get("ER, %", 0) < 1: flags.append("ER ниже 1 процента — аудитория мёртвая или накрученная") if stats.get("охват к подписчикам, %", 0) < 10: flags.append("Охват меньше 10 процентов от подписчиков — канал выгорел") if stats.get("разброс просмотров, %", 100) < 8: flags.append("Просмотры у всех постов почти одинаковые — признак накрутки") if stats.get("рекламных постов из выборки", 0) > 15: flags.append("Слишком много рекламы — наш пост утонет") return flags ``` Про розкид переглядів: у живого каналу одні пости заходять, інші ні, і розкид великий. Якщо у всіх постів перегляди майже збігаються — їх накручують ботами до однакового значення. Це найнадійніша ознака, її складніше підробити, ніж сам охоплення. Команда в `bot.py`: ```python import channels @dp.message(Command("channel")) async def cmd_channel(message: Message): if not allowed(message): return parts = message.text.split(maxsplit=1) if len(parts) < 2: await message.answer("Формат: /channel @username") return await message.answer("Считаю...") stats = channels.analyze(parts[1].strip()) if "error" in stats: await message.answer(stats["error"]) return lines = [f"{key}: {value}" for key, value in stats.items()] flags = channels.verdict(stats) if flags: lines += ["", "КРАСНЫЕ ФЛАГИ:"] + [f"- {flag}" for flag in flags] else: lines += ["", "Явных признаков накрутки нет."] await message.answer("n".join(lines)) ``` **Перший запуск потребуватиме входу.** Telethon попросить номер телефону та код із Telegram — один раз, прямо в консолі сервера. Після цього поруч з’явиться файл `assistant_session.session`, і вхід більше не знадобиться. Цей файл рівнозначний доступу до акаунта, його не можна копіювати та викладати. ## Крок 13. Чернетка рекламного поста Додаємо в `prompts.py`: ``` DRAFT_SYSTEM = """Ты пишешь рекламный пост для размещения в чужом Telegram-канале. Отвечаешь только текстом поста, без пояснений и без markdown. Требования: 1. Первая строка — крючок под аудиторию именно этого канала, а не общий. 2. Дальше два-четыре предложения по сути: что человек получит. 3. Одно конкретное доказательство: цифра, срок или результат. Не выдумывай цифры, бери только из брифа. Если цифр в брифе нет — обойдись без них. 4. Призыв к действию и ссылка. Ссылка ровно одна, вставь её как есть. 5. Объём — до 700 знаков. Пост должен читаться с телефона без раскрытия. 6. Подстройся под манеру канала из примеров ниже: длина фраз, обращение на ты или на вы, наличие заголовков. Не копируй их темы, копируй интонацию. 7. Без эмодзи. Без канцелярита. Без обещаний, которых нет в брифе.""" DRAFT_USER = """Бриф продукта: {brief} Ссылка: {link} Примеры постов канала, под который пишем: {samples}""" ``` У `brain.py`: ```python def draft_ad(brief: str, link: str, samples: list) -> str: joined = "nn---nn".join(samples[:5]) user = prompts.DRAFT_USER.format(brief=brief, link=link, samples=joined) return ask(prompts.DRAFT_SYSTEM, user, json_mode=False) ``` У `channels.py` — функція для прикладів постів: ```python def samples(username: str, limit: int = 10) -> list: with TelegramClient("assistant_session", int(config.TG_API_ID), config.TG_API_HASH) as client: return [post.text for post in client.iter_messages(username, limit=limit) if post.text and len(post.text) > 120] ``` Команда працює у два кроки. У `bot.py`: ```python from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup class Draft(StatesGroup): waiting = State() @dp.message(Command("draft")) async def cmd_draft(message: Message, state: FSMContext): if not allowed(message): return await state.set_state(Draft.waiting) await message.answer( "Пришлите одной строкой: @канал | бриф продукта | ссылкаnn" "Пример:n@ai_channel | сервис делает 3D-модели из фото | https://example.com" ) @dp.message(Draft.waiting) async def do_draft(message: Message, state: FSMContext): await state.clear() parts = [p.strip() for p in message.text.split("|")] if len(parts) < 3: await message.answer("Нужно три части через | ") return channel, brief, link = parts[0], parts[1], parts[2] await message.answer("Читаю канал и пишу черновик...") samples = channels.samples(channel) text = brain.draft_ad(brief, link, samples) await message.answer("Черновик. Проверьте факты перед отправкой админу:nn" + text) ``` Диспетчер зі станами потребує сховища. Змінюємо створення диспетчера на початку файлу: ```python from aiogram.fsm.storage.memory import MemoryStorage dp = Dispatcher(storage=MemoryStorage()) ``` **Чернетка — це чернетка.** Модель може написати красиво і при цьому перекрутити факт. Перед відправкою адміну текст читає людина. Це не перестраховка: цифра, якої немає в брифі, в рекламі означає претензію до вас, а не до бота. ![Боту достатньо одного права — публікувати](https://blog.krasovskiy.team/wp-content/uploads/2026/07/uk-06-rights.png) ## Крок 14. Публікація у свій канал Тут усе безпечно: бот публікує в канал, де він адміністратор. Особистий акаунт не задіяний, ризику блокування немає. > СКРІН 7: вікно прав адміністратора з увімкненою лише публікацією. ``` @dp.message(Command("post")) async def cmd_post(message: Message): if not allowed(message): return text = message.text.split(maxsplit=1) if len(text) < 2: await message.answer("Формат: /post текст поста") return await bot.send_message(config.CHANNEL_ID, text[1]) await message.answer("Опубликовано.") ``` **Якщо приходить помилка `chat not found`** — бот не доданий до каналу або username вказано з помилкою. **`not enough rights`** — не надано право публікації. ## Крок 15. Автозапуск і логи Поки бот запущений вручну, він помре разом із закриттям SSH. Ставимо його в автозапуск. Виходимо в root (`exit` з-під користувача `assistant`) і створюємо файл служби: ``` nano /etc/systemd/system/tg-assistant.service ``` ``` [Unit] Description=Telegram assistant After=network-online.target [Service] Type=simple User=assistant WorkingDirectory=/home/assistant/tg-assistant ExecStart=/home/assistant/tg-assistant/venv/bin/python bot.py Restart=always RestartSec=10 [Install] WantedBy=multi-user.target ``` Увімкнемо: ``` systemctl daemon-reload systemctl enable --now tg-assistant systemctl status tg-assistant ``` У статусі має бути `active (running)`. Корисні команди на кожен день: ``` journalctl -u tg-assistant -f # смотреть логи вживую systemctl restart tg-assistant # перезапустить после правок journalctl -u tg-assistant --since today | grep -i error # ошибки за сегодня ``` `Restart=always` означає, що після збою або перезавантаження сервера бот підніметься сам. ![Сім етапів закупівлі. Рожевим — те, що губиться без бота](https://blog.krasovskiy.team/wp-content/uploads/2026/07/uk-07-stages.png) ## Частина 4. Фінальна перевірка Проходимо за списком. Кожен пункт має відпрацювати. Якщо всі десять пройшли — асистент зібраний. ## Частина 5. Типові помилки СимптомПричинаЩо робитиБот мовчить, у логах порожньоХостинг блокує api.telegram.orgПеревірка з кроку 3, міняти сервер`Unauthorized` при стартіНевірний або відкликаний токенВзяти новий у BotFather через `/revoke``SpreadsheetNotFound`Сервісному акаунту не надано доступ до таблиціКрок 5, пункт 6`WorksheetNotFound`Назва аркуша не збігаєтьсяЗвірити літеру в літеру, включаючи регістрЗадачі з невірними датамиМодель рахує дати самаПеревірити, що календар підставляється в промтНазва каналу обрізанаЗ промту прибрано приклад до правила 3Повернути прикладБот вигадує задачіПрибрано правило 8Повернути правило`FloodWait` від TelethonЗанадто частий аналіз каналівЧекати вказаний час, ставити паузи між запитамиБот відповідає «Немає доступу»ID не в `ALLOWED_IDS`Додати ID і перезапустити службуБот зник після перезавантаженняНе ввімкнено автозапуск`systemctl enable --now tg-assistant` ## Частина 6. Кейс: подібна система в роботі Усе, що описано вище, — не теорія. У нас працює система тієї ж архітектури, тільки навколо контенту, а не закупівлі. Корисна вона тут тим, що показує, що ламається на дистанції. **З чого складається:** **Три речі, які довелося лагодити вже в роботі. Закладіть їх одразу.** **Чого система принципово не робить:** не пише незнайомим людям від особистого акаунта. Це обмеження не технічне, а усвідомлене, з причин із кроку 12. ## Частина 7. Що далі Асистент зібраний, але поки що приймає завдання та рахує. Далі його нарощують під процес: Порядок нарощування визначається тим, що найчастіше втрачається в роботі. Починати варто з автозаповнення охоплень — це найрутинніша й найзабутіша операція. ## Додаток. Усі промти одним списком Промти винесені в `prompts.py`, щоб їх можна було редагувати без змін коду. Змінювати їх — нормально, але після кожної правки проганяйте перевірку з частини 4, пункти 2-4: промт легко зіпсувати однією формулюванням.