Покрокова інструкція: як зібрати бота, який приймає голосові повідомлення, перетворює їх на завдання, веде таблицю закупівель, нагадує про заміри, аналізує канали перед покупкою та пише чернетки рекламних постів.
Інструкція розрахована на людину, яка раніше не запускала ботів. Усі команди копіюються повністю. Якщо крок не вдався — в кінці кожного розділу є перевірка та розбір типових помилок.
Що потрібно мати до початку:
- акаунт Telegram
- будь-який VPS на Ubuntu 22.04 або новіше (вистачить найдешевшого тарифу, 1 ядро та 1 ГБ пам’яті)
- акаунт Google (для таблиці)
- близько 40 хвилин часу
Скільки коштуватиме на місяць: сервер — від 4 доларів; Telegram — безкоштовно; Google Sheets — безкоштовно; розпізнавання голосу та розбір завдань — менше долара за звичайного навантаження.
Важливо про вибір сервера. Частина хостингів (особливо шеред-хостинги для сайтів) блокують вихідні з’єднання до api.telegram.org. На такому сервері бот не працюватиме, і зрозуміти це складно — він просто мовчить. Перевірка однією командою є в кроці 3.

Частина 1. Процес, який ми автоматизуємо
Бот марний, якщо незрозуміло, яку роботу він підтримує. Нижче — коротка версія процесу закупівлі. Повна версія з усіма шаблонами повідомлень, формулами та правилами безпеки лежить в окремому документі «Регламент: закупівля реклами в Telegram-каналах», його потрібно прочитати до початку роботи.
Закупівля складається з семи етапів:
- Відбір каналу. Дивимося не на кількість підписників, а на середній охоплення поста та залученість. Відсіюємо накрутку.
- Переговори. Пишемо адміну: продукт, формат, прохання назвати ціну, вільні дати та гаманець.
- Оплата. Платимо, записуємо хеш транзакції, надсилаємо хеш адміну в Telegram. Тільки нові хеші, повторно — ніколи.
- Креатив і посилання. Створюємо унікальне запрошувальне посилання до оплати, готуємо пост, надсилаємо адміну.
- Контроль виходу. Перевіряємо в узгоджений час, забираємо посилання на пост, стежимо, щоб посилання в тексті не вирізали.
- Заміри. Через 24 години — охоплення. Через 7 днів — скільки людей вступило за унікальним посиланням.
- Звіт. Рахуємо ціну підписника, виносимо вердикт: повторювати, торгуватися чи в чорний список.
Три місця тут ламаються найчастіше, і саме їх закриває бот:
- забули створити унікальне посилання до розміщення — прихід ні з чим пов’язати;
- не зняли замір вчасно — цифри змішалися з іншими джерелами і відновити їх неможливо;
- загубили, які хеші вже надіслані адмінам.
Бот бере на себе пам’ять і облік. Рішення та листування з людьми залишаємо людині.
Частина 2. Підготовка
Крок 1. Створюємо бота
Відкриваємо Telegram, у пошуку знаходимо @BotFather — офіційний бот Telegram для створення ботів. У справжнього BotFather є позначка верифікації, у підробок її немає.
- Натискаємо «Запустити».
- Надсилаємо команду
/newbot. - BotFather запитає ім’я бота — це те, що видно в заголовку чату. Наприклад:
Асистент закупівлі. - Потім запитає username — він має закінчуватися на
botі бути вільним. Наприклад:zakupka_assist_bot. - У відповідь надходить токен виду
8123456789:AAF...— довгий рядок.
СКРІН 1: діалог з BotFather, стрілка на рядок з токеном.
Токен — це пароль від бота. Хто ним володіє, той керує ботом. Не надсилаємо його в загальні чати, не комітимо в репозиторій. Якщо випадково засвітили — у BotFather команда /revoke випускає новий.
Відразу налаштовуємо приватність, щоб бот не збирав зайвого:
/setprivacy→ вибрати бота →Enable/setdescription→ короткий опис/setcommands→ надіслати блоком:
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 (потрібен завжди):
- Відкриваємо
console.mistral.ai, реєструємося. - Розділ API Keys → Create new key.
- Копіюємо ключ. Показується він один раз.
СКРІН 3: сторінка API Keys, стрілка на кнопку створення ключа.
Ключ OpenAI (рекомендується, для розпізнавання):
- Відкриваємо
platform.openai.com, розділ API keys → Create new secret key. - Копіюємо ключ, поповнюємо баланс на мінімальну суму.
Розпізнавання коштує близько половини цента за хвилину мови. При двадцяти голосових на день це менше долара на місяць. Чому рекомендується саме він — у кроці 8, там виміри на одному й тому самому файлі.
Якщо платити за другий сервіс не хочеться — працює й на одному ключі Mistral, а також є повністю безкоштовний варіант без зовнішніх сервісів взагалі. Обидва описані там само.

