← До списку уроків
Заняття 05 з 13Адмінка бота
курс 0% виконано0%
Заняття 05 · місяць 2

Адмінка бота

90 хвилин

Навчити бота керувати контактами: додавати, змінювати й видаляти їх командами й кнопками в Telegram, на помилки вводу відповідати зрозуміло, а підсумки виводити на статичну сторінку, яку бот оновлює сам.

Мій сайт
http://91.219.61.4/u/<логін>/
Мій кабінет
http://91.219.61.4/k/<логін>/
Вхід на сервер
ssh <логін>@91.219.61.4

Замість <логін> підставляєш свій логін виду bohdan.g9: ім’я, крапка, номер групи. Кутові дужки не пишуться. Кабінет — сторінка, де проти кожного критерію стоїть відмітка, зарахований він чи ні. У прикладах на цій сторінці скрізь написано bohdan.g9 — читай це як свій логін.

Що ти зробиш сьогодні

Зараз список тих, хто писав твоєму боту, ти бачиш командою /users, а змінити в ньому щось можна лише командою SQL у терміналі. Сьогодні в боту з’явиться «адмінка» — набір дій, якими ти керуєш контактами просто в Telegram, без терміналу й без жодної сторінки в браузері: /list — показати, /add — додати, /edit — змінити, /del — видалити. Контакт — це рядок таблиці users. Він може бути людиною, яка писала боту, а може бути доданий тобою вручну: однокласник, який боту ще не писав.

Окремо ти навчишся робити так, щоб бот на неправильні дані відповідав словами, а не падав: порожнє ім’я, номер, якого немає, «номер», який не є числом — на кожне бот пояснить, що не так і як правильно. Додаси кнопки внизу екрана Telegram, щоб головні команди натискалися пальцем. І зробиш статичну сторінку ~/www/stats.html з цифрами (скільки людей, скільки повідомлень) — її бот переписуватиме сам після кожної зміни, а відкривається вона в будь-якому браузері, навіть на телефоні, без жодної працюючої програми.

Наприкінці покажеш: контакт, який ти додав, змінив і видалив командами; бота, що на порожнє ім’я й на неіснуючий номер відповідає поясненням; сторінку http://91.219.61.4/u/<логін>/stats.html на телефоні; репозиторій на GitHub, де основна гілка тепер називається main.

Якщо в тебе немає TelegramУсе, крім кнопок і розмови з живим ботом, робиться через try_bot.py: python3 try_bot.py "/add Оля | подруга". Автоперевірка теж пише боту саме так.

Поки що команди відкритіЯк і /users із заняття 04, команди /add, /edit і /del сьогодні виконає кожен, хто знайде твого бота: ніхто не перевіряє, що це саме ти. Це тимчасово, для навчання: на занятті 07 ти залишиш керування лише собі. Тому поки що нікому не розсилай нік свого бота й не пиши в контакти справжніх даних, яких не хочеш показувати.

Теорія

1. Адмінка без браузера: чотири дії над списком

З будь-яким списком даних роблять чотири речі: створити запис, прочитати (показати) його, змінити й видалити. Програмісти називають їх одним словом CRUD — за першими літерами англійських Create, Read, Update, Delete. Майже будь-який застосунок — це ці чотири дії над різними таблицями: записник, магазин, соцмережа. У базі їм відповідають чотири команди SQL, які ти вже знаєш із заняття 04:

ДіяКоманда ботаЩо робить у базі
показати/listSELECT: читає рядки, нічого не змінює
створити/add Оля | сидить поручINSERT: додає рядок
змінити/edit 7 note нова приміткаUPDATE … WHERE id = 7: міняє лише рядок №7
видалити/del 7DELETE … WHERE id = 7: прибирає рядок №7

Така «панель керування» називається адмінкою — від слова «адміністратор», людина, яка керує даними. У багатьох сайтів адмінка — окрема сторінка з формами. У нас її роль виконує сам бот: ти пишеш йому команду — він змінює базу й відповідає, що вийшло. Чому не сторінка? Бо сторінка з формами потребує програми, яка весь час працює і чекає запитів, — вона займає пам’ять сервера, а ми ділимо його на всю групу. Бот уже працює, і додати йому кілька команд майже безкоштовно. До того ж в Telegram уже є вхід, телефон і кнопки, які не треба малювати.

Команда — це повідомлення, що починається з косої риски. Після назви йдуть аргументи — дані, з якими працює команда. У /edit 7 note нова примітка їх три: номер запису (7), назва поля (note) і нове значення (нова примітка). Бот розбирає текст по частинах: слова розділяються пробілами, а в /add Оля | сидить поруч ім’я від примітки відділяє вертикальна риска |, бо ім’я та примітка самі можуть складатися з кількох слів.

Номер, який потрібен для /edit і /del, ти береш зі списку: це id з таблиці — той самий PRIMARY KEY із заняття 04. Саме тому /list друкує його на початку кожного рядка: 7. Оля, повідомлень: 0, …. Номер не змінюється за життя запису, тож по ньому можна впевнено вказати, кого саме міняти.

Контакт, якого нема в TelegramУ людини, яка писала боту, є chat_id. У контакту, якого ти додав сам, його немає: у цьому полі лежить NULL — «нічого не записано» (заняття 04). Правило UNIQUE дозволяє скільки завгодно порожніх значень, тож рядків без chat_id може бути багато. А ось видалена людина, яка писала боту, повернеться, щойно напише знову: бот додасть її як нову.

