Files
telemt-bot/main.go
T
2026-03-22 12:13:31 +07:00

922 lines
26 KiB
Go
Raw 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
)
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())
}
msg := fmt.Sprintf("Health: %s\nRead-only: %t", h.Status, h.ReadOnly)
return b.sendMessage(ctx, chatID, msg)
}
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())
}
msg := fmt.Sprintf(
"Uptime: %.0fs\nConn total: %d\nConn bad: %d\nHandshake timeouts: %d\nUsers: %d",
s.UptimeSeconds, s.ConnectionsTotal, s.ConnectionsBadTotal, s.HandshakeTimeoutsTotal, s.ConfiguredUsers,
)
return b.sendMessage(ctx, chatID, msg)
}
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, "Пользователи не найдены.")
}
const maxRows = 20
var sb strings.Builder
if len(users) > maxRows {
sb.WriteString(fmt.Sprintf("Показаны первые %d из %d\n", maxRows, len(users)))
}
limit := len(users)
if limit > maxRows {
limit = maxRows
}
for i := 0; i < limit; i++ {
u := users[i]
sb.WriteString(fmt.Sprintf(
"%d) %s | conn=%d | ips=%d | octets=%d\n",
i+1, u.Username, u.CurrentConnections, u.ActiveUniqueIPs, u.TotalOctets,
))
}
return b.sendMessage(ctx, chatID, strings.TrimRight(sb.String(), "\n"))
}
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); 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:
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())
}
msg := fmt.Sprintf("Health: %s\nRead-only: %t", h.Status, h.ReadOnly)
return b.editMessageText(ctx, chatID, msgID, msg, backKeyboard())
}
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())
}
msg := fmt.Sprintf(
"Uptime: %.0fs\nConn total: %d\nConn bad: %d\nHandshake timeouts: %d\nUsers: %d",
s.UptimeSeconds, s.ConnectionsTotal, s.ConnectionsBadTotal, s.HandshakeTimeoutsTotal, s.ConfiguredUsers,
)
return b.editMessageText(ctx, chatID, msgID, msg, backKeyboard())
}
func (b *bot) handleUsersCB(ctx context.Context, chatID, msgID int64) 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())
}
const maxRows = 20
var sb strings.Builder
if len(users) > maxRows {
sb.WriteString(fmt.Sprintf("Показаны первые %d из %d\n", maxRows, len(users)))
}
limit := len(users)
if limit > maxRows {
limit = maxRows
}
for i := 0; i < limit; i++ {
u := users[i]
sb.WriteString(fmt.Sprintf(
"%d) %s | conn=%d | ips=%d | octets=%d\n",
i+1, u.Username, u.CurrentConnections, u.ActiveUniqueIPs, u.TotalOctets,
))
}
return b.editMessageText(ctx, chatID, msgID, strings.TrimRight(sb.String(), "\n"), backKeyboard())
}
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); 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 (b *bot) callTelemtJSON(ctx context.Context, method, path string, payload any, out any, successStatus int) error {
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 resp.StatusCode != successStatus {
return fmt.Errorf("telemt status %s", resp.Status)
}
return fmt.Errorf("telemt decode response: %w", err)
}
if resp.StatusCode != successStatus {
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 {
reqBody := map[string]any{
"chat_id": chatID,
"text": text,
}
if keyboard != nil {
reqBody["reply_markup"] = keyboard
}
return b.postTelegram(ctx, "/sendMessage", reqBody)
}
func (b *bot) editMessageText(ctx context.Context, chatID, messageID int64, text string, keyboard *inlineKeyboardMarkup) error {
reqBody := map[string]any{
"chat_id": chatID,
"message_id": messageID,
"text": text,
}
if keyboard != nil {
reqBody["reply_markup"] = keyboard
}
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
}