Крок 5. Готуємо таблицю
Таблиця — це база даних асистента. Структура повторює регламент.
Створюємо Google-таблицю з назвою Закупка Telegram і п’ятьма аркушами. Назви аркушів мають збігатися буква в букву:
Аркуш Задачі — перший рядок:
Дата создания | Задача | Тип | Канал | Срок | Приоритет | Статус | Заметка
Аркуш Канали:
Название | Ссылка | Тематика | Подписчиков | Средний охват | ER % | Админ | Кошелёк | Прайс | Статус | Последний контакт | Заметки
Аркуш Розміщення:
ID | Канал | Дата брони | Дата выхода | Формат | Цена | Валюта | Кошелёк | Хеш | Хеш отправлен | Креатив | Инвайт-ссылка | Ссылка на пост | Статус | Охват 24ч | Подписчиков 7д | CPM | CPS | Вердикт
Аркуш Платежі:
Дата | Сумма | Валюта | Кошелёк | Хеш | Канал | Хеш отправлен
Аркуш Креативи:
ID | Текст | Медиа | Где использовался | Лучший CPS | Заметки
Тепер надаємо боту доступ до цієї таблиці.
- Відкриваємо
console.cloud.google.com, створюємо проект (назва будь-яка). - У пошуку зверху вводимо
Google Sheets API→ Enable. Те саме дляGoogle Drive API. - Зліва IAM і адміністрування → Сервісні акаунти → Створити сервісний акаунт. Ім’я будь-яке, ролі не потрібні, тиснемо «Готово».
- Відкриваємо створений акаунт → вкладка Ключі → Додати ключ → Створити ключ → тип JSON. Файл завантажиться автоматично.
- Відкриваємо завантажений файл, знаходимо рядок
"client_email"— там адреса видуім’я@проект.iam.gserviceaccount.com. - Повертаємося до таблиці, тиснемо «Налаштування доступу» та додаємо цю адресу як Редактора.
СКРІН 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:
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:
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.
Якщо відповіді немає:
- у консолі
Unauthorized— невірний токен, перевіряємо.env; - бот мовчить і в логах порожньо — сервер не бачить Telegram, повертаємося до перевірки з кроку 3;
- бот відповідає «Немає доступу» — свій ID не потрапив до
ALLOWED_IDS.

