Гулливер у Свифта просит помощи у разумных лошадей. Те внимательно слушают, потом беседуют между собой. Путешественнику остаётся ждать — или выучить ржание и вклиниться в разговор. Пользователю бота хочется обойтись родным языком: задать вопрос человеку и получить понятный ответ.

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

Допустим, вы устраиваете обмен комнатными растениями. Дата и место уже в канале. Участник держит черенок с корнями, а горшка нет. Обсуждать один стаканчик со всем каналом неловко. Сделаем для таких вопросов отдельную дверь — с ручкой с обеих сторон.

Как создать Telegram-бота обратной связи с двумя диалогами

Вопрос про черенок сначала показывается отправителю с кнопками «Отправить» и «Отмена». После подтверждения организатор получает текст с номером и отвечает: «Да, запасные горшки будут на столе». Бот возвращает ответ автору вопроса.

Теперь можно идти с черенком как есть. А после одного «Вопрос принят» придётся ждать. К вечеру растение рискует укорениться прямо в ветке переписки; хорошо ещё, если участник не начнёт поливать телефон. Организатор знает, будут ли горшки. Дадим ему возможность ответить.

Для прототипа нужны Go, sqlite3 с режимом -json и Bash. Один процесс, один организатор в личном диалоге, вопросы текстом до 1500 знаков. Если пока не знаете, где это запускать, начните с бесплатного чата DeepSeek. Вставьте запрос и следом эту статью с кодом. ChatGPT или Claude тоже подойдут, если уже ими пользуетесь.

Помоги собрать бота обратной связи по статье, которую пришлю следом.
Начнём с малого: запустить программу и получить мой ID командой /id.
Сначала спроси систему компьютера и помоги проверить Go, sqlite3
с режимом -json и подходящий терминал для Bash. Давай одно действие:
что открыть, куда вставить код или команду, какой результат ждать.
Жди моего ответа. Токен и пароли я введу только у себя.
Ошибки покажу без секретов и личных данных: разбери причину,
предложи одну проверку; спорное сверяй с официальной документацией.
Сохрани связь номера вопроса с адресатом, подтверждение и отмену.
После /id проведи через вопрос и ответ в двух разных диалогах.
Если не уверен, скажи; без поиска попроси нужный фрагмент справки.

Код сохраняют в файл, команды запуска вводят в терминале, а /id отправляют в Telegram. Если перепутали эти места, опишите помощнику, что открыто и какой ответ получили. К следующему шагу переходите после понятного результата.

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

Номер удерживает ответ рядом с вопросом

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

Поэтому в SQLite у каждого вопроса своя строка: номер, ID диалога, текст, состояние. ID служит числовым адресом. Команда /reply номер ответ выбирает именно эту строку; ответ возвращается по сохранённому адресу.

До подтверждения текст остаётся черновиком. Новый текст отменяет только прежний черновик этого человека. Кнопка тоже привязана к владельцу: чужое нажатие не должно запускать отправку. А старое подтверждение после отмены уже ничего не отправляет. Это разные случаи одной задачи — сохранить связь человека с его вопросом.

В учебнике Telegram и Bot API описаны нужные части: getUpdates получает события, sendMessage отправляет текст, callback_query сообщает о кнопке, answerCallbackQuery отмечает её нажатие. Связь вопроса с человеком хранит уже наша программа.

Сохраните код в main.go

В отдельной папке создайте main.go и скопируйте весь блок. Рядом появится feedback.db с вопросами, адресатами и состояниями.

Параметры SQLite -json и .timeout описаны в справке sqlite3. Внешние библиотеки Go здесь не нужны.

package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"
)

type M = map[string]any
type Message struct {
	Chat struct {
		ID   int64
		Type string
	}
	Text string
}
type Callback struct {
	ID, Data string
	From     struct{ ID int64 }
}
type Update struct {
	ID       int64     `json:"update_id"`
	Message  *Message  `json:"message"`
	Callback *Callback `json:"callback_query"`
}
type Row struct {
	ID, U int64
	Q, S  string
}

