Ctrl K
ComunidadGuías…

config.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package main

import (
	"bytes"
	"cmp"
	"errors"
	"fmt"
	"io"
	"net/url"
	"os"
	"regexp"
	"slices"
	"strconv"
	"strings"

	"go.yaml.in/yaml/v3"
)

// config holds everything the bot needs to start. Secrets and monitoring come
// from the environment, routes from a YAML file — or from two variables when
// there is a single pair of channels.
type config struct {
	telegramToken string
	maxToken      string
	adminID       int64  // Telegram user to report failures to; 0 — nobody
	heartbeat     string // URL to POST to after every successful health check
	telegramAPI   string // own Bot API server (--local mode): files up to 2 GB instead of 20 MB
	stateFile     string
	maxWebhookURL string
	maxListen     string
	maxSecret     string
	tgWebhookURL  string
	tgSecret      string
	routes        []route
}

// route sends every post of each "from" channel to each "to" channel.
type route struct {
	Name        string `yaml:"name"`
	Enabled     *bool  `yaml:"enabled"`
	From        refs   `yaml:"from"`
	To          refs   `yaml:"to"`
	reverse     bool
	syncEdits   bool
	syncDeletes bool
}

type platform string

const (
	telegramPlatform platform = "telegram"
	maxPlatform      platform = "max"
)

var validWebhookSecret = regexp.MustCompile(`^[a-zA-Z0-9_-]{16,256}$`)

func (r *route) UnmarshalYAML(node *yaml.Node) error {
	if node.Kind != yaml.MappingNode {
		return fmt.Errorf("строка %d: нужен объект маршрута", node.Line)
	}
	platforms := [2]platform{telegramPlatform, maxPlatform}
	seen := map[string]bool{}
	for i := 0; i < len(node.Content); i += 2 {
		key, value := node.Content[i].Value, node.Content[i+1]
		if seen[key] {
			return fmt.Errorf("строка %d: поле %q повторяется", value.Line, key)
		}
		seen[key] = true
		var err error
		switch key {
		case "name":
			err = value.Decode(&r.Name)
		case "enabled":
			err = value.Decode(&r.Enabled)
		case "sync_edits":
			err = value.Decode(&r.syncEdits)
		case "sync_deletes":
			err = value.Decode(&r.syncDeletes)
		case "from":
			err = decodeEndpoint(value, &r.From, &platforms[0])
		case "to":
			err = decodeEndpoint(value, &r.To, &platforms[1])
		default:
			return fmt.Errorf("строка %d: неизвестное поле %q", value.Line, key)
		}
		if err != nil {
			return err
		}
	}
	if platforms[0] == platforms[1] {
		return fmt.Errorf("строка %d: from и to должны быть на разных платформах", node.Line)
	}
	r.reverse = platforms[0] == maxPlatform

	return nil
}

func decodeEndpoint(node *yaml.Node, channels *refs, p *platform) error {
	if node.Kind != yaml.MappingNode {
		return node.Decode(channels)
	}
	seenPlatform, seenChannels := false, false
	for i := 0; i < len(node.Content); i += 2 {
		key, value := node.Content[i].Value, node.Content[i+1]
		switch {
		case key == "platform" && !seenPlatform:
			*p = platform(value.Value)
			seenPlatform = true
		case (key == "channel" || key == "channels") && !seenChannels:
			if err := value.Decode(channels); err != nil {
				return err
			}
			seenChannels = true
		default:
			return fmt.Errorf("строка %d: неизвестное или повторное поле %q", value.Line, key)
		}
	}
	if !seenPlatform || (*p != telegramPlatform && *p != maxPlatform) {
		return fmt.Errorf("строка %d: platform должен быть telegram или max", node.Line)
	}

	return nil
}

// refs is a channel or a list of channels: a link, @name or numeric id.
type refs []string

func (r *refs) UnmarshalYAML(node *yaml.Node) error {
	switch node.Kind {
	case yaml.ScalarNode:
		*r = refs{node.Value}

		return nil
	case yaml.SequenceNode:
		var list []string
		if err := node.Decode(&list); err != nil {
			return err
		}
		*r = list

		return nil
	}

	return fmt.Errorf("строка %d: нужен канал или список каналов", node.Line)
}