У списку є рядок 7. Оля, повідомлень: 3, …. Ти пишеш /edit 7 note сидить поруч. Що змінить бот?

2. Як бот розбирає команду

Бот отримує просто рядок тексту: "/edit 7 note сидить поруч". Щоб дістати з нього частини, Python має кілька інструментів, які ми й візьмемо:

Нижче в практиці ти побачиш їх у справі. Усе це — звичайні рядки Python; нічого нового, крім уважності до того, що людина може написати.

3. Помилка людини — це не помилка програми

Бот отримує дані від людей, а люди вводять усяке: забувають ім’я, плутають номер, пишуть літери замість цифр, натискають кнопку, яка не підходить. Тому програма завжди має план на «неправильно». Є три рівні, і їх легко сплутати:

  1. Перевірка введення. Код сам дивиться, чи дані годяться, ще до того, як торкнутися бази, і відповідає зрозуміло: «Ім’я не може бути порожнім. Приклад: /add Оля | сидить поруч». Це найкращий рівень: людина одразу знає, що виправити;
  2. Правила бази. Якби перевірка введення пропустила порожнє ім’я, NOT NULL із заняття 04 зупинив би запис. Але база відповість мовою програм (NOT NULL constraint failed), яку людина не зрозуміє. Це запасний захист, а не заміна першого рівня;
  3. Необроблена помилка. Якщо код упав — виняток з Traceback — бот у poll_forever() із заняття 02 перехопить його й напише людині «Ой, щось пішло не так». Бот живий, але людина не отримала ні результату, ні пояснення. У журналі лишився рядок «помилка обробки» — для тебе. Потрапити сюди через неправильне введення не повинно.

Хороша відповідь на помилку робить три речі: каже, що не так («немає контакту з номером 999999»), показує, як правильно (приклад команди або «подивись номери: /list») і ніколи не звинувачує людину. «Номер має бути числом» — добре; «Ти ввів дурницю» — ні.

Ще важливо, що саме бот перевіряє. Кожне поле має межі: ім’я — не порожнє й не довше за 100 символів, примітка — до 500. Без меж хтось надішле «ім’я» на 4000 символів, і відповідь з таким іменем не вміститься в одне повідомлення (Telegram приймає не більше 4096 символів). Перевіряти треба в коді бота, бо ніхто, окрім нього, цього не зробить: кнопка Telegram не вміє «заборонити порожнє поле», на відміну від форми на сторінці.

І ще одне: значення з повідомлення не вставляють у SQL (заняття 04). Усюди — через ?. Єдине виключення в сьогоднішньому коді: назву стовпця, який міняє /edit, підставляє f-рядок, але вона береться не з тексту людини, а зі списку «name або note», який бот перевірив раніше. Людина вибирає зі списку; вигадати свою назву стовпця вона не може.

Хтось пише /del абв. Яка відповідь бота найкраща?

4. Що команда робить із базою: INSERT, UPDATE, DELETE і «скільки рядків змінилося»

Від заняття 04 у тебе є три функції для бази: query (прочитати), execute (записати й дізнатися id нового рядка) і changed (записати й дізнатися, скільки рядків змінилося). Сьогодні вони всі потрібні:

У UPDATE і DELETE умова WHERE id = ? обов’язкова — без неї зміниться чи зникне вся таблиця (заняття 04). Тому в кожній такій команді спершу перевіряємо номер, а лише потім звертаємося до бази.

Видалення не можна скасувати: рядка більше немає ніде. Тому відповідь на /del повторює, кого саме видалено, і підказує команду, якою його можна додати знову. Для великих даних перед видаленням просять підтвердження (кнопкою «Так, видалити») — на занятті 10 ти зробиш і це; для списку з десятка контактів достатньо чіткої відповіді.

5. Кнопки в Telegram: клавіатура внизу екрана

Щоб не набирати /list руками, Telegram вміє показувати кнопки. Є два види, і сьогодні ми беремо простіший:

Щоб показати клавіатуру, бот додає до sendMessage параметр reply_markup — JSON-опис рядів кнопок. Наша клавіатура:

{"keyboard": [[{"text": "/list"}, {"text": "/add"}],
              [{"text": "/help"}]],
 "resize_keyboard": True}

Зовнішній список — ряди, внутрішні — кнопки в ряду; тут два ряди (дві кнопки й одна). resize_keyboard робить кнопки компактними. Клавіатура лишається на екрані, поки її не заберуть, тож досить надіслати її з першою відповіддю. Ми додаємо її до кожної відповіді — і це дешевше, ніж стежити, кому її вже показано.

Кнопка /add без імені надішле боту просто /add — і бот відповість підказкою з прикладом. Це й є «добра відповідь на помилку» з розділу 3. Ще один прийом: у Telegram будь-яку команду в тексті повідомлення (/list, /help) можна натиснути пальцем, тож підказки бота з назвами команд теж працюють як кнопки.

6. Статична сторінка, яку пише бот

Ти вже знаєш, що сайт у твоїй теці ~/www — це звичайні файли, які віддає nginx. Жодна програма для цього не працює: прийшов запит — nginx відкрив файл і віддав. Така сторінка називається статичною. Протилежність — динамічна: сторінка, яку для кожного запиту складає працююча програма. Ми динамічних не робимо, бо вони потребують процесу, який постійно висить у пам’яті.