var database = "feedback.db"
var endpoint string
var admin int64
var client = &http.Client{Timeout: 35 * time.Second}

func sql(q string) []Row {
	b, e := exec.Command("sqlite3", "-bail", "-json", "-cmd", ".timeout 3000", database, q).Output()
	if e != nil {
		panic("Ошибка SQLite: остановка")
	}
	var r []Row
	if len(bytes.TrimSpace(b)) > 0 {
		if json.Unmarshal(b, &r) != nil {
			panic("Ответ SQLite не разобран")
		}
	}
	return r
}
func quote(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
func api(method string, p M) (json.RawMessage, error) {
	b, e := json.Marshal(p)
	if e != nil {
		return nil, e
	}
	r, e := client.Post(endpoint+method, "application/json", bytes.NewReader(b))
	if e != nil {
		return nil, errors.New("Сеть: исход отправки неизвестен; проверьте диалог")
	}
	defer r.Body.Close()
	var v struct {
		OK     bool
		Result json.RawMessage
	}
	if json.NewDecoder(io.LimitReader(r.Body, 2<<20)).Decode(&v) != nil || r.StatusCode != 200 || !v.OK {
		return nil, errors.New("Telegram не подтвердил операцию; проверьте диалог")
	}
	return v.Result, nil
}
func call(method string, p M) json.RawMessage {
	b, e := api(method, p)
	if e != nil {
		panic(e)
	}
	return b
}
func send(id int64, text string) { call("sendMessage", M{"chat_id": id, "text": text}) }
func initDB() {
	sql(`CREATE TABLE IF NOT EXISTS tickets(id INTEGER PRIMARY KEY,u INTEGER,q TEXT,s TEXT);
 CREATE TABLE IF NOT EXISTS meta(v INTEGER);INSERT INTO meta SELECT 0 WHERE NOT EXISTS(SELECT 1 FROM meta);`)
}
func handle(u Update) {
	if c := u.Callback; c != nil {
		api("answerCallbackQuery", M{"callback_query_id": c.ID})
		kind, num, ok := strings.Cut(c.Data, ":")
		id, e := strconv.ParseInt(num, 10, 64)
		if !ok || e != nil || (kind != "ok" && kind != "no") {
			return
		}
		r := sql(fmt.Sprintf("SELECT * FROM tickets WHERE id=%d", id))
		if len(r) != 1 || r[0].U != c.From.ID {
			return
		}
		if r[0].S != "draft" {
			send(c.From.ID, "Эта кнопка уже обработана.")
			return
		}
		if kind == "no" {
			sql(fmt.Sprintf("UPDATE tickets SET s='cancelled' WHERE id=%d", id))
			send(c.From.ID, "Вопрос отменён.")
			return
		}
		if admin == 0 {
			send(c.From.ID, "Оператор ещё не настроен.")
			return
		}
		sql(fmt.Sprintf("UPDATE tickets SET s='sending' WHERE id=%d", id))
		send(admin, fmt.Sprintf("Вопрос №%d\n%s\nОтвет: /reply %d ваш текст", id, r[0].Q, id))
		sql(fmt.Sprintf("UPDATE tickets SET s='sent' WHERE id=%d", id))
		send(c.From.ID, fmt.Sprintf("Вопрос №%d передан человеку. Ответ придёт сюда.", id))
		return
	}
	m := u.Message
	if m == nil || m.Chat.Type != "private" {
		return
	}
	who := m.Chat.ID
	if m.Text == "/id" {
		send(who, strconv.FormatInt(who, 10))
		return
	}
	if admin == 0 {
		send(who, "Идёт настройка. Оператор может узнать свой ID командой /id.")
		return
	}
	if who == admin {
		p := strings.SplitN(m.Text, " ", 3)
		if len(p) != 3 || p[0] != "/reply" {
			send(who, "Ответьте: /reply номер текст")
			return
		}
		id, e := strconv.ParseInt(p[1], 10, 64)
		if e != nil {
			return
		}
		r := sql(fmt.Sprintf("SELECT * FROM tickets WHERE id=%d AND s='sent'", id))
		if len(r) != 1 {
			send(who, "Номер не найден или ответ уже начат.")
			return
		}
		if utf8.RuneCountInString(p[2]) > 1500 {
			send(who, "Ответ — до 1500 знаков.")
			return
		}
		sql(fmt.Sprintf("UPDATE tickets SET s='answering' WHERE id=%d", id))
		send(r[0].U, fmt.Sprintf("Ответ человека на вопрос №%d:\n%s", id, p[2]))
		sql(fmt.Sprintf("UPDATE tickets SET s='answered' WHERE id=%d", id))
		send(who, "Ответ отправлен.")
		return
	}
	if m.Text == "/start" {
		send(who, "Вопрос прочитает организатор обмена растениями. Текст и ID диалога сохраняются у него до конца проверки. Пришлите один текстовый вопрос, затем подтвердите отправку. Для удаления напишите в этом же диалоге.")
		return
	}
	if strings.TrimSpace(m.Text) == "" || utf8.RuneCountInString(m.Text) > 1500 || strings.ContainsRune(m.Text, 0) {
		send(who, "Пришлите обычный текст до 1500 знаков; вложения пока не принимаются.")
		return
	}
	sql(fmt.Sprintf("BEGIN;UPDATE tickets SET s='cancelled' WHERE u=%d AND s='draft' AND id<>%d;INSERT OR IGNORE INTO tickets VALUES(%d,%d,%s,'draft');COMMIT;", who, u.ID, u.ID, who, quote(m.Text)))
	buttons := [][]M{{{"text": "Отправить", "callback_data": fmt.Sprintf("ok:%d", u.ID)}, {"text": "Отмена", "callback_data": fmt.Sprintf("no:%d", u.ID)}}}
	call("sendMessage", M{"chat_id": who, "text": "Передать организатору?\n" + m.Text, "reply_markup": M{"inline_keyboard": buttons}})
}
func main() {
	token := os.Getenv("BOT_TOKEN")
	if token == "" {
		panic("Задайте BOT_TOKEN")
	}
	var e error
	if s := os.Getenv("ADMIN_CHAT_ID"); s != "" {
		admin, e = strconv.ParseInt(s, 10, 64)
		if e != nil || admin <= 0 {
			panic("Нужен ID личного диалога оператора")
		}
	}
	endpoint = "https://api.telegram.org/bot" + token + "/"
	initDB()
	var hook struct{ URL string }
	if json.Unmarshal(call("getWebhookInfo", M{}), &hook) != nil {
		panic("Webhook не разобран")
	}
	if hook.URL != "" {
		panic("У бота уже есть webhook. Используйте отдельного бота")
	}
	for {
		offset := sql("SELECT v AS id FROM meta")[0].ID
		var updates []Update
		b := call("getUpdates", M{"offset": offset, "timeout": 25, "allowed_updates": []string{"message", "callback_query"}})
		if json.Unmarshal(b, &updates) != nil {
			panic("Обновления не разобраны")
		}
		for _, u := range updates {
			handle(u)
			sql(fmt.Sprintf("UPDATE meta SET v=%d", u.ID+1))
		}
	}
}

Сначала представьтесь своему боту

В Bash внутри этой папки введите токен командой read: он не появится на экране и не попадёт в исходник.

read -r -s -p "Токен бота: " BOT_TOKEN
export BOT_TOKEN
go run main.go

В личном диалоге со своим ботом отправьте /id. Он вернёт числовой ID этого диалога. Остановите программу сочетанием Ctrl+C, затем задайте полученное число и запустите снова:

export ADMIN_CHAT_ID="число_из_ответа_бота"
go run main.go

Слова в кавычках замените полученным числом. Теперь это диалог организатора. Посетителем побудет согласившийся помочь знакомый: операторский диалог программа обрабатывает иначе.

При существующем webhook запуск остановится: Telegram уже отправляет события другому получателю. Возьмите отдельного бота. Совместимость способов доставки объясняет Telegram Bots FAQ.

Создать Telegram-бота обратной связи помогает именно связка номера и отправителя: теперь проверим, что ответ доходит до нужного человека.

Пройдите вопрос в обе стороны

Попросите помощника открыть бота и отправить /start. Он увидит, кто прочитает вопрос и что сохранится. Первую пробу начните с отмены: пусть человек напишет вопрос про черенок и нажмёт «Отмена». Проверяйте именно свой операторский диалог — нового вопроса в нём быть не должно.

Теперь пусть он напишет вопрос заново и нажмёт «Отправить». Вы увидите номер и подсказку для ответа. Вместо слов «ваш текст» напишите короткий ответ, например:

/reply 101 Да, запасные горшки будут на столе.

Замените 101 номером из своего сообщения. Номера берутся из событий Telegram и могут идти с пропусками. Ответ должен появиться у того помощника, который отправил вопрос. Только после этой проверки стоит давать ссылку остальным.

Проверка Что должно произойти
Отмена черновика Организатор не получает вопрос
Подтверждение Организатор видит текст и номер
Ответ по этому номеру Текст возвращается его отправителю
Два человека пишут почти одновременно У каждого свой номер и свой ответ
Повторное нажатие старой кнопки Второй вопрос организатору не уходит
Повтор команды ответа на завершённый номер Бот сообщает организатору, что ответ уже начат или номер не найден

Затем пусть один помощник спросит про горшок, второй — про вход. Ответьте сначала второму. У входа должны узнать дорогу; корни должны остаться в разговоре о растении. Такая перестановка проверяет связь адресов лучше, чем два послушных вопроса по очереди.

Если разговор оборвался

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

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

После остановки можно посмотреть последние состояния:

sqlite3 feedback.db "SELECT id,s FROM tickets ORDER BY id DESC LIMIT 10;"

draft означает черновик, cancelled — отмену, sent — передачу организатору, answered — отправку ответа. При sending или answering сначала сравните переписку. Пример не восстанавливает спорные отправки автоматически; не меняйте эти отметки наугад. Для следующей пробы возьмите новый вопрос и проследите его отдельно.

Ещё один возможный отказ — посетитель заблокировал бота. Программа должна встретить его как ошибку отправки, без отметки answered. Даже обычная успешная отправка ещё не доказывает чтение: попросите помощника подтвердить, какой текст он получил.

Что можно поручить ИИ

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

Если хочется заменить команды понятными действиями в диалоге, продолжите инструкцией о кнопках Telegram-бота. Сначала сохраните работающую цепочку вопроса и ответа: кнопка должна запускать уже понятное действие.

Частые вопросы

Можно ответить обычным сообщением в диалоге организатора?

Для ответа нужна команда /reply номер текст: она указывает, какому вопросу принадлежит сообщение. Без номера бот не выбирает посетителя по догадке.

Будет ли переписка анонимной?

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

Можно прислать фотографию растения?

Этот код принимает только текст. Изображения требуют отдельной обработки, передачи и проверки; кнопку или обещание приёма фото пока добавлять незачем.

Что делать, если ответ пришёл другому человеку?

Остановите проверку и разберите связь номера с адресом. Не заменяйте её общей переменной «последний пользователь». Повторите два вопроса с ответами в обратном порядке; каждый помощник должен увидеть только свой ответ.

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

Участник получил ответ и собрался на встречу, организатор вернулся к столу. Черенок будет пускать корни в горшке. В переписке ему укореняться незачем.