Перед занятием в керамической мастерской участники собирают фартуки, полотенца и коробки, а организатор снова отвечает в чате, что нужно принести. Список уже готов, осталось выдавать его по запросу. Для такой задачи соберём Telegram-бота, который по команде /materials пришлёт памятку, а на обычный текст ответит сообщением с подсказкой.

Мастерская здесь вымышленная, список для неё составлен как пример. Возьмите реальный повторяющийся вопрос и заранее напишите ответ, пригодный для отправки человеку. Я бы начал именно с него, ведь автоматизировать удобнее уже решённую задачу.

После инструкции получится программа в одном файле main.go, которую запускают на компьютере. Пока программа работает и есть доступ к Telegram, бот отвечает в личном диалоге. Потребуются зарегистрированный бот, его токен и установленный Go; ниже есть полный код и команды запуска.

Подготовьте отдельного бота и компьютер

Для примера создайте отдельного бота через BotFather. Если этот шаг ещё впереди, пройдите регистрацию бота и сохранение токена. Рабочего бота, уже подключённого к конструктору или другой программе, для упражнения лучше оставить на месте.

В официальном руководстве Telegram токен предлагают хранить как пароль. В наш файл он попадать не будет, при запуске прочитаем его из переменной окружения BOT_TOKEN. С токеном можно управлять ботом, поэтому храните его в менеджере паролей, без копий в общих документах и чатах.

На компьютере понадобятся текстовый редактор и Go. Если Go ещё отсутствует, установите его по официальной инструкции для своей системы. После установки откройте новое окно терминала и выполните:

go version

В ответ должна появиться версия Go. Сообщение о неизвестной команде означает, что сначала нужно закончить установку или восстановить путь к Go по той же инструкции.

Создайте папку workshop-bot в удобном месте, а внутри неё файл main.go. Название должно оканчиваться именно на .go; сохраните файл как обычный текст в UTF-8, без дополнительного .txt. Ниже один полный файл, дополнительных пакетов для него скачивать не нужно.

Добавьте команды и готовый ответ

У бота будут команды /start, /help и /materials. Первые две показывают подсказку, третья выдаёт список вещей. Обычное сообщение бот вернёт с припиской о команде, так сразу видно, что программа получила текст из диалога.

Вставьте весь код в main.go:

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"time"
)

type bot struct {
	client *http.Client
	base   string
}

type update struct {
	ID      int64 `json:"update_id"`
	Message *struct {
		Text string `json:"text"`
		Chat struct {
			ID   int64  `json:"id"`
			Type string `json:"type"`
		} `json:"chat"`
	} `json:"message"`
}

func (b bot) call(method string, fields url.Values, result any) error {
	resp, err := b.client.PostForm(b.base+method, fields)
	if err != nil {
		return errors.New("связь с Telegram прервалась")
	}
	defer resp.Body.Close()
	var envelope struct {
		OK     bool            `json:"ok"`
		Result json.RawMessage `json:"result"`
		Code   int             `json:"error_code"`
	}
	if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&envelope); err != nil {
		return errors.New("получен нечитаемый ответ Telegram")
	}
	if !envelope.OK || resp.StatusCode != http.StatusOK {
		return fmt.Errorf("Telegram отклонил запрос: API %d, HTTP %d", envelope.Code, resp.StatusCode)
	}
	if result != nil {
		if err := json.Unmarshal(envelope.Result, result); err != nil {
			return errors.New("получен неожиданный результат Telegram")
		}
	}
	return nil
}

func reply(text string) string {
	switch strings.TrimSpace(text) {
	case "/start", "/help":
		return "Я помощник мастерской. /materials пришлёт список вещей для занятия. /help покажет эту подсказку."
	case "/materials":
		return "Для занятия возьмите:\n• фартук\n• небольшое полотенце\n• коробку для готовой работы."
	default:
		chars := []rune(text)
		if len(chars) > 1000 {
			text = string(chars[:1000]) + "…"
		}
		return "Получено: " + text + "\nДля списка вещей отправьте /materials."
	}
}

func (b bot) step(offset int64) (int64, error) {
	var updates []update
	err := b.call("getUpdates", url.Values{
		"offset":          {strconv.FormatInt(offset, 10)},
		"timeout":         {"30"},
		"limit":           {"10"},
		"allowed_updates": {`["message"]`},
	}, &updates)
	if err != nil {
		return offset, err
	}
	for _, u := range updates {
		m := u.Message
		if m != nil && m.Chat.Type == "private" && m.Text != "" {
			err := b.call("sendMessage", url.Values{
				"chat_id": {strconv.FormatInt(m.Chat.ID, 10)},
				"text":    {reply(m.Text)},
			}, nil)
			if err != nil {
				return offset, fmt.Errorf("ответ не подтверждён; проверьте диалог перед повторным запуском: %w", err)
			}
		}
		if u.ID >= offset {
			offset = u.ID + 1
		}
	}
	return offset, nil
}

