Опросы и голосования — одна из самых вовлекающих механик в 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_anonymous | true — Telegram не отдаст имена голосовавших |
allows_multiple_answers | мультивыбор |
type="quiz" | викторина с правильным ответом |
correct_option_id | индекс правильного ответа в quiz |
open_period | автозакрытие через N сек |
close_date | unix-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 ошибок
- Используют
is_anonymous=True, потом не могут разобраться, кто голосовал. - Не закрывают опрос — голоса продолжают копиться через год.
- Делают кастомное голосование без блокировки повторного голоса — накручивают.
- Хранят голос в
callback_data— лимит 64 байта, выходят за пределы. - Не дублируют выбор юзера в
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 не отдаёт пер-юзерные данные.