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 }