func run() error {
	token := strings.TrimSpace(os.Getenv("BOT_TOKEN"))
	if token == "" {
		return errors.New("сначала задайте BOT_TOKEN")
	}
	b := bot{
		client: &http.Client{
			Timeout: 35 * time.Second,
			CheckRedirect: func(*http.Request, []*http.Request) error {
				return http.ErrUseLastResponse
			},
		},
		base: "https://api.telegram.org/bot" + token + "/",
	}
	var webhook struct {
		URL string `json:"url"`
	}
	if err := b.call("getWebhookInfo", nil, &webhook); err != nil {
		return err
	}
	if webhook.URL != "" {
		return errors.New("у бота уже настроен webhook; возьмите отдельного бота для примера")
	}
	fmt.Println("Бот запущен. Для остановки нажмите Ctrl+C.")
	var offset int64
	for {
		next, err := b.step(offset)
		if err != nil {
			return err
		}
		offset = next
	}
}

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

Подготовленный список находится в функции reply, после строки case "/materials":. Замените его текстом для своего занятия. Внутри строки \n задаёт перенос, а кавычки вокруг всего ответа остаются на месте. Если в тексте нужна сама двойная кавычка, запишите её как \".

В этой же функции задаётся поведение на неизвестное сообщение. Сейчас программа возвращает до тысячи символов исходного текста и подсказку. Для постоянного ответчика мастерской удобнее заменить эту ветку на короткий ответ «Список вещей доступен по команде /materials». На первом запуске возврат текста помогает увидеть связь между входящим сообщением и ответом.

Через getUpdates программа читает входящие сообщения, через sendMessage отвечает в диалоге. В offset хранится номер, с которого продолжать чтение. После ответа бот сдвигает этот номер вперёд. Telegram считает прежнее обновление подтверждённым, как только получит следующий запрос с большим offset. Так программа переходит к новым сообщениям.

Перед началом программа проверяет getWebhookInfo. Если у бота уже задан адрес для доставки сообщений, запуск остановится. Такой способ доставки называется webhook, и вместе с getUpdates он работать не может. Для нашего примера отдельный новый бот избавляет от необходимости менять действующее подключение.

Запустите программу с токеном

Откройте терминал в папке workshop-bot. На Mac можно набрать cd с пробелом, перетащить папку в окно терминала и нажать Enter. На Windows откройте папку в Проводнике и выберите в её контекстном меню «Открыть в Терминале»; используйте PowerShell.

Выполняйте команды по одной строке. После команды ввода вставьте токен от BotFather и нажмите Enter. Символы токена при вводе скрыты, обычного вывода текста ждать не нужно.

Для Mac, в стандартной оболочке zsh:

read -rs "BOT_TOKEN?Токен бота: "
echo
export BOT_TOKEN
go run main.go

Для Windows, в PowerShell:

$secret = Read-Host "Токен бота" -AsSecureString
$env:BOT_TOKEN = [System.Net.NetworkCredential]::new("", $secret).Password
Remove-Variable secret
go run main.go

После сборки появится строка Бот запущен. Для остановки нажмите Ctrl+C. Окно терминала оставляем открытым, в нём сейчас работает программа. Один бот, один запущенный экземпляр; второй терминал с тем же кодом и токеном создаст лишнего получателя сообщений.

Перейдите в личный диалог с этим ботом, нажмите Start или отправьте /start, затем /materials. В ответ должен прийти список с фартуком, полотенцем и коробкой. Кстати, код отсеивает сообщения из групп и сообщения без текста, поэтому проверка фотографией в общем чате здесь ничего не покажет.

Проверьте связь команды с ответом

Пройдите таблицу в личном диалоге. Для первого опыта достаточно нескольких сообщений подряд, чтобы отличить верный ответ от случайно оставшейся старой программы.

Действие в диалоге Что должен вернуть этот код Что проверить при другом результате
Отправить /start Подсказку с /materials и /help Имя бота и открытый терминал с надписью о запуске
Отправить /materials Список вещей для занятия Команду без добавочного текста и ветку case "/materials": в файле
Написать Проверяю связь Получено: Проверяю связь и подсказку Сохранён ли полный файл и запущена ли именно эта программа
Снова отправить /materials Такой же подготовленный список Один ли экземпляр работает; ошибки в терминале
Отправить фото без подписи Ответа от этого кода нет Проверка ожидаемая, обработчика фотографий в примере нет

