Модель пользователя и настройки
Учим Цыплёнка узнавать каждого собеседника в лицо: завести таблицу настроек, сохранять выбор по telegram id и отвечать каждому по его вкусу.
Модель пользователя — это таблица в базе, где одна строка хранит всё, что бот знает про одного человека: его telegram id и выбранные им настройки (город, язык, нужны ли уведомления).
Зачем боту вообще тебя запоминать
Представь: ты открываешь любимый стриминг с музыкой, а он каждый раз спрашивает заново, какой жанр ты любишь и в какой стране живёшь. Бесит, правда? Хороший сервис помнит тебя — и сразу подсовывает то, что тебе нравится. С ботом ровно то же самое. Если Цыплёнок-помощник будет при каждом сообщении переспрашивать «а ты из какого города?», им никто не станет пользоваться дольше одного дня.
В прошлом уроке про SQLite для бота мы научились заводить базу и складывать туда строки. Теперь сделаем шаг вперёд: спроектируем нормальную таблицу настроек, чтобы Цыплёнок узнавал каждого собеседника по его telegram id и отвечал ему по-своему. Вот к чему придём — пользователь один раз говорит боту свой город, а дальше команда /погода просто берёт сохранённый город и не задаёт лишних вопросов.
Пользователь: /город Казань
Бот: Запомнил! Теперь буду показывать погоду для города Казань 🐥
(на следующий день)
Пользователь: /погода
Бот: В городе Казань сейчас +18°, можно гулять без куртки ☀️Результат: в чате бот сначала запоминает город, а на следующий день сам подставляет его, не переспрашивая. Между этими двумя сообщениями бот мог сто раз выключаться и включаться — настройка лежит в базе, а не в оперативной памяти, поэтому никуда не девается.
Что такое «модель пользователя» простыми словами
Слово «модель» звучит страшно, но за ним прячется очень простая мысль. Модель пользователя — это анкета, которую бот заводит на каждого человека. У анкеты есть графы: «кто это» (telegram id), «как зовут», «из какого города», «на каком языке отвечать», «слать ли напоминания». Все анкеты сложены в один ящик — таблицу. Каждая анкета — это одна строка таблицы.
Анкета на человека = одна строка таблицы. Графа анкеты = столбец. Ящик со всеми анкетами = таблица.
Почему именно таблица, а не обычный словарь Python? Ты ведь мог бы написать settings = {} и складывать туда настройки прямо в коде — и поначалу это даже работает. Но как только бот перезапустится (а он перезапускается каждый раз, когда ты правишь код, когда падает сервер или когда хостинг обновляет твоё приложение), словарь в памяти исчезнет, и все настройки всех людей пропадут. База данных — это память, которая переживает перезапуск: ты пишешь в файл на диске, и он остаётся на месте, что бы ни случилось с программой. Поэтому профиль пользователя живёт именно в базе, а не в переменной.
Главная графа — telegram id. Это число, которое Telegram навсегда закрепляет за аккаунтом. Имя пользователя можно сменить, юзернейм тоже, а id — нет. Поэтому именно по нему мы и будем находить нужную анкету. Считай его как номер читательского билета: внешне человек меняется, а по номеру библиотека всегда найдёт именно его карточку. У тебя в школе так же устроен электронный дневник: учителя меняют классы и кабинеты, но твой номер в системе не меняется, и оценки всегда подтягиваются к тебе, а не к однофамильцу из параллели.
Где взять этот telegram id в aiogram
Каждое сообщение приходит вместе с информацией об отправителе. В aiogram 3.x внутри хэндлера это message.from_user.id. Давай сначала на маленьком чистом примере разберём, как из словаря с данными о человеке достать нужные поля — без всякого бота, просто на стандартном Python.
user = {
"id": 777123,
"first_name": "Аня",
"username": "anya_codes",
}
tg_id = user["id"]
name = user.get("first_name", "друг")
print("id:", tg_id)
print("имя:", name)Вывод:
id: 777123 имя: Аня
Обрати внимание на user.get("first_name", "друг"): если имени вдруг нет, мы получим запасное слово «друг», а не ошибку. Эта же привычка — всегда иметь запасной вариант — пригодится нам, когда пользователя ещё нет в базе.
Проектируем таблицу настроек
Прежде чем писать код, реши на бумаге, какие графы нужны. Не пихай в анкету всё подряд — бери только то, что бот реально использует в ответах. Для Цыплёнка-помощника возьмём пять полей.
| Столбец | Тип | Зачем |
| tg_id | INTEGER, первичный ключ | тот самый «номер билета», по нему ищем человека |
| name | TEXT | как обращаться к пользователю в ответах |
| city | TEXT | для какого города показывать погоду |
| lang | TEXT, по умолчанию ru | на каком языке отвечать |
| notify | INTEGER, по умолчанию 1 | слать ли напоминания (1 — да, 0 — нет) |
Почему tg_id — это первичный ключ (PRIMARY KEY)? Потому что у одного человека должна быть ровно одна анкета. Первичный ключ — это обещание базе: «в этом столбце значения не повторяются». Если попробуешь вставить второго пользователя с тем же tg_id, SQLite не даст и поднимет ошибку. Это нам только на руку — никаких дублей.
Заметь ещё одну хитрость: в SQLite нет отдельного типа «да/нет». Поэтому флажок notify мы храним числом: 1 значит «уведомления включены», 0 — «выключены». Так делают почти все: булево значение прячут в маленькое целое число.
И последний совет про проектирование: лучше иметь несколько узких графа с понятными именами, чем одну большую графу «всё про пользователя», куда свалено сразу и город, и язык, и ещё что-то через запятую. Когда поля разложены по отдельным столбцам, ты можешь обновить один город, не трогая язык, и можешь потом легко искать, скажем, всех, у кого notify = 1, чтобы разослать напоминания. Если же всё слеплено в одну строку, любая мелкая правка превращается в разбор текста — а это ровно та боль, от которой база нас и спасает.
SQL, который создаёт таблицу
CREATE TABLE IF NOT EXISTS users (
tg_id INTEGER PRIMARY KEY,
name TEXT,
city TEXT,
lang TEXT DEFAULT 'ru',
notify INTEGER DEFAULT 1
);Слова IF NOT EXISTS значат «создай таблицу, только если её ещё нет». Без них при втором запуске бота SQLite ругнётся, что таблица уже существует. А DEFAULT задаёт значение по умолчанию: если при добавлении строки мы не укажем язык, туда само подставится 'ru'.
Сохраняем и обновляем настройки
Теперь самое интересное. Когда пользователь говорит боту свой город, может быть две ситуации: его анкеты ещё нет (тогда надо создать строку) или анкета уже есть (тогда надо обновить поле). Писать два разных запроса и каждый раз проверять «а есть ли он уже?» — скучно и легко ошибиться. У SQLite есть приём, который делает это одной командой — UPSERT (от слов update + insert: «обнови, а если нечего обновлять — вставь»).
INSERT INTO users (tg_id, name) VALUES (?, ?)
ON CONFLICT(tg_id) DO UPDATE SET name = excluded.name;Читается так: «попробуй вставить нового пользователя; но если по tg_id уже есть строка (конфликт первичного ключа) — не падай с ошибкой, а просто обнови у него имя». Слово excluded — это та строка, которую мы пытались вставить; через неё мы берём новое значение. Удобно: один запрос работает и для новичка, и для старожила.
Пишем функции в bot.py
Подключаемся к той же базе, что и в прошлом уроке. Я вынесу работу с базой в отдельные маленькие функции — так код хэндлеров останется чистым. Эти функции добавляются в наш bot.py, рядом с уже существующими bot и dp.
import sqlite3
DB = "chicken.db"
def init_db():
with sqlite3.connect(DB) as db:
db.execute("""
CREATE TABLE IF NOT EXISTS users (
tg_id INTEGER PRIMARY KEY,
name TEXT,
city TEXT,
lang TEXT DEFAULT 'ru',
notify INTEGER DEFAULT 1
)
""")
def set_city(tg_id: int, name: str, city: str):
with sqlite3.connect(DB) as db:
db.execute(
"""
INSERT INTO users (tg_id, name, city) VALUES (?, ?, ?)
ON CONFLICT(tg_id) DO UPDATE SET city = excluded.city,
name = excluded.name
""",
(tg_id, name, city),
)
def get_user(tg_id: int):
with sqlite3.connect(DB) as db:
db.row_factory = sqlite3.Row
row = db.execute(
"SELECT tg_id, name, city, lang, notify FROM users WHERE tg_id = ?",
(tg_id,),
).fetchone()
return row # это None, если такого пользователя ещё нетРезультат: в чате этот код ничего не печатает — это три «помощника», которые умеют создать таблицу, сохранить город и достать анкету по id. Дальше мы позовём их из хэндлеров.
Разберём по шагам. Конструкция with sqlite3.connect(DB) as db: сама закрывает соединение и сохраняет изменения, когда блок заканчивается — не надо вручную звать commit() и close(). Знаки ? в запросе — это плейсхолдеры: вместо того чтобы вклеивать значения прямо в строку SQL, мы передаём их отдельным кортежем. Так делают всегда: это защищает от ошибок и от подмены запроса (про это подробнее ниже, в подводных камнях).
А db.row_factory = sqlite3.Row — приятная мелочь. Без неё fetchone() вернёт обычный кортеж, и к городу пришлось бы обращаться по номеру row[2] (попробуй вспомни через неделю, что там под индексом 2). С sqlite3.Row можно писать по-человечески: row["city"].
Хэндлер команды /город
from aiogram import F
from aiogram.filters import Command
from aiogram.types import Message
@dp.message(Command("город"))
async def cmd_city(message: Message):
parts = message.text.split(maxsplit=1)
if len(parts) < 2:
await message.answer("Напиши так: /город Казань")
return
city = parts[1].strip()
set_city(message.from_user.id, message.from_user.first_name, city)
await message.answer(f"Запомнил! Теперь буду показывать погоду для города {city} 🐥")Результат: в чате на сообщение /город Казань бот ответит «Запомнил! Теперь буду показывать погоду для города Казань 🐥», а в базе появится (или обновится) строка с этим пользователем. Команда /город без аргумента вежливо подскажет правильный формат.
Читаем настройки и применяем их в ответах
Сохранять данные бессмысленно, если их потом не использовать. Сделаем команду /погода, которая достаёт сохранённый город. И сразу заложим самый важный случай: пользователя ещё нет в базе. Функция get_user в таком случае вернёт None — и если мы попробуем достать из None поле city, бот упадёт с ошибкой. Поэтому проверку «а есть ли вообще анкета?» делаем первым делом.
@dp.message(Command("погода"))
async def cmd_weather(message: Message):
user = get_user(message.from_user.id)
if user is None or not user["city"]:
await message.answer("Я пока не знаю твой город. Напиши: /город Казань")
return
city = user["city"]
# настоящий запрос к API погоды добавим в следующем модуле,
# а пока просто покажем, что город подхватился
await message.answer(f"В городе {city} сейчас +18°, можно гулять без куртки ☀️")Результат: в чате, если город сохранён, бот ответит «В городе Казань сейчас +18°...»; если человек впервые пишет боту и города ещё нет, бот не упадёт, а мягко попросит сначала указать город. Заметь двойную проверку: user is None ловит «анкеты вообще нет», а not user["city"] ловит «анкета есть, но город в ней пустой».
Команда /настройки — показать всё разом
Полезно дать пользователю команду, которая покажет его текущие настройки. Здесь хорошо видно, как get_user возвращает целую анкету, а мы красиво её разворачиваем.
@dp.message(Command("настройки"))
async def cmd_settings(message: Message):
user = get_user(message.from_user.id)
if user is None:
await message.answer("Мы ещё не знакомы. Напиши /город, и я тебя запомню 🐥")
return
city = user["city"] or "не задан"
notify = "включены" if user["notify"] == 1 else "выключены"
await message.answer(
f"Твои настройки:\n"
f"Город: {city}\n"
f"Язык: {user['lang']}\n"
f"Уведомления: {notify}"
)Результат: в чате на /настройки бот покажет аккуратный список: город, язык и состояние уведомлений. Если города ещё нет, в строке будет «не задан» — мы заранее подставили запасной текст через or, чтобы не показывать пользователю пугающее слово None.
Заводим анкету сразу на /start
Есть приятный приём, который снимает половину проблем с «пользователя ещё нет в базе»: создавать пустую анкету в тот самый момент, когда человек впервые нажимает /start. Тогда дальше можно почти не бояться None — строка-то уже есть. Это называют регистрацией пользователя. Допишем хэндлер /start нашему Цыплёнку так, чтобы он заодно заводил анкету.
def register_user(tg_id: int, name: str):
with sqlite3.connect(DB) as db:
db.execute(
"""
INSERT INTO users (tg_id, name) VALUES (?, ?)
ON CONFLICT(tg_id) DO NOTHING
""",
(tg_id, name),
)
@dp.message(Command("start"))
async def cmd_start(message: Message):
register_user(message.from_user.id, message.from_user.first_name)
name = message.from_user.first_name
await message.answer(
f"Привет, {name}! Я Цыплёнок-помощник 🐥\n"
f"Скажи свой город командой /город, и я запомню тебя."
)Результат: в чате на /start бот поздоровается по имени и заведёт для нового человека строку в базе; повторное нажатие /start уже ничего не сломает. Здесь важна разница с прошлым запросом: вместо DO UPDATE мы написали ON CONFLICT(tg_id) DO NOTHING — «если анкета уже есть, ничего не делай». Иначе бы каждый /start затирал сохранённый город пустотой. Выбирай между DO UPDATE и DO NOTHING по смыслу: обновляем мы поле или просто хотим убедиться, что строка существует.
Частые ошибки и подводные камни
На этих граблях прыгает почти каждый новичок. Прыгни мысленно сейчас, чтобы не прыгать вживую.
- Вклеивать значения прямо в SQL-строку. Соблазн написать
f"... WHERE tg_id = {tg_id}"велик, но так делать нельзя: это и ломается на хитрых символах, и открывает дыру (SQL-инъекцию). Всегда используй плейсхолдеры?и передавай значения отдельным кортежем — как в наших функциях. - Забыть запятую в кортеже из одного элемента. Параметры запроса передаются кортежем. Для одного значения нужно писать
(tg_id,)с запятой, а не(tg_id)— без запятой это просто число в скобках, и SQLite выдаст ошибку «parameters are of unsupported type». - Не проверить None после get_user. Если человек написал боту впервые, его анкеты нет, и
get_userвернётNone. ОбращениеNone["city"]уронит хэндлер. Проверяйif user is None:прежде чем лезть в поля. - Делать INSERT вместо UPSERT. Если на каждое
/городделать простоINSERT, то при второй смене города SQLite поднимет ошибку нарушения первичного ключа (такой tg_id уже есть). ИспользуйON CONFLICT ... DO UPDATE, чтобы один и тот же код и создавал, и обновлял. - Путать «пустую строку» и «нет значения». Поле может быть
None(графу вообще не заполняли) или пустой строкой""(заполнили пробелом). В проверкеnot user["city"]оба случая ловятся сразу — и это удобно, но помни про разницу, когда будешь хранить, например, число0: для негоnot 0тоже True, а это не всегда то, что ты хочешь.
Мини-практика: добавь переключатель уведомлений
Поле notify у нас уже есть в таблице, но команды, которая бы его меняла, ещё нет. Допиши боту команду /уведомления, которая переключает флажок: было включено — выключи, было выключено — включи.
- Напиши функцию
toggle_notify(tg_id), которая читает текущее значение черезget_user, считает противоположное (0если было1, и наоборот) и сохраняет его UPSERT-запросом. - Учти случай, когда пользователя ещё нет в базе: тогда логично создать строку и сразу поставить
notify = 0(раз человек явно нажал «выключить»). - Добавь хэндлер
@dp.message(Command("уведомления")), который зовётtoggle_notifyи отвечает «Уведомления включены 🔔» или «Уведомления выключены 🔕» в зависимости от нового значения. - Проверь: вызови
/настройкидо и после переключения — строка про уведомления должна меняться.
Подсказка: для UPSERT по notify запрос будет похож на наш set_city, только обновлять надо столбец notify через excluded.notify.
Итоги и что дальше
Сегодня Цыплёнок-помощник научился по-настоящему узнавать людей. Мы спроектировали таблицу users с осмысленными полями, сделали tg_id первичным ключом, чтобы анкета была одна на человека, и научились сохранять настройки одним UPSERT-запросом — он сам решает, создавать строку или обновлять. Мы читаем настройки через get_user, аккуратно обрабатываем случай, когда человека ещё нет в базе, и применяем сохранённый город в ответах. А ещё разобрали грабли с плейсхолдерами, кортежем из одного элемента и проверкой на None.
Теперь у бота есть память о каждом собеседнике — и это фундамент для всего интересного впереди. В следующем уроке мы возьмём сохранённый город и наконец сходим за настоящей погодой во внешний API: Цыплёнок будет ходить на сервер прогноза, как ты ходишь в приложение погоды перед прогулкой, и подставлять реальные градусы вместо наших заглушечных +18°.