Але цифри на сторінці міняються, і їх хочеться бачити свіжими. Рішення просте: бот, який і так працює, сам переписує файл щоразу, коли дані змінилися. Для відвідувача все виглядає так, ніби сторінка жива, а сервер знову не напружується: файл лежить і чекає. Так у нас працюватиме ~/www/stats.html: після кожного нового повідомлення і після кожної команди /add, /edit, /del бот перераховує цифри й записує сторінку заново.

Два правила, як переписувати файл безпечно:

Що писати на сторінці? Вона публічна: адресу http://91.219.61.4/u/<логін>/stats.html може відкрити будь-хто, хто її знає, без жодного входу. Тому там тільки цифри: «людей у базі», «повідомлень», «писали за тиждень» — і жодного імені, ніка чи номера чату. Це продовження розмови про персональні дані із заняття 04: те, що лежить у базі, не обов’язково показувати світові. Автоперевірка L05.4 саме це й перевіряє: вона додає контакт із вигаданим ім’ям і дивиться, що на сторінці його немає.

І ще дві речі, без яких сторінку незручно читати. Рядок <meta name="viewport" content="width=device-width, initial-scale=1"> у заголовку каже телефону: «показуй сторінку на ширину екрана, а не як зменшений комп’ютерний сайт» — без нього все стає дрібним. А порожній стан — те, що людина бачить, коли даних ще немає. Порожня сторінка з нулями схожа на зламану; тому, коли людей нуль, сторінка каже: «Поки що тут порожньо. Напиши боту /start — і з’являться перші цифри». Добрий порожній стан каже дві речі: що відбувається і що зробити.

Яку цифру не можна виводити на публічній сторінці stats.html?

Навіщо бот пише сторінку спочатку в stats.html.tmp, а потім перейменовує?

Слова цього уроку

СловоЩо означає
адмінкаПанель, якою керують даними; у нас — команди бота в Telegram
CRUDЧотири дії над списком: створити, прочитати, змінити, видалити
команда, аргументПовідомлення з косою рискою; дані після назви команди
split, partition, isdigitРозрізати рядок на слова; розрізати по першому роздільнику; чи складається рядок із цифр
перевірка введенняКод сам дивиться, чи годяться дані, і відповідає зрозуміло, ще до звернення до бази
порожній станТе, що бачить людина, коли даних ще немає, — з поясненням, що робити
changed()Функція db.py: виконати запит і повернути, скільки рядків змінилося (0 — такого запису немає)
клавіатура відповідіКнопки внизу екрана Telegram; натискання надсилає боту текст кнопки
reply_markupПараметр sendMessage з описом кнопок
статична сторінкаГотовий файл, який nginx просто віддає; жодної програми для цього не працює
os.replaceПідмінити файл за один неподільний крок; так сторінка ніколи не лишається напівзаписаною
viewportРядок у заголовку сторінки, що каже телефону показувати її на ширину екрана
contacts.pyФайл бота з командами для контактів і сторінкою статистики
stats.htmlПублічна сторінка з цифрами, яку бот переписує сам

Практика

Виконуй кроки по черзі. Де в команді стоїть $USER, нічого не підставляй — ця змінна вже містить твій логін. А <логін> разом із кутовими дужками заміни на свій, наприклад bohdan.g9. Номери контактів (6, 7) у прикладах — умовні: у тебе будуть свої, і в командах пиши саме їх. Усі команди виконуються з теки ~/app, якщо не сказано інше.

1 Зайти на сервер і подивитися, з чого починаємо

ssh <логін>@91.219.61.4
cd ~/app
systemctl --user is-active club-bot
python3 try_bot.py "/users" | head -n 3
ls contacts.py
grep -n "def changed" db.py
git status --short
git branch --show-current

Має бути по черзі: active — служба працює; початок списку («Людей у базі: …» і перші рядки з людьми, щонайменше п’ятеро з заняття 04); contacts.py — файл із заняття 04 на місці; рядок на зразок 44:def changed(sql, params=()):; порожній вивід git status --short — усе з заняття 04 закомічено; і назва основної гілки — найімовірніше, master. Сьогодні наприкінці ти її перейменуєш.

2 Підготувати contacts.py: імпорти й межі

nano contacts.py

У файлі з заняття 04 вгорі стоїть import sqlite3, порожній рядок і from db import query. Заміни ці рядки на такий блок імпортів (рядок import sqlite3 лишається — просто тепер він серед інших):

import html
import os
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path

from db import CFG, changed, execute, query

Нові імпорти знадобляться по черзі: changed і execute — командам, html, os, datetime, Path і CFG — сторінці статистики. Одразу під рядком MAX_REPLY = 3800 … додай межі для введення. А під функцією _clean() — функцію now():

MAX_NAME = 100
MAX_NOTE = 500
def now():
    return datetime.now().isoformat(timespec="seconds")

З nano не виходь: кроки 3–6 працюють у тому ж файлі. Зберегти можна будь-коли (Ctrl+O, Enter).

3 Написати перевірки введення

Знайди в contacts.py рядок HANDLERS = …. Над ним, між функціями, встав п’ять маленьких функцій, які перевіряють те, що ввела людина. Жодна не торкається бази:

def _id(word):
    """«7» → 7; усе інше → None. Номер у базі — додатне ціле."""
    if word.isdigit() and len(word) <= 12:
        return int(word)
    return None


def _bad_id(word):
    return (f"Номер має бути числом — його видно на початку рядка в /list. "
            f"А ти написав «{_clean(word)[:20]}».")


def _no_such(num):
    return f"Контакту з номером {num} немає. Подивись номери: /list"