Если пришла другая подсказка, сопоставьте имя бота в диалоге с ботом, чей токен ввели. Подготовленный ответ ведь должен воспроизводиться дословно. Когда такой ответ получен на нужную команду, получилась вся цепочка от сообщения участника до отправленной памятки.

Как остановить бота и разобрать отказ

Для остановки нажмите Ctrl+C в терминале и дождитесь приглашения вводить команды. После этого программа перестанет отвечать. При изменении списка сохраните main.go и снова выполните go run main.go в том же окне, пока переменная BOT_TOKEN ещё задана.

В этом примере номер последнего обновления хранится только в памяти программы. При остановке перед следующим запросом к Telegram последнее сообщение может прийти повторно, и после нового запуска бот ответит на него ещё раз. Для памятки дубль заметен в чате и легко удаляется; приём заявок или платежей требует отдельного учёта уже обработанных действий. Этот код разумно использовать для знакомства и выдачи справочного текста.

При ошибке программа останавливается и пишет её в терминал. Действуйте по сообщению:

  • Сначала задайте BOT_TOKEN означает, что токен нужно ввести командами для своей системы выше.
  • API 401 обычно означает отказ авторизации. Проверьте, целиком ли скопирован действующий токен нужного бота; если BotFather выдал новый, используйте его.
  • API 409 при получении сообщений обычно указывает на конфликт получателей. Завершите другой запущенный экземпляр или вернитесь к отдельному боту.
  • Сообщение про уже настроенный webhook означает, что нужен другой бот для примера. Программа сама это подключение не удаляет.
  • При оборванной связи проверьте доступ к Telegram с этого компьютера. Если ошибка возникла при отправке ответа, сначала посмотрите диалог, сообщение могло успеть дойти. Повторный запуск тогда способен дать дубль.

Пустое окно без новых строк само по себе нормально, код выводит только запуск и ошибки. Входящие тексты и токен в журнал терминала он не пишет.

Закончив работу, удалите токен из окружения. На Mac выполните unset BOT_TOKEN, в PowerShell Remove-Item Env:BOT_TOKEN. При следующем запуске в новом окне потребуется снова ввести токен.

Вопросы и ответы

Нужно ли подключать ИИ для такого бота?

Для заранее написанного списка достаточно команды и готового ответа. В примере вызывается только Telegram Bot API, подписки конструктора и модельного API для него не нужны. ИИ имеет смысл подключать, когда задача требует сформулировать ответ по новому вопросу или обработать содержание сообщения.

Будет ли бот отвечать после закрытия ноутбука?

Программа должна оставаться запущенной на компьютере с доступом к интернету. После закрытия терминала или перехода компьютера в сон ответы прекратятся. Для постоянной работы понадобится отдельный запуск на сервере с перезапуском после сбоев; условия и стоимость такого размещения выбирают отдельно.

Можно ли использовать этого бота в группе?

В приведённом коде принимаются только сообщения из личного диалога, это проверка m.Chat.Type == "private". Для группы нужно отдельно определить команды, права и подходящий способ получать сообщения. Особенности доступа к сообщениям описаны в Telegram Bots FAQ.

Где добавить новую команду?

Внутри switch функции reply добавьте ещё одну ветку по образцу /materials, например case "/address":, и ниже return со своим ответом. После сохранения остановите программу и запустите заново. Затем отправьте новую команду в диалоге и сравните ответ с текстом файла.

Можно ли сразу собирать заявки участников?

Для заявок понадобятся поля, место хранения и понятное подтверждение. Нынешний ответ «Получено» лишь возвращает входящий текст отправителю, в таблицу организатора он ничего не записывает. Сначала составьте сценарий диалога бота, включая исправление данных и отмену, затем добавляйте хранение заявок.

От памятки к следующей задаче

Когда /materials выдаёт нужный список, замените пример настоящей памяткой и проверьте ответ ещё раз. Уже есть маленькая полезная программа, поведение которой видно целиком в одном файле. Следующую команду выбирайте из реальных вопросов участников, так будет понятно, зачем она нужна.

Если хочется продолжить с более сложными сценариями, в практике клуба по ботам разбираются задачи и проекты участников. Уроки открываются постепенно, создание ботов относится к позднему этапу программы; по ходу работы можно задавать вопросы и приносить проект на ручной разбор. Начните с одного ответа, который действительно пригодится человеку по ту сторону чата.