diff --git a/main.go b/main.go index d40d37b..07542f9 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,9 @@ const ( defaultTelemtTimeoutSeconds = 5 defaultTelegramTimeoutSeconds = 60 defaultListenPollTimeout = 50 + + usersPerPage = 12 + usersPageCbPrefix = "u_pg:" ) type config struct { @@ -448,23 +451,8 @@ func (b *bot) handleUsers(ctx context.Context, chatID int64) error { return b.sendMessage(ctx, chatID, "Пользователи не найдены.") } - 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.sendMessage(ctx, chatID, strings.TrimRight(sb.String(), "\n")) + text, kb := buildUsersListMessage(users, 0) + return b.sendMessageWithKeyboardHTML(ctx, chatID, text, kb, true) } func (b *bot) handleCreateUser(ctx context.Context, chatID int64, text string) error { @@ -670,6 +658,17 @@ func (b *bot) handleCallbackQuery(ctx context.Context, cq *tgCallbackQuery) erro delete(b.awaitingUsername, chatID) return b.editMessageText(ctx, chatID, msgID, "Выберите действие:", mainMenuKeyboard()) default: + if strings.HasPrefix(cq.Data, usersPageCbPrefix) { + rest := strings.TrimPrefix(cq.Data, usersPageCbPrefix) + page, err := strconv.Atoi(rest) + if err != nil || page < 0 { + return b.answerCallbackQuery(ctx, cq.ID, "Некорректная страница", true) + } + if err := b.answerCallbackQuery(ctx, cq.ID, "", false); err != nil { + return err + } + return b.handleUsersPageCB(ctx, chatID, msgID, page) + } return b.answerCallbackQuery(ctx, cq.ID, "Неизвестное действие", false) } } @@ -696,6 +695,10 @@ func (b *bot) handleSummaryCB(ctx context.Context, chatID, msgID int64) error { } func (b *bot) handleUsersCB(ctx context.Context, chatID, msgID int64) error { + return b.handleUsersPageCB(ctx, chatID, msgID, 0) +} + +func (b *bot) handleUsersPageCB(ctx context.Context, chatID, msgID int64, page int) 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()) @@ -705,23 +708,123 @@ func (b *bot) handleUsersCB(ctx context.Context, chatID, msgID int64) error { return b.editMessageText(ctx, chatID, msgID, "Пользователи не найдены.", backKeyboard()) } - const maxRows = 20 + totalPages := usersTotalPages(len(users)) + if page >= totalPages { + page = totalPages - 1 + } + if page < 0 { + page = 0 + } + + text, kb := buildUsersListMessage(users, page) + return b.editMessageTextHTML(ctx, chatID, msgID, text, kb, true) +} + +func usersTotalPages(n int) int { + if n == 0 { + return 1 + } + pages := n / usersPerPage + if n%usersPerPage != 0 { + pages++ + } + if pages < 1 { + return 1 + } + return pages +} + +func escapeHTML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +// formatTrafficRu форматирует объём в октетах (байтах) в удобочитаемый вид (двоичные приставки). +func formatTrafficRu(octets uint64) string { + if octets == 0 { + return "0 Б" + } + const ( + kib = 1024 + mib = kib * 1024 + gib = mib * 1024 + tib = gib * 1024 + ) + switch { + case octets >= tib: + return fmt.Sprintf("%.2f ТиБ", float64(octets)/float64(tib)) + case octets >= gib: + return fmt.Sprintf("%.2f ГиБ", float64(octets)/float64(gib)) + case octets >= mib: + return fmt.Sprintf("%.2f МиБ", float64(octets)/float64(mib)) + case octets >= kib: + return fmt.Sprintf("%.1f КиБ", float64(octets)/float64(kib)) + default: + return fmt.Sprintf("%d Б", octets) + } +} + +func buildUsersListMessage(users []userInfo, page int) (string, *inlineKeyboardMarkup) { + total := len(users) + totalPages := usersTotalPages(total) + start := page * usersPerPage + if start > total { + start = 0 + } + end := start + usersPerPage + if end > total { + end = total + } + 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++ { + sb.WriteString("Пользователи") + sb.WriteString("\n") + sb.WriteString(fmt.Sprintf("Страница %d из %d · всего записей: %d", page+1, totalPages, total)) + sb.WriteString("\n\n") + + for i := start; i < end; i++ { u := users[i] + globalIdx := i + 1 + sb.WriteString(fmt.Sprintf("%d. %s\n", globalIdx, escapeHTML(u.Username))) sb.WriteString(fmt.Sprintf( - "%d) %s | conn=%d | ips=%d | octets=%d\n", - i+1, u.Username, u.CurrentConnections, u.ActiveUniqueIPs, u.TotalOctets, + " соединений: %d · уник. IP: %d · трафик: %s\n", + u.CurrentConnections, u.ActiveUniqueIPs, formatTrafficRu(u.TotalOctets), )) + if i+1 < end { + sb.WriteString("\n") + } } - return b.editMessageText(ctx, chatID, msgID, strings.TrimRight(sb.String(), "\n"), backKeyboard()) + + kb := usersListKeyboard(page, totalPages) + return strings.TrimRight(sb.String(), "\n"), kb +} + +func usersListKeyboard(page, totalPages int) *inlineKeyboardMarkup { + var rows [][]inlineKeyboardButton + var nav []inlineKeyboardButton + if page > 0 { + nav = append(nav, inlineKeyboardButton{ + Text: "◀ Назад", + CallbackData: usersPageCbPrefix + strconv.Itoa(page-1), + Style: "primary", + }) + } + if page < totalPages-1 { + nav = append(nav, inlineKeyboardButton{ + Text: "Вперёд ▶", + CallbackData: usersPageCbPrefix + strconv.Itoa(page+1), + Style: "primary", + }) + } + if len(nav) > 0 { + rows = append(rows, nav) + } + rows = append(rows, []inlineKeyboardButton{ + {Text: "« В меню", CallbackData: "cmd_menu"}, + }) + return &inlineKeyboardMarkup{InlineKeyboard: rows} } func (b *bot) handleCreateUserDirect(ctx context.Context, chatID int64, username string) error { @@ -849,6 +952,15 @@ func (b *bot) sendMessage(ctx context.Context, chatID int64, text string) error } func (b *bot) sendMessageWithKeyboard(ctx context.Context, chatID int64, text string, keyboard *inlineKeyboardMarkup) error { + return b.sendMessageWithKeyboardOpts(ctx, chatID, text, keyboard, "", false) +} + +// sendMessageWithKeyboardHTML — сообщение с разметкой HTML (см. Bot API: formatting options, link_preview_options). +func (b *bot) sendMessageWithKeyboardHTML(ctx context.Context, chatID int64, text string, keyboard *inlineKeyboardMarkup, disableLinkPreview bool) error { + return b.sendMessageWithKeyboardOpts(ctx, chatID, text, keyboard, "HTML", disableLinkPreview) +} + +func (b *bot) sendMessageWithKeyboardOpts(ctx context.Context, chatID int64, text string, keyboard *inlineKeyboardMarkup, parseMode string, disableLinkPreview bool) error { reqBody := map[string]any{ "chat_id": chatID, "text": text, @@ -856,10 +968,24 @@ func (b *bot) sendMessageWithKeyboard(ctx context.Context, chatID int64, text st if keyboard != nil { reqBody["reply_markup"] = keyboard } + if parseMode != "" { + reqBody["parse_mode"] = parseMode + } + if disableLinkPreview { + reqBody["link_preview_options"] = map[string]bool{"is_disabled": true} + } return b.postTelegram(ctx, "/sendMessage", reqBody) } func (b *bot) editMessageText(ctx context.Context, chatID, messageID int64, text string, keyboard *inlineKeyboardMarkup) error { + return b.editMessageTextOpts(ctx, chatID, messageID, text, keyboard, "", false) +} + +func (b *bot) editMessageTextHTML(ctx context.Context, chatID, messageID int64, text string, keyboard *inlineKeyboardMarkup, disableLinkPreview bool) error { + return b.editMessageTextOpts(ctx, chatID, messageID, text, keyboard, "HTML", disableLinkPreview) +} + +func (b *bot) editMessageTextOpts(ctx context.Context, chatID, messageID int64, text string, keyboard *inlineKeyboardMarkup, parseMode string, disableLinkPreview bool) error { reqBody := map[string]any{ "chat_id": chatID, "message_id": messageID, @@ -868,6 +994,12 @@ func (b *bot) editMessageText(ctx context.Context, chatID, messageID int64, text if keyboard != nil { reqBody["reply_markup"] = keyboard } + if parseMode != "" { + reqBody["parse_mode"] = parseMode + } + if disableLinkPreview { + reqBody["link_preview_options"] = map[string]bool{"is_disabled": true} + } return b.postTelegram(ctx, "/editMessageText", reqBody) }