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)
}