Крок 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:
openai— рекомендований. Найкраща якість на термінах, близько половини цента за хвилину мови.mistral— один ключ на весь проєкт, дешевше. Терміни виправляються пізніше, правилом 6 у промті розбору, тому варіант повністю робочий.local— безкоштовно, без зовнішніх сервісів, мова не залишає вашого сервера. Беріть його, якщо у голосових звучать суми, гаманці та домовленості, які не можна передавати назовні.
Чесність щодо безкоштовного варіанту. У вимірах вище він прогнаний на моделі 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:
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:
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:
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."""
Розбір кожного правила — навіщо воно потрібне:
- Правило 4 і календар. Мовні моделі погано рахують дати. У перевірці без готового календаря «у п’ятницю» перетворилося на 25 липня замість 24-го — помилка на добу, через яку замір був би зірваний. З підставленим календарем дата правильна. Ніколи не дозволяйте моделі рахувати дати самостійно.
- Правило 3 з прикладом. Без прикладу назва «Нейромережі та гроші» обрізалася до «Нейромережі», і завдання йшло не на той канал. Приклад у промті важливіший за формулювання правила.
- Правило 5 з останнім реченням. Без явної заборони оплата іноді отримувала третій пріоритет і тонула в списку.
- Правило 6. Лікує те саме «1.24» з розпізнавання.
- Правило 8. Без нього модель на будь-яку незрозумілу фразу вигадує завдання. Перевірено: з цим правилом на фразу «там по цьому каналу треба б щось вирішити» бот нічого не створює, а перепитує.
Файл brain.py:
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": ["Не удалось разобрать сообщение."]}

Крок 10. Запис завдань у таблицю
Файл sheets.py:
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()
Підключаємо до бота. Замінюємо обробник голосових на повний:
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:
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:
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 не дає ботам доступ до каналів, де вони не адмін. Потрібен доступ від імені користувача.
Отримуємо ключі:
- Відкриваємо
my.telegram.org, входимо за номером телефону. - API development tools → заповнюємо назву застосунку → Create application.
- Копіюємо
api_idтаapi_hashу.env.
СКРІН 6: сторінка my.telegram.org з полями api_id та api_hash.
Попередження, яке не можна пропускати. Такий доступ працює від вашого особистого акаунта. Читати публічні канали та рахувати статистику — безпечно. Розсилати повідомлення незнайомим людям з нього не можна. Масова розсилка незнайомим — прямий тригер антиспаму: спочатку заборона писати тим, кого немає в контактах, потім блокування акаунта разом з усією перепискою щодо закупівель. Повідомлення адмінам надсилає людина вручну. Завжди.
Файл channels.py:
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:
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:
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 — функція для прикладів постів:
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:
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)
Диспетчер зі станами потребує сховища. Змінюємо створення диспетчера на початку файлу:
from aiogram.fsm.storage.memory import MemoryStorage
dp = Dispatcher(storage=MemoryStorage())
Чернетка — це чернетка. Модель може написати красиво і при цьому перекрутити факт. Перед відправкою адміну текст читає людина. Це не перестраховка: цифра, якої немає в брифі, в рекламі означає претензію до вас, а не до бота.

Крок 14. Публікація у свій канал
Тут усе безпечно: бот публікує в канал, де він адміністратор. Особистий акаунт не задіяний, ризику блокування немає.
- Відкриваємо свій канал → Керування → Адміністратори → Додати адміністратора.
- Знаходимо бота за username, додаємо.
- Залишаємо лише право Публікація повідомлень, решту вимикаємо.
- У
.envвCHANNEL_IDпишемо@ім’я_каналу(для публічного) або числовий ID виду-100...(для приватного).
СКРІН 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 означає, що після збою або перезавантаження сервера бот підніметься сам.

Частина 4. Фінальна перевірка
Проходимо за списком. Кожен пункт має відпрацювати.
/start— приходить привітання.- Голосове з трьох доручень — приходить розшифровка, потім три задачі, у таблиці три нові рядки.
- Текстом «оплатити розміщення в каналі Х, 2000» — задача з типом «оплата» та першим пріоритетом.
- Нечітка фраза без конкретики — бот перепитує, задач не створює.
/today— показує задачі на сьогодні./channel @будь_який_публічний_канал— приходить статистика та червоні прапорці./draft @канал | бриф | посилання— приходить чернетка в стилі каналу./post перевірка— повідомлення з’являється у вашому каналі.systemctl restart tg-assistant— бот відповідає і після перезапуску.- Заповнюємо в аркуші «Розміщення» рядок з датою виходу вчорашнім днем, викликаємо зведення — приходить нагадування зняти охоплення.
Якщо всі десять пройшли — асистент зібраний.
Частина 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. Кейс: подібна система в роботі
Усе, що описано вище, — не теорія. У нас працює система тієї ж архітектури, тільки навколо контенту, а не закупівлі. Корисна вона тут тим, що показує, що ламається на дистанції.
З чого складається:
- Читалка каналів. Той самий Telethon з кроку 12. Забирає пости потрібних публічних каналів, за ними видно, які теми зараз обговорюють і що вже розібрано конкурентами.
- Черга тем. Зберігається в базі, а не в голові. Кожна тема має статус: чекає, у роботі, опублікована.
- Генератор текстів. Той самий принцип, що в кроці 13: промт з правилами тону та заборонами, на виході чернетка.
- Перевірка на дублі перед генерацією. Кожен готовий матеріал переводиться в числовий відбиток і складається в окрему базу. Перш ніж писати новий текст, система звіряє тему з уже написаними. Схожа є — тема не береться в роботу.
- Публікація ботом за розкладом — так само, як у кроці 14.
Три речі, які довелося лагодити вже в роботі. Закладіть їх одразу.
- Обмеження за часом публікації виявилося важливішим за якість текстів. Спочатку бот публікував, коли матеріал був готовий, — тобто іноді вночі й по два пости підряд. З боку це виглядає як спам-стрічка, незалежно від того, що написано. Зараз жорстке вікно: не раніше дев’ятої ранку, не пізніше десятої вечора, мінімум чотири з половиною години між постами. Якщо слот не підходить — публікація переноситься, а не скасовується.
- Без перевірки на дублі генератор ходить по колу. Через кілька десятків матеріалів він починає писати про одне й те саме різними словами. Виявляється це пізно, а розгрібається довго. Перевірка на схожість має з’явитися раніше, ніж накопичиться обсяг, — потім її впроваджувати болючіше.
- Людина на виході обов’язкова. Усе, що виходить назовні від нашого імені, читає людина. Окремо це стосується всього, що не можна перевірити очима: наприклад, озвучку роликів людина прослуховує до публікації, тому що синтез мовлення регулярно ламає вимову термінів і цифр — та сама хвороба, що
1.24у кроці 8, тільки в зворотний бік.
Чого система принципово не робить: не пише незнайомим людям від особистого акаунта. Це обмеження не технічне, а усвідомлене, з причин із кроку 12.
Частина 7. Що далі
Асистент зібраний, але поки що приймає завдання та рахує. Далі його нарощують під процес:
- Автозаповнення охоплень. Бот сам читає опублікований пост за посиланням із таблиці та проставляє перегляди через добу — без ручного введення.
- Розрахунок CPM та CPS. Формули в таблиці, бот надсилає тижневий звіт із вердиктами по кожному каналу.
- Чернетки повідомлень адмінам. Бот підставляє назву каналу, ціну та потрібний шаблон із регламенту, формує готовий текст. Надсилає людина.
- Контроль хешів. Окрема команда показує список невідправлених хешів одним повідомленням.
Порядок нарощування визначається тим, що найчастіше втрачається в роботі. Починати варто з автозаповнення охоплень — це найрутинніша й найзабутіша операція.
Додаток. Усі промти одним списком
Промти винесені в prompts.py, щоб їх можна було редагувати без змін коду. Змінювати їх — нормально, але після кожної правки проганяйте перевірку з частини 4, пункти 2-4: промт легко зіпсувати однією формулюванням.
TASKS_SYSTEM— розбір продиктованого на завдання. Крок 9.date_table— не промт, а підставлюваний календар. Без нього дати будуть невірними.DRAFT_SYSTEMтаDRAFT_USER— чернетка рекламного посту. Крок 13.

Андрій Красовський — програміст і дата-сайєнтист з досвідом у створенні складних автоматизованих систем на базі Python, Google Colab та n8n. Його експертиза охоплює побудову SEO-екосистем, інтеграцію API (Ahrefs, Google Ads, Search Console) та побудову пайплайнів для контенту. Андрій поєднує технічну точність із підприємницьким баченням, допомагаючи створювати рішення, що працюють на результат.