← До списку уроків
Заняття 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; нічого нового, крім уважності до того, що людина може написати.

Бот розбирає "Оля".partition("|") — риски в рядку немає. Що вийде?

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

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

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

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

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

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

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

Хтось пише /add | примітка — ім’я з самих пробілів. Чому недостатньо правила бази NOT NULL?

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

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

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

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

Чому в /del бот спочатку робить SELECT, а вже потім DELETE?

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

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

Щоб показати клавіатуру, бот додає до sendMessage параметр reply_markup — JSON-опис рядів кнопок (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: імпорти й межі

Поки редагуєш файлБот читає contacts.py не під час запуску, а тоді, коли хтось уперше надсилає команду. Якщо друг напише /list саме зараз, бот може відповісти помилкою. Це нормально: після перезапуску в кінці заняття все запрацює.

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

Знак ’ в слові «Ім’я» — типографський апостроф: у тексті відповіді він безпечний, а прямий ' усередині рядка в одинарних лапках закрив би цей рядок. Навіщо стільки дрібних функцій? Щоб один і той самий текст помилки не копіювати в /edit і /del: виправиш в одному місці — працює всюди. Типова помилка — вставити їх нижче за HANDLERS: Python читає файл зверху вниз, а HANDLERS посилається на функції, які мають уже існувати.

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) заміни їх на новішу версію. Було два шматки; тепер вони такі. Типова помилка — залишити старі й дописати нові поруч: тоді їх у файлі по два, і працює той, що нижче; шукай старі через grep -n "HANDLERS =" contacts.py — рядок має бути один.

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: він боту нічого не писав. А ти, коли пишеш боту команди, лічишся як звичайна людина — тож і ти є в списку. Типова помилка — брати номер із прикладу (7), а не зі свого /list: тоді /edit чи /del зачепить чужий контакт або бот скаже, що такого номера немає. Перед /del завжди дивись номер у /list.

Якщо замість відповідей — 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 і M contacts.py — їх ти змінив у кроках 2–11 (якщо contacts.py на занятті 04 не потрапив у коміт, замість M буде ?? contacts.py — «новий, git його ще не знає»). (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-репозиторії (так називають репозиторій без робочих файлів, лише з історією — таким є твій ~/repo.git) головну гілку задає файл 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 відповідає помилкою