Модель пользователя и настройки

Учим Цыплёнка узнавать каждого собеседника в лицо: завести таблицу настроек, сохранять выбор по 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_idINTEGER, первичный ключтот самый «номер билета», по нему ищем человека
nameTEXTкак обращаться к пользователю в ответах
cityTEXTдля какого города показывать погоду
langTEXT, по умолчанию ruна каком языке отвечать
notifyINTEGER, по умолчанию 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 у нас уже есть в таблице, но команды, которая бы его меняла, ещё нет. Допиши боту команду /уведомления, которая переключает флажок: было включено — выключи, было выключено — включи.

  1. Напиши функцию toggle_notify(tg_id), которая читает текущее значение через get_user, считает противоположное (0 если было 1, и наоборот) и сохраняет его UPSERT-запросом.
  2. Учти случай, когда пользователя ещё нет в базе: тогда логично создать строку и сразу поставить notify = 0 (раз человек явно нажал «выключить»).
  3. Добавь хэндлер @dp.message(Command("уведомления")), который зовёт toggle_notify и отвечает «Уведомления включены 🔔» или «Уведомления выключены 🔕» в зависимости от нового значения.
  4. Проверь: вызови /настройки до и после переключения — строка про уведомления должна меняться.

Подсказка: для UPSERT по notify запрос будет похож на наш set_city, только обновлять надо столбец notify через excluded.notify.

Итоги и что дальше

Сегодня Цыплёнок-помощник научился по-настоящему узнавать людей. Мы спроектировали таблицу users с осмысленными полями, сделали tg_id первичным ключом, чтобы анкета была одна на человека, и научились сохранять настройки одним UPSERT-запросом — он сам решает, создавать строку или обновлять. Мы читаем настройки через get_user, аккуратно обрабатываем случай, когда человека ещё нет в базе, и применяем сохранённый город в ответах. А ещё разобрали грабли с плейсхолдерами, кортежем из одного элемента и проверкой на None.

Теперь у бота есть память о каждом собеседнике — и это фундамент для всего интересного впереди. В следующем уроке мы возьмём сохранённый город и наконец сходим за настоящей погодой во внешний API: Цыплёнок будет ходить на сервер прогноза, как ты ходишь в приложение погоды перед прогулкой, и подставлять реальные градусы вместо наших заглушечных +18°.

Проверьте себя
1. Почему именно telegram id (tg_id) выбран ключом для поиска анкеты пользователя?
AПотому что это самое короткое поле в таблице
BПотому что Telegram навсегда закрепляет id за аккаунтом, в отличие от имени и юзернейма, которые можно сменить
CПотому что только числа можно хранить в SQLite
DПотому что id всегда совпадает с именем пользователя
2. Что делает запрос с конструкцией INSERT ... ON CONFLICT(tg_id) DO UPDATE (UPSERT)?
AВсегда добавляет новую строку, даже если такой tg_id уже есть
BУдаляет старую строку и вставляет новую
CВставляет строку, а если по tg_id уже есть запись — обновляет её, не поднимая ошибку
DПросто проверяет, существует ли пользователь, ничего не меняя
3. Что вернёт функция get_user, если пользователь ещё ни разу не писал боту и его нет в таблице?
AПустую строку ""
BНоль (0)
CЗначение None
DОшибку, которая уронит хэндлер
4. Зачем в SQL-запросах используют плейсхолдеры ? и передают значения отдельным кортежем вместо вклеивания через f-строку?
AТак запрос выполняется быстрее на больших таблицах
BЭто защищает от ошибок на хитрых символах и от SQL-инъекций
CИначе SQLite не понимает русские буквы
DПлейсхолдеры обязательны только для команды SELECT
5. Как в таблице SQLite принято хранить флажок «уведомления включены/выключены», если отдельного типа да/нет нет?
AТекстом "true" и "false"
BЦелым числом: 1 — включено, 0 — выключено
CОтдельной таблицей на каждое состояние
DВ виде None и пустой строки
6. Почему в хэндлере /start при регистрации пользователя выбран ON CONFLICT(tg_id) DO NOTHING, а не DO UPDATE?
ADO NOTHING работает быстрее на любой таблице
BЧтобы при повторном /start не затереть уже сохранённые настройки (например город) пустыми значениями
CDO UPDATE нельзя использовать в команде /start по правилам aiogram
DПотому что DO NOTHING сам создаёт первичный ключ
7. Что не так с записью параметра запроса в виде (tg_id) для одного значения?
AНичего, это полностью корректный кортеж
BБез запятой это не кортеж, а просто число в скобках — нужно писать (tg_id,)
CСкобки вообще нельзя использовать в параметрах
DТак можно передавать только строки, но не числа