def _name_error(name):
    if not name:
        return "Ім’я не може бути порожнім. Приклад: /add Оля | сидить поруч"
    if len(name) > MAX_NAME:
        return f"Ім’я задовге: {len(name)} символів, а можна до {MAX_NAME}."
    return None


def _note_error(note):
    if len(note) > MAX_NOTE:
        return f"Примітка задовга: {len(note)} символів, а можна до {MAX_NOTE}."
    return None

Лапки ’ в слові «Ім’я» — типографський апостроф: у тексті відповіді він безпечний, а прямий ' закрив би рядок Python.

4 Написати /add — додати контакт

Ще нижче, також перед HANDLERS, встав:

def add(arg):
    name, _, note = arg.partition("|")
    name, note = _clean(name), _clean(note)
    problem = _name_error(name) or _note_error(note)
    if problem:
        return problem
    t = now()
    new_id = execute("""INSERT INTO users(chat_id, name, username, note, first_seen, last_seen, messages)
                        VALUES(NULL, ?, '', ?, ?, ?, 0)""", (name, note, t, t))
    return f"Додано: {new_id}. {name}. Усі контакти: /list"

5 Написати /edit і /del

Нижче add(), також перед HANDLERS:

def edit(arg):
    parts = arg.split(None, 2)
    if len(parts) < 2:
        return "Приклад: /edit 7 note сидить поруч або /edit 7 name Нове ім’я. Номер дивись у /list."
    num = _id(parts[0])
    if num is None:
        return _bad_id(parts[0])
    field = parts[1].lower()
    value = _clean(parts[2]) if len(parts) > 2 else ""
    if field not in ("name", "note"):
        return f"Змінити можна name (ім’я) або note (примітку), а не «{_clean(parts[1])[:20]}»."
    problem = _name_error(value) if field == "name" else _note_error(value)
    if problem:
        return problem
    # назва стовпця береться лише зі списку вище, значення — через ?
    if not changed(f"UPDATE users SET {field} = ? WHERE id = ?", (value, num)):
        return _no_such(num)
    return f"Змінено контакт {num}: {'ім’я' if field == 'name' else 'примітка'} — «{value}»."


def delete(arg):
    if not arg:
        return "Приклад: /del 7 — номер дивись у /list."
    if len(arg.split()) > 1:
        return "Видаляю по одному контакту за раз: /del 7"
    word = arg.split()[0]
    num = _id(word)
    if num is None:
        return _bad_id(word)
    rows = query("SELECT name FROM users WHERE id = ?", (num,))
    if not rows:
        return _no_such(num)
    changed("DELETE FROM users WHERE id = ?", (num,))
    return (f"Видалено: {num}. {_clean(rows[0]['name'])}. "
            f"Передумав — додай знову: /add {_clean(rows[0]['name'])}")

6 Підключити команди й подивитися, що вони працюють

У кінці файлу (там, де були HANDLERS і command із заняття 04) заміни їх на новішу версію. Було два шматки; тепер вони такі:

HANDLERS = {"/users": lambda arg: users_text(), "/list": lambda arg: users_text(),
            "/add": add, "/edit": edit, "/del": delete}
CHANGING = ("/add", "/edit", "/del")               # після них бот переписує stats.html


def command(text, sender=None):
    """Команда → текст відповіді; None, якщо такої команди тут немає."""
    parts = text.split(None, 1)
    name = parts[0].split("@")[0].lower()
    handler = HANDLERS.get(name)
    if handler is None:
        return None
    try:
        reply = handler(parts[1].strip() if len(parts) > 1 else "")
    except sqlite3.Error as e:                     # база зайнята чи зіпсована — не traceback людині
        print(f"{now()} помилка бази: {e!r}", flush=True)
        return "База зараз недоступна. Спробуй ще раз за хвилину."
    if name in CHANGING:
        write_stats()                              # дані могли змінитися — переписуємо сторінку
    return reply


# ── кнопки внизу екрана ─────────────────────────────────────────────
KEYBOARD = {"keyboard": [[{"text": "/list"}, {"text": "/add"}], [{"text": "/help"}]],
            "resize_keyboard": True}


def extras():
    """Кнопки внизу екрана Telegram: натискання надсилає боту текст кнопки, як звичайне повідомлення."""
    return {"reply_markup": KEYBOARD}

Що змінилося: у словнику з’явилися /list (друге ім’я для /users), /add, /edit і /del; кортеж CHANGING зберігає команди, після яких дані могли змінитись; command() після такої команди викликає write_stats() — функцію зі сторінкою статистики, яку ти додаси в наступному кроці. Нижче — клавіатура з розділу 5: словник KEYBOARD і функція extras(), що повертає додатковий параметр для sendMessage. Ще одна дрібниця для порожнього стану: у функції users_text() заміни рядок return "Поки що боту ніхто не писав. Напиши йому сам — і тут з’явиться рядок." на два рядки, у дужках, щоб бот підказував, як додати першого:

        return ("Поки що боту ніхто не писав. Напиши йому сам — і тут з’явиться рядок. "
                "Або додай контакт сам: /add Оля | подруга")

І останнє: щоб у /list було видно примітку, яку ставлять /add Оля | сидить поруч і /edit 7 note …, у функції line() заміни останній рядок (return f"{row['id']}. …") на два:

    note = f" — {_clean(row['note'])}" if row["note"] else ""
    return f"{row['id']}. {_clean(row['name'])}{nick}, повідомлень: {row['messages']}, востаннє {when}{note}"

Примітки немає — рядок такий, як і був; є — додається після тире.

