commit c5f5a562c6d5f65d68e3b79a4a510f562818b461 Author: Denozordec Date: Mon Mar 9 16:05:44 2026 +0700 Init Commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..338d79c --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +TELEGRAM_BOT_TOKEN=123456:replace_me +TELEGRAM_ADMIN_CHAT_ID=123456789 +TELEMT_API_BASE_URL=http://host.docker.internal:9091 +TELEMT_API_AUTH_HEADER=Bearer replace_me +TELEGRAM_HTTP_TIMEOUT_SECONDS=60 +TELEMT_HTTP_TIMEOUT_SECONDS=5 diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml new file mode 100644 index 0000000..871e89c --- /dev/null +++ b/.gitea/workflows/docker-publish.yml @@ -0,0 +1,86 @@ +name: Publish telemt-bot Docker image + +on: + push: + branches: + - main + - master + - v* + tags: + - 'v*' + workflow_dispatch: + +env: + REGISTRY: git.shts.su + IMAGE_REPO: ${{ gitea.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver-opts: | + image=moby/buildkit:v0.13.2 + + - name: Log in to Gitea Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ gitea.actor }} + password: ${{ secrets.ACTIONS_PAT }} + + - name: Prepare Docker tags + id: prep + shell: bash + env: + REPO: ${{ gitea.repository }} + SHA: ${{ gitea.sha }} + REF_NAME: ${{ gitea.ref_name }} + run: | + set -euo pipefail + IMAGE="${REGISTRY}/${REPO}" + BRANCH_OR_TAG="${REF_NAME:-unknown}" + SAFE_REF="$(echo "${BRANCH_OR_TAG}" | tr '/' '-')" + SHORT_SHA="$(echo "${SHA}" | cut -c1-12)" + + TAGS=$(cat <> "${GITHUB_OUTPUT}" + echo "${TAGS}" >> "${GITHUB_OUTPUT}" + echo "EOF" >> "${GITHUB_OUTPUT}" + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/amd64 + cache-from: | + type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}:buildcache + cache-to: | + type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}:buildcache,mode=max + tags: ${{ steps.prep.outputs.tags }} + labels: | + org.opencontainers.image.title=telemt-bot + org.opencontainers.image.description=Telegram admin bot for Telemt Control API + org.opencontainers.image.revision=${{ gitea.sha }} + org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }} + provenance: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51a41ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +bin/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..25652a9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.22-alpine AS builder +WORKDIR /src + +COPY go.mod ./ +COPY main.go ./ +RUN go mod download +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/telemt-bot . + +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR /app +COPY --from=builder /out/telemt-bot /app/telemt-bot + +ENV TELEGRAM_HTTP_TIMEOUT_SECONDS=60 +ENV TELEMT_HTTP_TIMEOUT_SECONDS=5 + +ENTRYPOINT ["/app/telemt-bot"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfed3e9 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# telemt-bot + +Лёгкий Telegram-бот для админ-доступа к Telemt Control API. + +## Что умеет + +- `/health` -> `GET /v1/health` +- `/summary` -> `GET /v1/stats/summary` +- `/users` -> `GET /v1/stats/users` (первые 20 строк) + +Команды обрабатываются только от одного админ-чата (`TELEGRAM_ADMIN_CHAT_ID`). + +## Переменные окружения + +- `TELEGRAM_BOT_TOKEN` - токен Telegram-бота. +- `TELEGRAM_ADMIN_CHAT_ID` - chat id администратора, которому разрешены команды. +- `TELEMT_API_BASE_URL` - базовый URL API, например `http://host.docker.internal:9091`. +- `TELEMT_API_AUTH_HEADER` - значение заголовка `Authorization` для Telemt API (если включён `auth_header`). +- `TELEGRAM_HTTP_TIMEOUT_SECONDS` - timeout запросов к Telegram (по умолчанию `60`). +- `TELEMT_HTTP_TIMEOUT_SECONDS` - timeout запросов к Telemt API (по умолчанию `5`). + +## Быстрый старт (PowerShell) + +```powershell +Copy-Item .env.example .env +# отредактируй .env +docker compose up --build -d +docker compose logs -f +``` + +## Почему решение экономное по ресурсам + +- Go-бинарник без рантайма и с нулевым CGO. +- Long polling (`getUpdates`) вместо частых коротких запросов. +- Один переиспользуемый `http.Client` и keep-alive соединения. +- Multi-stage сборка и минимальный образ `distroless`. + +## CI/CD для Gitea Registry + +Добавлен workflow: `.gitea/workflows/docker-publish.yml`. + +Что делает: +- при `push` в `main`, `master`, `v*` и при `git tag v*`; +- собирает образ из `Dockerfile`; +- пушит в registry `git.shts.su/${REPO}`; +- выставляет теги: `latest`, ``, `sha-<12-symbols>`. + +Нужно создать secret в Gitea репозитории: +- `ACTIONS_PAT` - токен с правами на push в package/container registry. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..27ea8c5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + telemt-bot: + build: + context: . + dockerfile: Dockerfile + container_name: telemt-bot + restart: unless-stopped + environment: + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} + TELEGRAM_ADMIN_CHAT_ID: ${TELEGRAM_ADMIN_CHAT_ID} + TELEMT_API_BASE_URL: ${TELEMT_API_BASE_URL} + TELEMT_API_AUTH_HEADER: ${TELEMT_API_AUTH_HEADER} + TELEGRAM_HTTP_TIMEOUT_SECONDS: ${TELEGRAM_HTTP_TIMEOUT_SECONDS:-60} + TELEMT_HTTP_TIMEOUT_SECONDS: ${TELEMT_HTTP_TIMEOUT_SECONDS:-5} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..47dda09 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module telemt-bot + +go 1.22 diff --git a/main.go b/main.go new file mode 100644 index 0000000..37ec157 --- /dev/null +++ b/main.go @@ -0,0 +1,382 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" +) + +const ( + defaultTelemtTimeoutSeconds = 5 + defaultTelegramTimeoutSeconds = 60 + defaultListenPollTimeout = 50 +) + +type config struct { + TelegramBotToken string + AdminChatID int64 + + TelemtAPIBaseURL string + TelemtAPIAuth string + + TelemtTimeout time.Duration + TelegramTimeout time.Duration +} + +type bot struct { + cfg config + httpClient *http.Client + telegramBase string + offset int64 +} + +type tgGetUpdatesResponse struct { + OK bool `json:"ok"` + Result []tgUpdate `json:"result"` +} + +type tgUpdate struct { + UpdateID int64 `json:"update_id"` + Message *tgMessage `json:"message"` +} + +type tgMessage struct { + Chat tgChat `json:"chat"` + Text string `json:"text"` +} + +type tgChat struct { + ID int64 `json:"id"` +} + +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"` +} + +func main() { + cfg, err := loadConfig() + if err != nil { + log.Fatalf("config error: %v", err) + } + + httpClient := &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 64, + MaxIdleConnsPerHost: 32, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 5 * time.Second, + ForceAttemptHTTP2: true, + DisableCompression: false, + }, + Timeout: cfg.TelegramTimeout, + } + + b := &bot{ + cfg: cfg, + httpClient: httpClient, + telegramBase: "https://api.telegram.org/bot" + cfg.TelegramBotToken, + } + + 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) + + 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 (b *bot) run(ctx context.Context) error { + 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.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"}, + } + + 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 + } + + command := strings.Fields(text)[0] + if at := strings.IndexByte(command, '@'); at > 0 { + command = command[:at] + } + switch command { + case "/start", "/help": + return b.sendMessage(ctx, chatID, "Команды:\n/health\n/summary\n/users") + case "/health": + return b.handleHealth(ctx, chatID) + case "/summary": + return b.handleSummary(ctx, chatID) + case "/users": + return b.handleUsers(ctx, chatID) + default: + return b.sendMessage(ctx, chatID, "Неизвестная команда. Используй /help") + } +} + +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) callTelemt(ctx context.Context, path string, out any) error { + cctx, cancel := context.WithTimeout(ctx, b.cfg.TelemtTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(cctx, http.MethodGet, b.cfg.TelemtAPIBaseURL+path, nil) + if err != nil { + return err + } + if b.cfg.TelemtAPIAuth != "" { + req.Header.Set("Authorization", b.cfg.TelemtAPIAuth) + } + + resp, err := b.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("telemt status %s", resp.Status) + } + + var env telemtEnvelope + if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { + return err + } + 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 len(env.Data) == 0 { + return errors.New("telemt empty data") + } + return json.Unmarshal(env.Data, out) +} + +func (b *bot) sendMessage(ctx context.Context, chatID int64, text string) error { + reqBody := map[string]any{ + "chat_id": chatID, + "text": text, + } + + var body bytes.Buffer + if err := json.NewEncoder(&body).Encode(reqBody); err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.telegramBase+"/sendMessage", &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 sendMessage status: %s", resp.Status) + } + return nil +}