func loadConfig() (config, error) {
	env := func(key string) string { return strings.TrimSpace(os.Getenv(key)) }

	c := config{
		telegramToken: env("TELEGRAM_BOT_TOKEN"),
		maxToken:      env("MAX_BOT_TOKEN"),
		heartbeat:     env("HEARTBEAT_URL"),
		telegramAPI:   strings.TrimSuffix(env("TELEGRAM_API_URL"), "/"),
		stateFile:     env("STATE_FILE"),
		maxWebhookURL: env("MAX_WEBHOOK_URL"),
		maxListen:     env("MAX_WEBHOOK_LISTEN"),
		maxSecret:     env("MAX_WEBHOOK_SECRET"),
		tgWebhookURL:  env("TELEGRAM_WEBHOOK_URL"),
		tgSecret:      env("TELEGRAM_WEBHOOK_SECRET"),
	}
	var missing []string
	if c.telegramToken == "" {
		missing = append(missing, "TELEGRAM_BOT_TOKEN")
	}
	if c.maxToken == "" {
		missing = append(missing, "MAX_BOT_TOKEN")
	}
	if len(missing) > 0 {
		return c, fmt.Errorf("не заданы переменные окружения: %s", strings.Join(missing, ", "))
	}

	if v := env("TELEGRAM_ADMIN_ID"); v != "" {
		id, err := strconv.ParseInt(v, 10, 64)
		if err != nil {
			return c, fmt.Errorf("TELEGRAM_ADMIN_ID — числовой id пользователя Telegram, а не %q", v)
		}
		c.adminID = id
	}
	if u, err := url.Parse(c.heartbeat); c.heartbeat != "" && (err != nil || (u.Scheme != "https" && u.Scheme != "http")) {
		return c, errors.New("HEARTBEAT_URL — адрес вида https://…")
	}
	if u, err := url.Parse(c.telegramAPI); c.telegramAPI != "" && (err != nil || (u.Scheme != "https" && u.Scheme != "http")) {
		return c, errors.New("TELEGRAM_API_URL — адрес сервера Bot API вида http://telegram-bot-api:8081")
	}

	path := env("CONFIG_FILE")
	if path == "" {
		path = "config.yaml"
	}
	routes, err := loadRoutes(path, env("TELEGRAM_CHANNEL"), env("MAX_CHANNEL"))
	c.routes = routes
	if err != nil {
		return c, err
	}
	if c.stateFile == "" {
		c.stateFile = "perepost.sqlite"
	}
	if c.maxListen == "" {
		c.maxListen = cmp.Or(env("WEBHOOK_LISTEN"), "127.0.0.1:8090")
	}
	if c.tgWebhookURL != "" {
		u, err := url.Parse(c.tgWebhookURL)
		if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" || u.RawQuery != "" ||
			u.Path == "" || u.Path == "/" || (u.Port() != "" && u.Port() != "443") {
			return c, errors.New("TELEGRAM_WEBHOOK_URL — публичный HTTPS-адрес с отдельным путём на порту 443")
		}
		if !validWebhookSecret.MatchString(c.tgSecret) {
			return c, errors.New("TELEGRAM_WEBHOOK_SECRET — 16–256 символов из A–Z, a–z, 0–9, _ и -")
		}
		if maxURL, err := url.Parse(c.maxWebhookURL); err == nil && maxURL.Path == u.Path {
			return c, errors.New("MAX и Telegram нужны разные пути вебхуков")
		}
	}
	if slices.ContainsFunc(routes, func(r route) bool { return r.reverse }) {
		u, err := url.Parse(c.maxWebhookURL)
		if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Fragment != "" || u.RawQuery != "" ||
			(u.Port() != "" && u.Port() != "443") {
			return c, errors.New("MAX_WEBHOOK_URL — публичный HTTPS-адрес вебхука на порту 443 без параметров")
		}
		if !validWebhookSecret.MatchString(c.maxSecret) {
			return c, errors.New("MAX_WEBHOOK_SECRET — 16–256 символов из A–Z, a–z, 0–9, _ и -")
		}
	}

	return c, nil
}

// loadRoutes takes a single pair from TELEGRAM_CHANNEL and MAX_CHANNEL, or all
// routes from the YAML file at path. Both at once is an error: it is unclear
// which one the user meant.
func loadRoutes(path, telegramChannel, maxChannel string) ([]route, error) {
	if telegramChannel != "" || maxChannel != "" {
		if telegramChannel == "" || maxChannel == "" {
			return nil, errors.New("для одной пары каналов задайте и TELEGRAM_CHANNEL, и MAX_CHANNEL")
		}
		if _, err := os.Stat(path); err == nil {
			return nil, fmt.Errorf("маршруты заданы и в TELEGRAM_CHANNEL/MAX_CHANNEL, и в %s — оставьте что-то одно", path)
		}

		return []route{{From: refs{telegramChannel}, To: refs{maxChannel}}}, nil
	}

	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return nil, fmt.Errorf("маршруты не заданы: создайте %s (пример — config.example.yaml) или задайте TELEGRAM_CHANNEL и MAX_CHANNEL", path)
	}
	if err != nil {
		return nil, err
	}

	var file struct {
		Routes []route `yaml:"routes"`
	}
	decoder := yaml.NewDecoder(bytes.NewReader(data))
	decoder.KnownFields(true) // a typo like "form:" must not silently drop a route
	if err := decoder.Decode(&file); err != nil && !errors.Is(err, io.EOF) {
		return nil, fmt.Errorf("%s: %w", path, err)
	}
	if len(file.Routes) == 0 {
		return nil, fmt.Errorf("%s: в routes нет ни одного маршрута", path)
	}
	active := make([]route, 0, len(file.Routes))
	for i, r := range file.Routes {
		if r.Enabled != nil && !*r.Enabled {
			continue
		}
		if r.syncDeletes && !r.reverse {
			return nil, fmt.Errorf("%s: маршрут %d: sync_deletes доступен для источников MAX", path, i+1)
		}
		for _, list := range []refs{r.From, r.To} {
			if len(list) == 0 || slices.ContainsFunc(list, func(ref string) bool { return strings.TrimSpace(ref) == "" }) {
				return nil, fmt.Errorf("%s: у маршрута %d нужны непустые from и to", path, i+1)
			}
		}
		active = append(active, r)
	}
	if len(active) == 0 {
		return nil, fmt.Errorf("%s: нет включённых маршрутов", path)
	}

	return active, nil
}