1119 lines
32 KiB
Go
1119 lines
32 KiB
Go
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, "&", "&")
|
||
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("<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
|
||
}
|