Тепер збережи файл (Ctrl+O, Enter) і вийди (Ctrl+X). Поки write_stats() немає, команди /add, /edit і /del видаватимуть помилку — тому спершу допиши її, а перевіряти команди будемо одразу після.

7 Додати сторінку статистики

Готовий блок сторінки лежить у файлі уроку; виведи його й допиши в кінець свого contacts.py. Ти вже вийшов з nano, тож просто в терміналі:

sed -n '/^# ── статична сторінка/,$p' /opt/club/kurs/05/contacts.py | head -n 20
sed -n '/^# ── статична сторінка/,$p' /opt/club/kurs/05/contacts.py >> contacts.py
python3 -c "import contacts; print(contacts.stats_numbers())"

Перша команда лише показує початок блоку (head -n 20 — перші двадцять рядків). Друга дописує блок у кінець твого файлу: >> — «додати в кінець», а один > стер би файл. Не виконуй її двічі. Третя перевіряє цифри: вона друкує словник на зразок {'people': 7, 'messages': 12, 'recent': 7, 'manual': 0} — скільки людей, повідомлень, писали за тиждень і скільки додано вручну.

Прочитай блок (less contacts.py, вийти — q) — він невеликий:

Теки ~/www у тебе вже є. Якби не було — write_stats() створить її сама (mkdir(parents=True, exist_ok=True)).

8 Перевірити всі команди «щасливим шляхом»

python3 try_bot.py "/add Оля Кравчук | сидить поруч"
python3 try_bot.py "/list"
python3 try_bot.py "/edit 7 note допомагає тестувати"
python3 try_bot.py "/edit 7 name Оля К."
python3 try_bot.py "/list"
python3 try_bot.py "/del 7"
python3 try_bot.py "/list"

Приблизно такі відповіді (номер у тебе свій — це наступний вільний): «Додано: 7. Оля Кравчук. Усі контакти: /list»; список, де нагорі рядок «7. Оля Кравчук, повідомлень: 0, востаннє … — сидить поруч»; «Змінено контакт 7: примітка — «допомагає тестувати»»; «Змінено контакт 7: ім’я — «Оля К.»»; список із новим іменем і приміткою; «Видалено: 7. Оля К. Передумав — додай знову: /add Оля К.»; список без неї. Лічильник повідомлень у доданого вручну контакту — 0: він боту нічого не писав. А ти, коли пишеш боту команди, лічишся як звичайна людина — тому твій рядок (Тест) у списку завжди першим.

Якщо замість відповідей — NameError: name 'write_stats' is not defined, ти пропустив крок 7; SyntaxError чи IndentationError — подивись номер рядка у повідомленні: найчастіше загублена дужка чи лапка у вставленому коді; No module named 'contacts' — ти не в ~/app; Не знаю такої команди — HANDLERS не оновлено. Перевір: grep -n "/edit" contacts.py має знайти рядок словника HANDLERS.

9 Свідомо ввести неправильно

Тепер перевіряємо головне — що бот робить, коли людина помиляється:

python3 try_bot.py "/add"
python3 try_bot.py "/add    | примітка без імені"
python3 try_bot.py "/del 999999"
python3 try_bot.py "/edit 999999 note щось"
python3 try_bot.py "/del абв"
python3 try_bot.py "/edit 3 колір синій"
python3 try_bot.py "/edit 3 name"
python3 try_bot.py "/add $(python3 -c 'print("Я" * 101)')"
python3 try_bot.py "/list" | head -n 1

Кожна з перших восьми команд має дати одне зрозуміле речення без слова Traceback: «Ім’я не може бути порожнім. Приклад: /add Оля | сидить поруч»; те саме для порожнього імені з приміткою; «Контакту з номером 999999 немає. Подивись номери: /list»; те саме для /edit; «Номер має бути числом — його видно на початку рядка в /list. А ти написав «абв»»; «Змінити можна name (ім’я) або note (примітку), а не «колір»»; «Ім’я не може бути порожнім» для /edit 3 name; «Ім’я задовге: 101 символів, а можна до 100». Остання команда друкує перший рядок списку: кількість людей не змінилася після всіх помилок — жодна не пройшла в базу.

Побачив Traceback — прочитай його останній рядок: це місце, де перевірка введення пропустила випадок. Найчастіше — _id не викликається (int(…) прямо від тексту) або забутий return після повідомлення про помилку.

І ще дві спроби, що показують, чому значення йдуть через ? (заняття 04):

python3 try_bot.py "/add Мар'яна | з \"лапками\""
python3 try_bot.py "/add x'); DROP TABLE users;--"
python3 try_bot.py "/list" | head -n 4
sqlite3 ~/data/app.db ".tables"

Обидва «імені» записались як є — з апострофом, лапками й навіть «командою» DROP TABLE усередині. Таблиця users на місці: текст людини ніколи не стає частиною запиту. Видали ці два зайві контакти командами /del з їхніми номерами зі списку.

10 Підключити команди й кнопки в bot.py

У try_bot.py усе вже працює, бо виклик contacts.command() у bot.py з'явився на занятті 04. Лишилося три дрібні зміни в самому боті: довідка, свіжа сторінка після кожного повідомлення й кнопки при відправленні відповіді.

nano bot.py

Перша — довідка. У кінці рядка HELP заміни останній рядок "/users — хто писав боту") на чотири (на кінці кожного, крім останнього, — \n усередині лапок):

        "/users, /list — хто писав боту\n"
        "/add Ім’я | примітка — додати контакт\n"
        "/edit номер note текст — змінити\n"
        "/del номер — видалити")

