Files

1119 lines
32 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>")
}
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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
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("<b>Состояние сервера</b>\n")
sb.WriteString("<i>проверка доступности и режима работы</i>\n\n")
sb.WriteString(fmt.Sprintf("<b>Статус:</b> <code>%s</code>\n", escapeHTML(strings.TrimSpace(h.Status))))
sb.WriteString(fmt.Sprintf("<b>Только чтение:</b> <b>%s</b>", yesNoRu(h.ReadOnly)))
return sb.String()
}
func buildSummaryMessageHTML(s summaryData) string {
var sb strings.Builder
sb.WriteString("<b>Сводка</b>\n")
sb.WriteString("<i>агрегированная статистика</i>\n\n")
sb.WriteString(fmt.Sprintf("<b>Время работы:</b> <b>%s</b>\n", escapeHTML(formatUptimeRu(s.UptimeSeconds))))
sb.WriteString(fmt.Sprintf(" <i>(%.0f с)</i>\n\n", s.UptimeSeconds))
sb.WriteString(fmt.Sprintf("<b>Всего соединений:</b> <b>%d</b>\n", s.ConnectionsTotal))
sb.WriteString(fmt.Sprintf("<b>Сбойных соединений:</b> <b>%d</b>\n", s.ConnectionsBadTotal))
sb.WriteString(fmt.Sprintf("<b>Таймауты рукопожатия:</b> <b>%d</b>\n", s.HandshakeTimeoutsTotal))
sb.WriteString(fmt.Sprintf("<b>Пользователей в конфигурации:</b> <b>%d</b>", 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("<b>Пользователи</b>")
sb.WriteString("\n")
sb.WriteString(fmt.Sprintf("<i>Страница %d из %d · всего записей: %d</i>", page+1, totalPages, total))
sb.WriteString("\n\n")
for i := start; i < end; i++ {
u := users[i]
globalIdx := i + 1
sb.WriteString(fmt.Sprintf("<b>%d.</b> <code>%s</code>\n", globalIdx, escapeHTML(u.Username)))
sb.WriteString(fmt.Sprintf(
" соединений: <b>%d</b> · уник. IP: <b>%d</b> · трафик: <b>%s</b>\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
}