Legan Studio
Все статьи
~ 4 мин чтения

Бот для голосований и опросов в Telegram

Как собрать бот с голосованиями: нативные poll, кастомные опросы с кнопками, защита от накруток и интеграция с Mini App для сложных голосований.

  • Telegram
  • опросы
  • polls
  • голосование

Опросы и голосования — одна из самых вовлекающих механик в Telegram. Нативные Poll от Bot API закрывают 70% задач, а для сложных голосований (с верификацией, рейтингами, ранжированием) подключают кастомные клавиатуры и Mini App. Разберём оба пути и покажем, как защититься от накруток.

Нативные опросы: sendPoll

Самый быстрый способ — sendPoll:

from aiogram import Bot
from aiogram.types import InputPollOption

await bot.send_poll(
    chat_id=CHAT_ID,
    question="Какой день для встречи удобнее?",
    options=[
        InputPollOption(text="Понедельник"),
        InputPollOption(text="Вторник"),
        InputPollOption(text="Среда"),
    ],
    is_anonymous=False,         # нужно знать, кто как голосовал
    allows_multiple_answers=False,
    type="regular",             # или "quiz" с правильным ответом
    open_period=3600,           # автозакрытие через час
    explanation="Спасибо за голос!",
)

Параметры:

ПараметрЧто делает
is_anonymoustrue — Telegram не отдаст имена голосовавших
allows_multiple_answersмультивыбор
type="quiz"викторина с правильным ответом
correct_option_idиндекс правильного ответа в quiz
open_periodавтозакрытие через N сек
close_dateunix-timestamp закрытия

Получение результатов

Если is_anonymous=False, Telegram присылает poll_answer на каждый голос:

from aiogram import Router, F
from aiogram.types import PollAnswer

router = Router()

@router.poll_answer()
async def on_vote(answer: PollAnswer):
    await votes.upsert(
        poll_id=answer.poll_id,
        user_id=answer.user.id,
        option_ids=answer.option_ids,  # список выбранных индексов
    )

Если is_anonymous=True, бот видит только агрегированные результаты через апдейт poll (когда меняется состав опроса).

Закрытие и финальные результаты

poll = await bot.stop_poll(chat_id=CHAT_ID, message_id=MSG_ID)
print(poll.total_voter_count)
for opt in poll.options:
    print(opt.text, opt.voter_count)

После stop_poll опрос становится read-only, новые голоса не принимаются.

Кастомные опросы на inline-кнопках

Когда нативного Poll мало (нужны изображения, ранжирование, мульти-этапы) — рисуем своё через inline-кнопки:

from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton

def vote_kb(poll_id: str, options: list[Option]):
    rows = [
        [InlineKeyboardButton(
            text=f"{o.text}  ({o.votes})",
            callback_data=f"vote:{poll_id}:{o.id}",
        )]
        for o in options
    ]
    return InlineKeyboardMarkup(inline_keyboard=rows)


@router.callback_query(F.data.startswith("vote:"))
async def cast_vote(cb: CallbackQuery):
    _, poll_id, opt_id = cb.data.split(":")
    if await votes.has_voted(cb.from_user.id, poll_id):
        await cb.answer("Вы уже голосовали", show_alert=True)
        return
    await votes.add(poll_id, opt_id, cb.from_user.id)
    await cb.message.edit_reply_markup(reply_markup=vote_kb(poll_id, await get_options(poll_id)))
    await cb.answer("Голос учтён!")

Защита от накруток

Главная проблема голосований в Telegram — накрутка через многоаккаунтинг. Меры защиты:

ЗащитаЭффективность
Лимит «один tg_id — один голос»базовая, ловит 60%
Требование подписки на канал перед голосом+20% защиты
Минимальный возраст аккаунта (>30 дней)+10%
Каптча перед голосованием+5%, но падает CR
Биометрия (Mini App)сильная, но сложно
Telegram Passport+99% защиты, для серьёзных голосований

Простейший антифрод:

async def can_vote(user_id: int) -> bool:
    if user_id > 8_000_000_000:  # очень свежий аккаунт
        return False
    member = await bot.get_chat_member(CHANNEL_ID, user_id)
    if member.status in ("left", "kicked"):
        return False
    return True

Голосование в Mini App

Для сложных кейсов (рейтинг 50 проектов, ранжирование, многотуровое голосование) — Mini App на React.

