package main import ( "bytes" "context" "encoding/hex" "encoding/json" "errors" "fmt" "log" "net" "net/http" "net/url" "os" "os/signal" "regexp" "strconv" "strings" "syscall" "time" "golang.org/x/net/proxy" ) const ( defaultTelemtTimeoutSeconds = 5 defaultTelegramTimeoutSeconds = 60 defaultListenPollTimeout = 50 usersPerPage = 12 usersPageCbPrefix = "u_pg:" ) type config struct { TelegramBotToken string AdminChatID int64 TelemtAPIBaseURL string TelemtAPIAuth string TelemtTimeout time.Duration TelegramTimeout time.Duration TelemtLinkHost string TelemtLinkPort int TelemtLinkTLSDomain string } type bot struct { cfg config httpClient *http.Client telemtClient *http.Client telegramBase string offset int64 awaitingUsername map[int64]bool } type tgGetUpdatesResponse struct { OK bool `json:"ok"` Result []tgUpdate `json:"result"` } type tgUpdate struct { UpdateID int64 `json:"update_id"` Message *tgMessage `json:"message"` CallbackQuery *tgCallbackQuery `json:"callback_query"` } type tgMessage struct { 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"` Error *struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } type healthData struct { Status string `json:"status"` ReadOnly bool `json:"read_only"` } type summaryData struct { UptimeSeconds float64 `json:"uptime_seconds"` ConnectionsTotal uint64 `json:"connections_total"` ConnectionsBadTotal uint64 `json:"connections_bad_total"` HandshakeTimeoutsTotal uint64 `json:"handshake_timeouts_total"` ConfiguredUsers uint64 `json:"configured_users"` } type userInfo struct { Username string `json:"username"` CurrentConnections uint64 `json:"current_connections"` ActiveUniqueIPs uint64 `json:"active_unique_ips"` TotalOctets uint64 `json:"total_octets"` Links userLinks `json:"links"` } type userLinks struct { Classic []string `json:"classic"` Secure []string `json:"secure"` TLS []string `json:"tls"` } type createUserRequest struct { Username string `json:"username"` } type createUserResponse struct { User userInfo `json:"user"` Secret string `json:"secret"` } func makeHTTPTransport() (*http.Transport, error) { tr := &http.Transport{ MaxIdleConns: 64, MaxIdleConnsPerHost: 32, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 5 * time.Second, ForceAttemptHTTP2: true, DisableCompression: false, } proxyURL := getProxyURL() if proxyURL == "" { return tr, nil } u, err := url.Parse(proxyURL) if err != nil { return nil, fmt.Errorf("invalid proxy URL %q: %w", proxyURL, err) } switch u.Scheme { case "socks5", "socks5h": dialer, err := proxy.FromURL(u, proxy.Direct) if err != nil { return nil, fmt.Errorf("socks5 proxy %q: %w", proxyURL, err) } if ctxDialer, ok := dialer.(proxy.ContextDialer); ok { tr.DialContext = ctxDialer.DialContext } else { tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { return dialer.Dial(network, addr) } } log.Printf("using SOCKS5 proxy: %s", u.Host) case "http", "https": tr.Proxy = http.ProxyURL(u) log.Printf("using HTTP proxy: %s", u.Host) default: return nil, fmt.Errorf("unsupported proxy scheme %q (use socks5, socks5h, http, or https)", u.Scheme) } return tr, nil } func makeDirectTransport() *http.Transport { return &http.Transport{ MaxIdleConns: 16, MaxIdleConnsPerHost: 8, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 5 * time.Second, ForceAttemptHTTP2: true, DisableCompression: false, } } func getProxyURL() string { for _, key := range []string{"ALL_PROXY", "all_proxy", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"} { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } } return "" } func main() { cfg, err := loadConfig() if err != nil { log.Fatalf("config error: %v", err) } transport, err := makeHTTPTransport() if err != nil { log.Fatalf("transport error: %v", err) } httpClient := &http.Client{ Transport: transport, Timeout: cfg.TelegramTimeout, } telemtClient := &http.Client{ Transport: makeDirectTransport(), Timeout: cfg.TelemtTimeout, } b := &bot{ 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) defer stop() log.Printf("telemt-bot started for admin chat %d", cfg.AdminChatID) if err := b.run(ctx); err != nil && !errors.Is(err, context.Canceled) { log.Fatalf("bot stopped with error: %v", err) } log.Println("telemt-bot stopped") } func loadConfig() (config, error) { var cfg config cfg.TelegramBotToken = strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")) if cfg.TelegramBotToken == "" { return cfg, errors.New("TELEGRAM_BOT_TOKEN is required") } adminChat := strings.TrimSpace(os.Getenv("TELEGRAM_ADMIN_CHAT_ID")) if adminChat == "" { return cfg, errors.New("TELEGRAM_ADMIN_CHAT_ID is required") } id, err := strconv.ParseInt(adminChat, 10, 64) if err != nil { return cfg, fmt.Errorf("invalid TELEGRAM_ADMIN_CHAT_ID: %w", err) } cfg.AdminChatID = id cfg.TelemtAPIBaseURL = strings.TrimRight(strings.TrimSpace(os.Getenv("TELEMT_API_BASE_URL")), "/") if cfg.TelemtAPIBaseURL == "" { return cfg, errors.New("TELEMT_API_BASE_URL is required") } if _, err := url.ParseRequestURI(cfg.TelemtAPIBaseURL); err != nil { return cfg, fmt.Errorf("invalid TELEMT_API_BASE_URL: %w", err) } cfg.TelemtAPIAuth = strings.TrimSpace(os.Getenv("TELEMT_API_AUTH_HEADER")) cfg.TelemtTimeout = parseSecondsEnv("TELEMT_HTTP_TIMEOUT_SECONDS", defaultTelemtTimeoutSeconds) cfg.TelegramTimeout = parseSecondsEnv("TELEGRAM_HTTP_TIMEOUT_SECONDS", defaultTelegramTimeoutSeconds) cfg.TelemtLinkHost = strings.TrimSpace(os.Getenv("TELEMT_LINK_HOST")) cfg.TelemtLinkPort = parseIntEnv("TELEMT_LINK_PORT", 9443) cfg.TelemtLinkTLSDomain = strings.TrimSpace(os.Getenv("TELEMT_TLS_DOMAIN")) return cfg, nil } func parseSecondsEnv(key string, fallback int) time.Duration { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return time.Duration(fallback) * time.Second } v, err := strconv.Atoi(raw) if err != nil || v <= 0 { return time.Duration(fallback) * time.Second } return time.Duration(v) * time.Second } func parseIntEnv(key string, fallback int) int { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" { return fallback } v, err := strconv.Atoi(raw) if err != nil || v <= 0 { return fallback } return v } 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(): return ctx.Err() default: } updates, err := b.getUpdates(ctx) if err != nil { log.Printf("getUpdates error: %v", err) time.Sleep(2 * time.Second) continue } for i := range updates { u := updates[i] 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 } if err := b.handleMessage(ctx, u.Message); err != nil { log.Printf("handleMessage error: %v", err) } } } } func (b *bot) getUpdates(ctx context.Context) ([]tgUpdate, error) { reqBody := map[string]any{ "timeout": defaultListenPollTimeout, "offset": b.offset, "allowed_updates": []string{"message", "callback_query"}, } var body bytes.Buffer if err := json.NewEncoder(&body).Encode(reqBody); err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.telegramBase+"/getUpdates", &body) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := b.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("telegram getUpdates status: %s", resp.Status) } var parsed tgGetUpdatesResponse if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { return nil, err } if !parsed.OK { return nil, errors.New("telegram getUpdates returned ok=false") } return parsed.Result, nil } func (b *bot) handleMessage(ctx context.Context, msg *tgMessage) error { chatID := msg.Chat.ID text := strings.TrimSpace(msg.Text) if text == "" { return nil } if chatID != b.cfg.AdminChatID { 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.sendMessageWithKeyboard(ctx, chatID, "Выберите действие:", mainMenuKeyboard()) case "/health": return b.handleHealth(ctx, chatID) case "/summary": return b.handleSummary(ctx, chatID) case "/users": return b.handleUsers(ctx, chatID) case "/create_user", "/createuser": return b.handleCreateUser(ctx, chatID, text) default: return b.sendMessageWithKeyboard(ctx, chatID, "Неизвестная команда.", mainMenuKeyboard()) } } func (b *bot) handleHealth(ctx context.Context, chatID int64) error { var h healthData if err := b.callTelemt(ctx, "/v1/health", &h); err != nil { return b.sendMessage(ctx, chatID, "Ошибка API: "+err.Error()) } text := buildHealthMessageHTML(h) return b.sendMessageWithKeyboardHTML(ctx, chatID, text, backKeyboard(), true) } func (b *bot) handleSummary(ctx context.Context, chatID int64) error { var s summaryData if err := b.callTelemt(ctx, "/v1/stats/summary", &s); err != nil { return b.sendMessage(ctx, chatID, "Ошибка API: "+err.Error()) } text := buildSummaryMessageHTML(s) return b.sendMessageWithKeyboardHTML(ctx, chatID, text, backKeyboard(), true) } func (b *bot) handleUsers(ctx context.Context, chatID int64) error { var users []userInfo if err := b.callTelemt(ctx, "/v1/stats/users", &users); err != nil { return b.sendMessage(ctx, chatID, "Ошибка API: "+err.Error()) } if len(users) == 0 { return b.sendMessage(ctx, chatID, "Пользователи не найдены.") } 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 { parts := strings.Fields(text) if len(parts) < 2 { return b.sendMessage(ctx, chatID, "Использование: /create_user ") } username := strings.TrimSpace(parts[1]) if !isValidUsername(username) { return b.sendMessage(ctx, chatID, "Некорректный username. Разрешены [A-Za-z0-9_.-], длина 1..64.") } req := createUserRequest{Username: username} var created createUserResponse if err := b.callTelemtJSON(ctx, http.MethodPost, "/v1/users", req, &created, http.StatusCreated, http.StatusAccepted); err != nil { return b.sendMessage(ctx, chatID, "Ошибка создания пользователя: "+err.Error()) } 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.sendMessage(ctx, chatID, msg) } func hasAnyLinks(links userLinks) bool { return len(links.Classic) > 0 || len(links.Secure) > 0 || len(links.TLS) > 0 } // serverParamRe matches server=... in tg://proxy URLs var serverParamRe = regexp.MustCompile(`server=[^&]+`) func deduplicateStrings(items []string) []string { if len(items) <= 1 { return items } seen := make(map[string]struct{}, len(items)) out := make([]string, 0, len(items)) for _, s := range items { if _, ok := seen[s]; ok { continue } seen[s] = struct{}{} out = append(out, s) } return out } func (b *bot) rewriteLinksWithHost(links userLinks) userLinks { host := strings.TrimSpace(b.cfg.TelemtLinkHost) if host == "" { return links } replacement := "server=" + host rewrite := func(items []string) []string { if len(items) == 0 { return items } out := make([]string, len(items)) for i, s := range items { out[i] = serverParamRe.ReplaceAllString(s, replacement) } return deduplicateStrings(out) } return userLinks{ Classic: rewrite(links.Classic), Secure: rewrite(links.Secure), TLS: rewrite(links.TLS), } } func (b *bot) resolveUserLinks(ctx context.Context, username, secret string, initial userLinks) userLinks { var result userLinks if hasAnyLinks(initial) { result = initial } else { var fetched userInfo path := "/v1/users/" + url.PathEscape(username) if err := b.callTelemt(ctx, path, &fetched); err == nil && hasAnyLinks(fetched.Links) { result = fetched.Links } else { var users []userInfo if err := b.callTelemt(ctx, "/v1/users", &users); err == nil { for i := range users { if users[i].Username == username && hasAnyLinks(users[i].Links) { result = users[i].Links break } } } if !hasAnyLinks(result) { if err := b.callTelemt(ctx, "/v1/stats/users", &users); err == nil { for i := range users { if users[i].Username == username && hasAnyLinks(users[i].Links) { result = users[i].Links break } } } } if !hasAnyLinks(result) { result = b.generateLinksFromSecret(secret) } } } return b.rewriteLinksWithHost(result) } func (b *bot) generateLinksFromSecret(secret string) userLinks { host := strings.TrimSpace(b.cfg.TelemtLinkHost) if host == "" || secret == "" { return userLinks{} } port := b.cfg.TelemtLinkPort classic := fmt.Sprintf("tg://proxy?server=%s&port=%d&secret=%s", host, port, secret) dd := fmt.Sprintf("tg://proxy?server=%s&port=%d&secret=dd%s", host, port, secret) out := userLinks{ Classic: []string{classic}, Secure: []string{dd}, } if b.cfg.TelemtLinkTLSDomain != "" { hexDomain := hex.EncodeToString([]byte(b.cfg.TelemtLinkTLSDomain)) tls := fmt.Sprintf("tg://proxy?server=%s&port=%d&secret=ee%s%s", host, port, secret, hexDomain) out.TLS = []string{tls} } return out } func formatUserLinks(links userLinks) string { var sb strings.Builder appendLinks := func(title string, items []string) { if len(items) == 0 { return } for i := range items { if len(items) == 1 { sb.WriteString(title) sb.WriteString(": ") } else { sb.WriteString(title) sb.WriteString(" [") sb.WriteString(strconv.Itoa(i + 1)) sb.WriteString("]: ") } sb.WriteString(items[i]) sb.WriteByte('\n') } } appendLinks("Classic", links.Classic) appendLinks("DD", links.Secure) appendLinks("EE-TLS", links.TLS) return strings.TrimRight(sb.String(), "\n") } func isValidUsername(v string) bool { if len(v) < 1 || len(v) > 64 { return false } for i := 0; i < len(v); i++ { ch := v[i] if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '.' || ch == '-' { continue } return false } 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: 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) } } 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()) } text := buildHealthMessageHTML(h) return b.editMessageTextHTML(ctx, chatID, msgID, text, backKeyboard(), true) } 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()) } text := buildSummaryMessageHTML(s) return b.editMessageTextHTML(ctx, chatID, msgID, text, backKeyboard(), true) } 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()) } if len(users) == 0 { return b.editMessageText(ctx, chatID, msgID, "Пользователи не найдены.", backKeyboard()) } 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 } func yesNoRu(v bool) string { if v { return "да" } return "нет" } // formatUptimeRu преобразует аптайм в секундах в читаемую строку на русском. func formatUptimeRu(seconds float64) string { if seconds < 0 { seconds = 0 } s := int64(seconds + 0.5) const day = 86400 const hour = 3600 const min = 60 d := s / day s %= day h := s / hour s %= hour m := s / min sec := s % min var parts []string if d > 0 { parts = append(parts, fmt.Sprintf("%d д.", d)) } if h > 0 || d > 0 { parts = append(parts, fmt.Sprintf("%d ч.", h)) } if m > 0 || h > 0 || d > 0 { parts = append(parts, fmt.Sprintf("%d мин.", m)) } parts = append(parts, fmt.Sprintf("%d с", sec)) return strings.Join(parts, " ") } func buildHealthMessageHTML(h healthData) string { var sb strings.Builder sb.WriteString("Состояние сервера\n") sb.WriteString("проверка доступности и режима работы\n\n") sb.WriteString(fmt.Sprintf("Статус: %s\n", escapeHTML(strings.TrimSpace(h.Status)))) sb.WriteString(fmt.Sprintf("Только чтение: %s", yesNoRu(h.ReadOnly))) return sb.String() } func buildSummaryMessageHTML(s summaryData) string { var sb strings.Builder sb.WriteString("Сводка\n") sb.WriteString("агрегированная статистика\n\n") sb.WriteString(fmt.Sprintf("Время работы: %s\n", escapeHTML(formatUptimeRu(s.UptimeSeconds)))) sb.WriteString(fmt.Sprintf(" (%.0f с)\n\n", s.UptimeSeconds)) sb.WriteString(fmt.Sprintf("Всего соединений: %d\n", s.ConnectionsTotal)) sb.WriteString(fmt.Sprintf("Сбойных соединений: %d\n", s.ConnectionsBadTotal)) sb.WriteString(fmt.Sprintf("Таймауты рукопожатия: %d\n", s.HandshakeTimeoutsTotal)) sb.WriteString(fmt.Sprintf("Пользователей в конфигурации: %d", s.ConfiguredUsers)) return sb.String() } // 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 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 · уник. IP: %d · трафик: %s\n", u.CurrentConnections, u.ActiveUniqueIPs, formatTrafficRu(u.TotalOctets), )) if i+1 < end { sb.WriteString("\n") } } 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 { 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, http.StatusAccepted); 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: "Состояние", CallbackData: "cmd_health", Style: "primary"}, {Text: "Сводка", CallbackData: "cmd_summary", Style: "primary"}, }, { {Text: "Пользователи", CallbackData: "cmd_users", Style: "primary"}, {Text: "Создать пользователя", CallbackData: "cmd_create_user", Style: "success"}, }, }, } } func backKeyboard() *inlineKeyboardMarkup { return &inlineKeyboardMarkup{ InlineKeyboard: [][]inlineKeyboardButton{ {{Text: "« В меню", CallbackData: "cmd_menu"}}, }, } } func cancelKeyboard() *inlineKeyboardMarkup { return &inlineKeyboardMarkup{ InlineKeyboard: [][]inlineKeyboardButton{ {{Text: "Отмена", 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) } func httpStatusInList(code int, allowed []int) bool { for _, c := range allowed { if code == c { return true } } return false } func (b *bot) callTelemtJSON(ctx context.Context, method, path string, payload any, out any, successStatuses ...int) error { if len(successStatuses) == 0 { return errors.New("callTelemtJSON: at least one success HTTP status required") } cctx, cancel := context.WithTimeout(ctx, b.cfg.TelemtTimeout) defer cancel() var bodyReader *bytes.Reader if payload != nil { body, err := json.Marshal(payload) if err != nil { return err } bodyReader = bytes.NewReader(body) } else { bodyReader = bytes.NewReader(nil) } req, err := http.NewRequestWithContext(cctx, method, b.cfg.TelemtAPIBaseURL+path, bodyReader) if err != nil { return err } if payload != nil { req.Header.Set("Content-Type", "application/json") } if b.cfg.TelemtAPIAuth != "" { req.Header.Set("Authorization", b.cfg.TelemtAPIAuth) } resp, err := b.telemtClient.Do(req) if err != nil { return err } defer resp.Body.Close() var env telemtEnvelope if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { if !httpStatusInList(resp.StatusCode, successStatuses) { return fmt.Errorf("telemt status %s", resp.Status) } return fmt.Errorf("telemt decode response: %w", err) } if !httpStatusInList(resp.StatusCode, successStatuses) { if env.Error != nil { return fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message) } return fmt.Errorf("telemt status %s", resp.Status) } if !env.OK { if env.Error != nil { return fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message) } return errors.New("telemt returned ok=false") } if out == nil { return nil } if len(env.Data) == 0 || string(env.Data) == "null" { return errors.New("telemt empty data") } return json.Unmarshal(env.Data, out) } 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 { 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, } 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, "text": 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) } 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+method, &body) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := b.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("telegram %s status: %s", method, resp.Status) } return nil }