Enhance bot functionality in main.go by adding support for callback queries and user creation flow. Introduce new types for callback queries and inline keyboard markup, and refactor message handling to accommodate these features. Update allowed updates in getUpdates method to include callback queries.
Publish telemt-bot Docker image / build-and-push (push) Successful in 54s

This commit is contained in:
Denozordec
2026-03-22 12:09:07 +07:00
parent a310d7e6d4
commit 00ec6eb4f4
+246 -18
View File
@@ -44,11 +44,12 @@ type config struct {
}
type bot struct {
cfg config
httpClient *http.Client
telemtClient *http.Client
telegramBase string
offset int64
cfg config
httpClient *http.Client
telemtClient *http.Client
telegramBase string
offset int64
awaitingUsername map[int64]bool
}
type tgGetUpdatesResponse struct {
@@ -57,19 +58,42 @@ type tgGetUpdatesResponse struct {
}
type tgUpdate struct {
UpdateID int64 `json:"update_id"`
Message *tgMessage `json:"message"`
UpdateID int64 `json:"update_id"`
Message *tgMessage `json:"message"`
CallbackQuery *tgCallbackQuery `json:"callback_query"`
}
type tgMessage struct {
Chat tgChat `json:"chat"`
Text string `json:"text"`
MessageID int64 `json:"message_id"`
Chat tgChat `json:"chat"`
Text string `json:"text"`
}
type tgChat struct {
ID int64 `json:"id"`
}
type tgCallbackQuery struct {
ID string `json:"id"`
From tgUser `json:"from"`
Message *tgMessage `json:"message"`
Data string `json:"data"`
}
type tgUser struct {
ID int64 `json:"id"`
}
type inlineKeyboardButton struct {
Text string `json:"text"`
CallbackData string `json:"callback_data,omitempty"`
Style string `json:"style,omitempty"`
}
type inlineKeyboardMarkup struct {
InlineKeyboard [][]inlineKeyboardButton `json:"inline_keyboard"`
}
type telemtEnvelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
@@ -201,10 +225,11 @@ func main() {
}
b := &bot{
cfg: cfg,
httpClient: httpClient,
telemtClient: telemtClient,
telegramBase: "https://api.telegram.org/bot" + cfg.TelegramBotToken,
cfg: cfg,
httpClient: httpClient,
telemtClient: telemtClient,
telegramBase: "https://api.telegram.org/bot" + cfg.TelegramBotToken,
awaitingUsername: make(map[int64]bool),
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
@@ -278,6 +303,10 @@ func parseIntEnv(key string, fallback int) int {
}
func (b *bot) run(ctx context.Context) error {
if err := b.setMyCommands(ctx); err != nil {
log.Printf("setMyCommands error: %v", err)
}
for {
select {
case <-ctx.Done():
@@ -297,6 +326,12 @@ func (b *bot) run(ctx context.Context) error {
if u.UpdateID >= b.offset {
b.offset = u.UpdateID + 1
}
if u.CallbackQuery != nil {
if err := b.handleCallbackQuery(ctx, u.CallbackQuery); err != nil {
log.Printf("handleCallbackQuery error: %v", err)
}
continue
}
if u.Message == nil || strings.TrimSpace(u.Message.Text) == "" {
continue
}
@@ -311,7 +346,7 @@ func (b *bot) getUpdates(ctx context.Context) ([]tgUpdate, error) {
reqBody := map[string]any{
"timeout": defaultListenPollTimeout,
"offset": b.offset,
"allowed_updates": []string{"message"},
"allowed_updates": []string{"message", "callback_query"},
}
var body bytes.Buffer
@@ -356,13 +391,19 @@ func (b *bot) handleMessage(ctx context.Context, msg *tgMessage) error {
return nil
}
if b.awaitingUsername[chatID] && !strings.HasPrefix(text, "/") {
delete(b.awaitingUsername, chatID)
return b.handleCreateUserDirect(ctx, chatID, text)
}
delete(b.awaitingUsername, chatID)
command := strings.Fields(text)[0]
if at := strings.IndexByte(command, '@'); at > 0 {
command = command[:at]
}
switch command {
case "/start", "/help":
return b.sendMessage(ctx, chatID, "Команды:\n/health\n/summary\n/users\n/create_user <username>")
return b.sendMessageWithKeyboard(ctx, chatID, "Выберите действие:", mainMenuKeyboard())
case "/health":
return b.handleHealth(ctx, chatID)
case "/summary":
@@ -372,7 +413,7 @@ func (b *bot) handleMessage(ctx context.Context, msg *tgMessage) error {
case "/create_user", "/createuser":
return b.handleCreateUser(ctx, chatID, text)
default:
return b.sendMessage(ctx, chatID, "Неизвестная команда. Используй /help")
return b.sendMessageWithKeyboard(ctx, chatID, "Неизвестная команда.", mainMenuKeyboard())
}
}
@@ -599,6 +640,145 @@ func isValidUsername(v string) bool {
return true
}
func (b *bot) handleCallbackQuery(ctx context.Context, cq *tgCallbackQuery) error {
if cq.Message == nil {
return nil
}
chatID := cq.Message.Chat.ID
msgID := cq.Message.MessageID
if chatID != b.cfg.AdminChatID {
return b.answerCallbackQuery(ctx, cq.ID, "Доступ запрещён", true)
}
switch cq.Data {
case "cmd_health":
b.answerCallbackQuery(ctx, cq.ID, "", false)
return b.handleHealthCB(ctx, chatID, msgID)
case "cmd_summary":
b.answerCallbackQuery(ctx, cq.ID, "", false)
return b.handleSummaryCB(ctx, chatID, msgID)
case "cmd_users":
b.answerCallbackQuery(ctx, cq.ID, "", false)
return b.handleUsersCB(ctx, chatID, msgID)
case "cmd_create_user":
b.answerCallbackQuery(ctx, cq.ID, "", false)
b.awaitingUsername[chatID] = true
return b.editMessageText(ctx, chatID, msgID,
"Отправьте username нового пользователя:", cancelKeyboard())
case "cmd_menu":
b.answerCallbackQuery(ctx, cq.ID, "", false)
delete(b.awaitingUsername, chatID)
return b.editMessageText(ctx, chatID, msgID, "Выберите действие:", mainMenuKeyboard())
default:
return b.answerCallbackQuery(ctx, cq.ID, "Неизвестное действие", false)
}
}
func (b *bot) handleHealthCB(ctx context.Context, chatID, msgID int64) error {
var h healthData
if err := b.callTelemt(ctx, "/v1/health", &h); err != nil {
return b.editMessageText(ctx, chatID, msgID, "Ошибка API: "+err.Error(), backKeyboard())
}
msg := fmt.Sprintf("Health: %s\nRead-only: %t", h.Status, h.ReadOnly)
return b.editMessageText(ctx, chatID, msgID, msg, backKeyboard())
}
func (b *bot) handleSummaryCB(ctx context.Context, chatID, msgID int64) error {
var s summaryData
if err := b.callTelemt(ctx, "/v1/stats/summary", &s); err != nil {
return b.editMessageText(ctx, chatID, msgID, "Ошибка API: "+err.Error(), backKeyboard())
}
msg := fmt.Sprintf(
"Uptime: %.0fs\nConn total: %d\nConn bad: %d\nHandshake timeouts: %d\nUsers: %d",
s.UptimeSeconds, s.ConnectionsTotal, s.ConnectionsBadTotal, s.HandshakeTimeoutsTotal, s.ConfiguredUsers,
)
return b.editMessageText(ctx, chatID, msgID, msg, backKeyboard())
}
func (b *bot) handleUsersCB(ctx context.Context, chatID, msgID int64) error {
var users []userInfo
if err := b.callTelemt(ctx, "/v1/stats/users", &users); err != nil {
return b.editMessageText(ctx, chatID, msgID, "Ошибка API: "+err.Error(), backKeyboard())
}
if len(users) == 0 {
return b.editMessageText(ctx, chatID, msgID, "Пользователи не найдены.", backKeyboard())
}
const maxRows = 20
var sb strings.Builder
if len(users) > maxRows {
sb.WriteString(fmt.Sprintf("Показаны первые %d из %d\n", maxRows, len(users)))
}
limit := len(users)
if limit > maxRows {
limit = maxRows
}
for i := 0; i < limit; i++ {
u := users[i]
sb.WriteString(fmt.Sprintf(
"%d) %s | conn=%d | ips=%d | octets=%d\n",
i+1, u.Username, u.CurrentConnections, u.ActiveUniqueIPs, u.TotalOctets,
))
}
return b.editMessageText(ctx, chatID, msgID, strings.TrimRight(sb.String(), "\n"), backKeyboard())
}
func (b *bot) handleCreateUserDirect(ctx context.Context, chatID int64, username string) error {
username = strings.TrimSpace(username)
if !isValidUsername(username) {
return b.sendMessageWithKeyboard(ctx, chatID,
"Некорректный username. Разрешены [A-Za-z0-9_.-], длина 1..64.", mainMenuKeyboard())
}
req := createUserRequest{Username: username}
var created createUserResponse
if err := b.callTelemtJSON(ctx, http.MethodPost, "/v1/users", req, &created, http.StatusCreated); err != nil {
return b.sendMessageWithKeyboard(ctx, chatID,
"Ошибка создания пользователя: "+err.Error(), mainMenuKeyboard())
}
links := b.resolveUserLinks(ctx, created.User.Username, created.Secret, created.User.Links)
msg := fmt.Sprintf("Пользователь создан: %s\nsecret: %s", created.User.Username, created.Secret)
if linksText := formatUserLinks(links); linksText != "" {
msg = msg + "\n\nСсылки:\n" + linksText
} else {
msg = msg + "\n\nСсылки не вернулись из API."
}
return b.sendMessageWithKeyboard(ctx, chatID, msg, backKeyboard())
}
func mainMenuKeyboard() *inlineKeyboardMarkup {
return &inlineKeyboardMarkup{
InlineKeyboard: [][]inlineKeyboardButton{
{
{Text: "Health", CallbackData: "cmd_health", Style: "primary"},
{Text: "Summary", CallbackData: "cmd_summary", Style: "primary"},
},
{
{Text: "Users", CallbackData: "cmd_users", Style: "primary"},
{Text: "Create User", CallbackData: "cmd_create_user", Style: "success"},
},
},
}
}
func backKeyboard() *inlineKeyboardMarkup {
return &inlineKeyboardMarkup{
InlineKeyboard: [][]inlineKeyboardButton{
{{Text: "<< Menu", CallbackData: "cmd_menu"}},
},
}
}
func cancelKeyboard() *inlineKeyboardMarkup {
return &inlineKeyboardMarkup{
InlineKeyboard: [][]inlineKeyboardButton{
{{Text: "Cancel", CallbackData: "cmd_menu", Style: "danger"}},
},
}
}
func (b *bot) callTelemt(ctx context.Context, path string, out any) error {
return b.callTelemtJSON(ctx, http.MethodGet, path, nil, out, http.StatusOK)
}
@@ -665,17 +845,65 @@ func (b *bot) callTelemtJSON(ctx context.Context, method, path string, payload a
}
func (b *bot) sendMessage(ctx context.Context, chatID int64, text string) error {
return b.sendMessageWithKeyboard(ctx, chatID, text, nil)
}
func (b *bot) sendMessageWithKeyboard(ctx context.Context, chatID int64, text string, keyboard *inlineKeyboardMarkup) error {
reqBody := map[string]any{
"chat_id": chatID,
"text": text,
}
if keyboard != nil {
reqBody["reply_markup"] = keyboard
}
return b.postTelegram(ctx, "/sendMessage", reqBody)
}
func (b *bot) editMessageText(ctx context.Context, chatID, messageID int64, text string, keyboard *inlineKeyboardMarkup) error {
reqBody := map[string]any{
"chat_id": chatID,
"message_id": messageID,
"text": text,
}
if keyboard != nil {
reqBody["reply_markup"] = keyboard
}
return b.postTelegram(ctx, "/editMessageText", reqBody)
}
func (b *bot) answerCallbackQuery(ctx context.Context, callbackQueryID, text string, showAlert bool) error {
reqBody := map[string]any{
"callback_query_id": callbackQueryID,
}
if text != "" {
reqBody["text"] = text
reqBody["show_alert"] = showAlert
}
return b.postTelegram(ctx, "/answerCallbackQuery", reqBody)
}
func (b *bot) setMyCommands(ctx context.Context) error {
commands := []map[string]string{
{"command": "start", "description": "Открыть меню"},
{"command": "help", "description": "Помощь"},
{"command": "health", "description": "Состояние сервера"},
{"command": "summary", "description": "Сводка статистики"},
{"command": "users", "description": "Список пользователей"},
{"command": "create_user", "description": "Создать пользователя"},
}
reqBody := map[string]any{
"commands": commands,
}
return b.postTelegram(ctx, "/setMyCommands", reqBody)
}
func (b *bot) postTelegram(ctx context.Context, method string, reqBody map[string]any) error {
var body bytes.Buffer
if err := json.NewEncoder(&body).Encode(reqBody); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.telegramBase+"/sendMessage", &body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.telegramBase+method, &body)
if err != nil {
return err
}
@@ -687,7 +915,7 @@ func (b *bot) sendMessage(ctx context.Context, chatID int64, text string) error
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("telegram sendMessage status: %s", resp.Status)
return fmt.Errorf("telegram %s status: %s", method, resp.Status)
}
return nil
}