function VotePage({ poll }: { poll: Poll }) {
  const [ranking, setRanking] = useState<string[]>([]);
  const submit = async () => {
    await api.post(`/polls/${poll.id}/vote`, {
      ranking,
      initData: window.Telegram.WebApp.initData,
    });
    window.Telegram.WebApp.close();
  };
  return (
    <DragDropList
      items={poll.options}
      onChange={setRanking}
    />
  );
}

initData валидируется на бэкенде — это даёт идентификацию юзера без отдельного логина.

Кейсы

  • Корпоративные голосования — выбор тимлида, награды квартала, лучшие проекты.
  • Конференции — голосование за доклады, Q&A с приоритизацией.
  • Школы — выбор представителя класса, опросы по учебным программам.
  • Сообщества — выбор контента следующего месяца, тема стрима.
  • Геймифицированный контент — голосование за фото недели в канале.

Аналитика и экспорт

Минимальный отчёт после голосования:

async def export_poll(poll_id: str) -> bytes:
    rows = await db.fetch(
        """
        SELECT u.username, u.full_name, o.text, v.created_at
        FROM votes v
        JOIN options o ON v.option_id = o.id
        JOIN users u ON v.user_id = u.id
        WHERE v.poll_id = $1
        ORDER BY v.created_at
        """,
        poll_id,
    )
    return rows_to_xlsx(rows)

Экспорт в Excel — must-have для корпоративных кейсов.

Топ-5 ошибок

  1. Используют is_anonymous=True, потом не могут разобраться, кто голосовал.
  2. Не закрывают опрос — голоса продолжают копиться через год.
  3. Делают кастомное голосование без блокировки повторного голоса — накручивают.
  4. Хранят голос в callback_data — лимит 64 байта, выходят за пределы.
  5. Не дублируют выбор юзера в editMessageReplyMarkup — UI не обновляется, юзер думает, что голос не учтён.

Итого

Для простых голосований нативный sendPoll — оптимум: 5 минут на интеграцию, нативный UX. Для сложных кейсов с защитой от накруток, ранжированием и красивой аналитикой — Mini App + кастомные кнопки + антифрод-стек. Telegram даёт сильные инструменты идентификации (Passport, биометрия), которые позволяют делать корпоративные голосования с уровнем доверия не хуже специализированных платформ.

Частые вопросы

Какой максимум опций в нативном Poll?

До 10 опций, текст каждой — до 100 символов. Если нужно больше — делайте кастомный опрос на inline-кнопках или Mini App. Inline-клавиатура поддерживает до 100 кнопок, но удобство падает: уже после 8 строк юзеру неудобно скроллить.

Можно ли отредактировать опрос после публикации?

Нет, ни вопрос, ни опции изменить нельзя. Можно только закрыть через stopPoll. Если нашли ошибку — закрывайте старый, публикуйте новый и в комментарии укажите причину. Поэтому проверяйте текст 3 раза перед sendPoll.

Как сделать голосование «один голос — один человек» с проверкой?

Самый надёжный способ — Telegram Passport с верификацией паспорта. Один паспорт = один аккаунт, накрутка невозможна без копирования физического документа. Полегче — KYC по телефону через requestContact с проверкой номера на дубликаты. Совсем легкий — tg_id + проверка возраста аккаунта + членство в канале.

Можно ли запустить опрос в нескольких чатах одновременно?

Каждый sendPoll создаёт независимый опрос — голоса не суммируются между чатами. Если нужно общее голосование, делайте кастомное на кнопках с центральной БД или Mini App. Тогда юзеры из разных чатов кладут голоса в один пул.

Сколько голосований может проводиться одновременно?

Технически у бота нет ограничений. Практически лучше держать 1–2 активных опроса на чат, иначе юзеры теряются в скроллбаре. Закрывайте старые сразу после окончания и архивируйте результаты в канал.

Как считать частичные результаты в реальном времени?

В нативных не-анонимных опросах — через poll_answer обновляйте свой счётчик, и рисуйте дашборд (например, в Mini App). Для опросов на кнопках обновляйте editMessageReplyMarkup после каждого голоса с актуальными цифрами в тексте кнопок. Минус — дёргаете API на каждый клик; при 1000 голосах в минуту получите 429.

Можно ли выгрузить голоса в CSV?

Да, для не-анонимных опросов и кастомных — без проблем. Бот пишет каждый голос в БД (poll_id, user_id, option_id, ts), и админ через команду /export <poll_id> получает Excel или CSV. Для анонимных нативных — никак, Telegram не отдаёт пер-юзерные данные.