diff --git a/main.go b/main.go index b2be838..5d7500b 100644 --- a/main.go +++ b/main.go @@ -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 ") + 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 }