Закривна дужка ) стоїть лише в останньому рядку — вона закриває HELP = (….

Друга — свіжа сторінка після кожного нового повідомлення. У кінці remember(), після виклику execute(…), додай два рядки (відступ — чотири пробіли):

    import contacts                                    # заняття 05: свіжа сторінка статистики
    contacts.write_stats()

Третя — кнопки. Знайди в poll_forever() рядок tg("sendMessage", chat_id=chat, text=reply) і додай до нього параметр; а перед функцією start_polling() — нову функцію:

                    tg("sendMessage", chat_id=chat, text=reply, **contacts_extras())
def contacts_extras():
    """Заняття 05: кнопки /list, /add, /help внизу екрана Telegram."""
    import contacts
    return contacts.extras()

**contacts_extras() розгортає словник {"reply_markup": …} у додаткові параметри виклику. Звіряй себе з готовим файлом: diff /opt/club/kurs/05/bot.py bot.py — мають відрізнятися лише твої власні тексти й команди із занять 02–03. Потім:

python3 try_bot.py "/help"
python3 try_bot.py "привіт"
ls -l ~/www/stats.html

Довідка показує нові команди; привіт відповідає як зазвичай; файл stats.html існує й оновився щойно (час у ls -l). Помилка про contacts_extras з'явиться лише в живому боті, не в try_bot.py — її покаже крок 12.

11 Записати команди в маніфест і перезапустити службу

nano project.yaml

У розділі commands заповни рядки (list уже є із заняття 04), а в розділі pages — рядок stats:

commands:
  about: "/about"
  list: "/users"                # заняття 04
  create: "/add"                # заняття 05
  update: "/edit"
  delete: "/del"

pages:
  index: "index.html"
  stats: "stats.html"           # заняття 05

Решту рядків не чіпай. Автоперевірка бере звідси назви твоїх команд і сторінки. Перевір формат і перезапусти бота:

python3 -c "import yaml; d = yaml.safe_load(open('project.yaml')); print(d['commands']['create'], d['pages']['stats'])"
systemctl --user restart club-bot
systemctl --user is-active club-bot
tail -n 5 ~/logs/club-bot.log

Має бути /add stats.html, потім active, а в журналі — жодного рядка з Error. Якщо в Telegram бот відповідає «Ой, щось пішло не так», знайди причину: tail -n 30 ~/logs/club-bot.log | grep "помилка обробки".

12 Спробувати в Telegram

Напиши боту /start — під полем вводу з’явилися кнопки /list, /add, /help. Потім:

  1. натисни кнопку /add — вона надішле порожню команду, і бот відповість підказкою з прикладом;
  2. напиши /add Марко | з гуртка — бот повідомить номер;
  3. натисни /list — Марко з’явився; біля рядка видно номер;
  4. напиши /edit з його номером та note і новий текст, потім /del з номером. Натисни пальцем на команду в підказці бота — вона спрацює як кнопка;
  5. спробуй /del 999999 і подивись, що відповість бот.

Без Telegram цей крок пропусти: усе, крім самих кнопок, ти вже перевірив через try_bot.py. Не з’явились кнопки — перезапусти службу (крок 11) і напиши щось ще раз: клавіатура приходить разом із відповіддю.

13 Відкрити сторінку статистики на телефоні

curl -s http://91.219.61.4/u/$USER/stats.html | head -n 14
curl -s -o /dev/null -w "%{http_code}\n" http://91.219.61.4/u/$USER/stats.html

Перша команда показує початок сторінки: <!doctype html>, рядок viewport і заголовок. Друга друкує лише код відповіді nginx — 200 означає, що сторінку видно. Це відповідь веб-сервера на звичайне відкриття файлу; жодна твоя програма в цьому не бере участі.

Тепер відкрий http://91.219.61.4/u/<логін>/stats.html у браузері, а краще — на телефоні через мобільний інтернет: чотири картки з цифрами мають стояти по дві в ряд і читатися без збільшення. Напиши боту /add Тарас і онови сторінку: «людей у базі» стало на одиницю більше. Імен на сторінці немає — і не повинно бути.

Порожній стан ти зараз не побачиш — у таблиці є люди. Але він є в коді: знайди в stats_html() абзац із <p class="empty"> і прочитай, що там написано. Хороший порожній стан каже, що відбувається («тут порожньо») і що зробити («напиши боту /start»).

Зберегти роботу на GitHub

Сьогодні, крім звичного коміту, у репозиторію зміниться назва основної гілки. Досі вона звалася master — так її за звичкою називає git, коли створює новий репозиторій. GitHub і більшість нових проєктів уже кілька років називають основну гілку main, і далі в курсі всі команди будуть саме з main. Назва — лише ярлик на останньому коміті: історія, коміти й файли від перейменування не змінюються.

Одне застереження: сторінку stats.html Git не збереже. Вона лежить у ~/www, а репозиторій — це тека ~/app. І це правильно: сторінку створює бот, і вона відновиться першим же повідомленням, а в репозиторії має лежати лише код.

14 Перейменувати гілку, зробити коміт і відправити

cd ~/app
git status --short
git branch --show-current

git status --short має показати змінені M bot.py і M project.yaml та новий файл ?? contacts.py — його ти змінив у кроках 2–7, а на занятті 04 він ще не був у коміті, якщо ти його не додавав. (M — modified, «змінено»). Якщо ти ставив нову версію db.py у кроці 1, буде й він. Файлів .env чи .db у списку бути не повинно; якщо вони там є — нічого не відправляй і поклич викладача. Друга команда, найімовірніше, надрукує master. Тоді перейменуй гілку:

git branch -m master main
git branch

-m — від move, «перемістити»: так git перейменовує гілку. Команда нічого не друкує, а git branch покаже * main (зірочка — гілка, на якій ти стоїш) і нижче proba з заняття 03. Якщо git branch --show-current одразу сказав main, перейменовувати нічого не треба — переходь до коміту.

git add -A
git commit -m "Заняття 05: адмінка в Telegram — /add, /edit, /del, кнопки і сторінка статистики"
git push origin main
git push -u github main

git commit відповість рядком [main …] Заняття 05: … і підсумком на зразок 3 files changed. Перший git push оновлює ~/repo.git на сервері, другий — GitHub; обидва закінчуються рядком * [new branch] main -> main: для віддалених репозиторіїв main — нова гілка. GitHub ще допише кілька рядків remote: Create a pull request for 'main'… — це пропозиція, яка нам не потрібна, пропускай її.

Параметр -u (від upstream, «основний віддалений») запам’ятовує: гілка main за замовчуванням ходить на github. Останній рядок відповіді це підтверджує: branch 'main' set up to track 'github/main'. Відтепер на GitHub досить писати просто git push, а копію на сервері так само оновлює git push origin main.

error: src refspec main does not match any — гілку не перейменовано: git branch досі показує * master. Permission denied (publickey) на другому push — GitHub не впізнав ключ: ssh -T git@github.com має привітати тебе на ім’я (налаштовано на занятті 02).

15 Зробити main головною й прибрати стару master

Тепер у ~/repo.git і на GitHub лежать обидві гілки, і головною там досі вважається master. Спершу сервер. У bare-репозиторії головну гілку задає файл HEAD — ось як його переписати й видалити стару гілку:

git -C ~/repo.git symbolic-ref HEAD refs/heads/main
git push origin --delete master

git -C ~/repo.git — «виконай команду в теці ~/repo.git», нікуди не переходячи. Перша команда нічого не друкує; друга відповідає - [deleted] master. Тепер GitHub — там головну гілку міняють на сторінці налаштувань:

  1. відкрий свій репозиторій club-app на GitHub і вгорі натисни Settings;
  2. у розділі Default branch праворуч від master натисни кнопку з двома стрілками (Switch to another branch);
  3. вибери main, натисни Update, а в попередженні — I understand, update the default branch.

Цю команду роби лише після пунктів 1–3 у браузері (інакше GitHub відмовить: «refusing to delete the current branch»).

git push github --delete master
git ls-remote --heads origin
git ls-remote --heads github
git status

Перша знову відповідає - [deleted] master. git ls-remote --heads показує, які гілки лежать у віддаленому репозиторії: в обох має бути рядок … refs/heads/main (і … refs/heads/proba, якщо ти відправляв цю гілку на занятті 03), а master — ніде. git status напише On branch main, Your branch is up to date with 'github/main'. і nothing to commit, working tree clean — усе збережено. Онови сторінку репозиторію на GitHub: у перемикачі гілок стоїть main, а останній коміт — «Заняття 05: адмінка в Telegram…».

! [remote rejected] master (refusing to delete the current branch: refs/heads/master) — GitHub не дає видалити головну гілку: спершу зміни Default branch на main (пункти 1–3), потім повтори команду. error: unable to delete 'master': remote ref does not exist — гілки master там уже немає, і це нормально, якщо в тебе від початку була main.

Що перевіряє автоматика

Сьогодні автоперевірка розмовляє з твоїм ботом так само, як ти через try_bot.py у кроках 8–9: імпортує bot.py і надсилає йому повідомлення від імені Автоперевірка. Всі команди вона бере з project.yaml — commands.create, list, update, delete і pages.stats, — сама нічого не вгадує, тож описка в маніфесті дає червоний пункт навіть тоді, коли бот працює. Усе відбувається на копії твоєї бази й з підставною сторінкою статистики — справжні контакти й справжній stats.html вона не змінює. Служба, Telegram і мережа не потрібні.

L05.1 спершу додає контакт «Контроль05» (якщо /add ще нічого не додає, пункт червоний), а потім надсилає /add без імені, з пробілами й з приміткою, але без імені та вимагає у відповідь пояснення, а не слово «Додано», і ніяких нових рядків у базі. L05.2 надсилає /del 999999, /edit 999999 note x (у відповіді має бути цей номер) і /del абв (будь-яке зрозуміле пояснення, не «не знаю такої команди»); у базі нічого не змінюється. L05.3 проходить цикл: додає контакт, шукає його у відповіді на /list, змінює примітку й ім’я, видаляє й переконується, що інших контактів не зачеплено. L05.4 додає контакт з вигаданим іменем, читає сторінку з STATS_FILE і перевіряє: вона є, у ній немає імені контакту й імені «Автоперевірка», є viewport, є кількість людей у базі, а після ще одного /add число змінилося; також є справжній файл ~/www/stats.html на сервері.

L05.5 оцінює викладач: відкриє бота й сторінку на телефоні, подивиться, чи зрозуміло, що робити, чи пояснено порожній стан, і попросить додати, змінити й видалити контакт. Натисни «Перевірити практичну частину» у розділі «Перевірка виконаних завдань» нижче на сторінці.

КодЩо перевіряєтьсяЯкщо не зараховано
L05.1порожнє ім'я: пояснення, а не запис і не помилка
автоперевірка
Команда додавання (commands.create, напр. "/add") має вміти додати контакт, а на порожнє ім'я («/add», «/add | примітка») відповісти поясненням і нічого не записати. Перевір, що перед записом стоїть перевірка name, а відповідь не починається зі слова «Додано».
L05.2неіснуючий номер: зрозуміла відповідь, а не помилка
автоперевірка
/del 999999 і /edit 999999 note x мають відповісти, що контакту з номером 999999 немає (у відповіді має бути цей номер), а /del абв — що номер має бути числом. Перевір changed(): якщо він повернув 0 — такого запису немає; і не лишай необроблених винятків.
L05.3додати → видно в списку → змінити → видалити
автоперевірка
Повний цикл командами не пройшов. Заповни в project.yaml commands.list, create, update, delete і перевір кожну команду вручну: python3 try_bot.py "/add Оля | подруга", потім /list, /edit номер note текст, /del номер.
L05.4бот сам переписує статичну сторінку stats.html
автоперевірка
Після /add бот має переписати сторінку (pages.stats у project.yaml, напр. stats.html у ~/www): цифри змінюються, імен немає, є <meta name="viewport">. Адресу файла бере stats_path() зі змінної STATS_FILE; write_stats() викликають і remember(), і /add, /edit, /del.
L05.5команди й кнопки зручні: порожній стан пояснено, сторінка статистики читається на телефоні
викладач
Позначку ставить викладач на занятті або після нього.

Твої відповіді на питання уроку

Кожне питання оцінюється відразу в браузері — це лишається так само. Ця кнопка лише надсилає підсумок учителю, щоб відповіді не зникли разом із закритою вкладкою.

Перевірка виконаних завдань

Список критеріїв і що саме вони означають — у таблиці вище, «Що перевіряє автоматика». Тут — та сама перевірка, але прямо зараз, без очікування на найближчий прохід за розкладом.

Домашнє завдання

  1. Команда /find. Напиши в contacts.py пошук: /find Ол показує контакти, у яких ім’я містить «Ол». Підказка: query("SELECT * FROM users WHERE name LIKE ?", ("%" + text + "%",)) — знаки % у LIKE означають «будь-що», а сам шаблон іде через ?, як усі значення. Не знайшов нікого — бот каже «Нікого не знайдено: …». Додай команду в HANDLERS, у HELP і перевір через try_bot.py.
  2. Людина за твоїм ботом. Дай телефон з ботом комусь удома й попроси: «Додай контакт, допиши йому примітку, а потім видали його». Не підказуй і не пояснюй. Записуй дослівно, що людина каже вголос і де зупиняється: «а що тут писати?», а не «не зрозуміла інтерфейс». Принеси записи на наступне заняття.
  3. Розділ про адмінку в README. Допиши в README.md розділ ## Адмінка: які є команди й що вони роблять, як бот відповідає на помилки (наведи по одному прикладу) і чому керування поки відкрите всім. Три-п’ять речень своїми словами. Розділи ## Що це і ## Як запустити не чіпай — їх і далі перевіряють.
  4. Свій вигляд сторінки. Зміни в stats_html() заголовок, колір або підписи карток і перевір: python3 try_bot.py "привіт", потім відкрий сторінку на телефоні. Лишай лише цифри: жодних імен.
  5. Поясни код. Підготуйся пояснити будь-який рядок add(), edit() і delete(). Зокрема: чому в delete() стоїть changed() і query(), а не лише execute(); чому f-рядок у edit() тут безпечний, а в SQL загалом — ні; що буде, якщо прибрати перевірку _id.
  6. Збережи. git add -A, git commit -m "Заняття 05: розділ про адмінку в README", git push origin main і git push — тепер без слів, бо гілка main пам’ятає дорогу на GitHub.

Якщо щось не працює

Бот не відповідає на /add, /edit, /del або /list

cd ~/app
python3 try_bot.py "/list"
python3 -c "import contacts"
grep -n "contacts" bot.py
tail -n 30 ~/logs/club-bot.log | grep "помилка"

L05.1 не зараховано: порожнє ім’я

cd ~/app
python3 try_bot.py "/add Контроль | перевірка"
python3 try_bot.py "/add"
python3 try_bot.py "/add    | примітка без імені"
sqlite3 ~/data/app.db "SELECT id, name FROM users WHERE TRIM(name) = ''"

L05.2 не зараховано: неіснуючий номер

cd ~/app
python3 try_bot.py "/del 999999"
python3 try_bot.py "/edit 999999 note щось"
python3 try_bot.py "/del абв"

L05.3 не зараховано: цикл не пройшов

Повідомлення перевірки каже, на якому кроці вона зупинилась. Повтори цикл руками й порівняй:

cd ~/app
python3 try_bot.py "/add Цикл | примітка1"
python3 try_bot.py "/list" | head -n 3
sqlite3 ~/data/app.db "SELECT id, name, note FROM users WHERE name = 'Цикл'"
python3 try_bot.py "/edit 8 note змінено"
python3 try_bot.py "/del 8"

L05.4 не зараховано: сторінка статистики

cd ~/app
python3 try_bot.py "/add Сторінка | перевірка"
ls -l ~/www/stats.html
grep -c "viewport" ~/www/stats.html
grep -c "Сторінка" ~/www/stats.html
grep -n "stats:" project.yaml

Тестовий контакт «Сторінка» видали: /del з його номером.

Кнопки не з’являються або /add відповідає не так

Гілка main: git відповідає помилкою