package laniakea import ( "encoding/json" "fmt" "io" "os" "regexp" "git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/utils" ) // ConfigVersion is the current version of the built-in JSON BotOpts file format. const ConfigVersion = 1 // ErrConfigVersionMismatch reports that a config file declares a newer version // than this library knows how to decode. var ErrConfigVersionMismatch = fmt.Errorf("config version mismatch: expected %d", ConfigVersion) type botOptsFileJSONLogger struct { LoggerBasePath string `json:"base_path"` UseRequestLogger bool `json:"use_request_logger"` WriteToFile bool `json:"write_to_file"` LogFormat utils.LogFormat `json:"log_format"` } type botOptsFileJSONAPI struct { UseTestServer bool `json:"use_test_server"` APIURL string `json:"url"` RateLimit int `json:"rate_limit"` PollTimeout int `json:"poll_timeout"` DropRLOverflow bool `json:"drop_overflow"` } // BotOptsFileJSON is the JSON file representation of BotOpts. type BotOptsFileJSON struct { // Version identifies the JSON configuration format version. Version int `json:"version"` // Token is the Telegram bot token. Token string `json:"token"` // UpdateTypes limits the update kinds requested from Telegram. UpdateTypes []tgapi.UpdateType `json:"update_types"` // Debug enables debug logging. Debug bool `json:"debug"` // ErrorTemplate formats user-facing handler errors. ErrorTemplate string `json:"error_template"` // Prefixes contains accepted command prefixes. Prefixes []string `json:"prefixes"` // Logger contains file logging options. Logger botOptsFileJSONLogger `json:"logger"` // API contains Telegram client and rate-limit options. API botOptsFileJSONAPI `json:"api"` // StrictPayloadType requires callback payloads to use the configured encoding. StrictPayloadType bool `json:"strict_payload_type"` // MaxWorkers limits concurrent update handlers. MaxWorkers int `json:"max_workers"` } // BotOptsFileJSONCodec encodes and decodes BotOpts using BotOptsFileJSON. type BotOptsFileJSONCodec struct{} // FromBytes decodes BotOpts from JSON file bytes. func (codec BotOptsFileJSONCodec) FromBytes(data []byte) (*BotOpts, error) { fileOpts := new(BotOptsFileJSON) err := json.Unmarshal(data, fileOpts) if err != nil { return nil, err } if fileOpts.Version > ConfigVersion { return nil, ErrConfigVersionMismatch } opts := &BotOpts{ Token: fileOpts.Token, UpdateTypes: fileOpts.UpdateTypes, Debug: fileOpts.Debug, ErrorTemplate: fileOpts.ErrorTemplate, Prefixes: fileOpts.Prefixes, LoggerBasePath: fileOpts.Logger.LoggerBasePath, UseRequestLogger: fileOpts.Logger.UseRequestLogger, WriteToFile: fileOpts.Logger.WriteToFile, LogFormat: fileOpts.Logger.LogFormat, UseTestServer: fileOpts.API.UseTestServer, APIURL: fileOpts.API.APIURL, RateLimit: fileOpts.API.RateLimit, PollTimeout: fileOpts.API.PollTimeout, DropRateLimitOverflow: fileOpts.API.DropRLOverflow, StrictPayloadType: fileOpts.StrictPayloadType, MaxWorkers: fileOpts.MaxWorkers, FileConfigVersion: fileOpts.Version, } return opts, nil } // ToBytes encodes BotOpts into JSON file bytes. func (codec BotOptsFileJSONCodec) ToBytes(opts *BotOpts) ([]byte, error) { if opts == nil { return nil, ErrOptsIsNil } fileOpts := &BotOptsFileJSON{ Version: ConfigVersion, Token: opts.Token, UpdateTypes: opts.UpdateTypes, Debug: opts.Debug, ErrorTemplate: opts.ErrorTemplate, Prefixes: opts.Prefixes, Logger: botOptsFileJSONLogger{ LoggerBasePath: opts.LoggerBasePath, UseRequestLogger: opts.UseRequestLogger, WriteToFile: opts.WriteToFile, LogFormat: opts.LogFormat, }, API: botOptsFileJSONAPI{ UseTestServer: opts.UseTestServer, APIURL: opts.APIURL, RateLimit: opts.RateLimit, PollTimeout: opts.PollTimeout, DropRLOverflow: opts.DropRateLimitOverflow, }, StrictPayloadType: opts.StrictPayloadType, MaxWorkers: opts.MaxWorkers, } data, err := json.Marshal(fileOpts) if err != nil { return nil, err } return data, nil } // Load reads BotOpts from a JSON config file. func (codec BotOptsFileJSONCodec) Load(filename string) (*BotOpts, error) { return LoadBotOptsFile(codec, filename) } // Save writes BotOpts to a JSON config file. func (codec BotOptsFileJSONCodec) Save(filename string, opts *BotOpts) error { return SaveBotOptsFile(codec, filename, opts) } // EscapeEnv escapes an environment value for use inside a JSON string. func (codec BotOptsFileJSONCodec) EscapeEnv(s string) string { data, _ := json.Marshal(s) return string(data[1 : len(data)-1]) } var envParameterRegex = regexp.MustCompile(`\{\{\s*(\w+)\s*}}`) type botOptsFileEnvEscaper interface { EscapeEnv(string) string } // BotOptsFileCodec decodes and encodes BotOpts file formats. type BotOptsFileCodec interface { FromBytes([]byte) (*BotOpts, error) ToBytes(*BotOpts) ([]byte, error) Load(filename string) (*BotOpts, error) Save(filename string, opts *BotOpts) error } // LoadBotOptsFile reads a config file, expands env placeholders, and decodes BotOpts. func LoadBotOptsFile(codec BotOptsFileCodec, filename string) (*BotOpts, error) { if isNilValue(codec) { return nil, ErrCodecIsNil } f, err := os.Open(filename) if err != nil { return nil, err } defer func() { _ = f.Close() }() data, err := io.ReadAll(f) if err != nil { return nil, err } data = expandEnvPlaceholdersInFile(codec, data) return codec.FromBytes(data) } // SaveBotOptsFile encodes BotOpts with codec and writes the result to filename. func SaveBotOptsFile(codec BotOptsFileCodec, filename string, opts *BotOpts) error { if isNilValue(codec) { return ErrCodecIsNil } if opts == nil { return ErrOptsIsNil } data, err := codec.ToBytes(opts) if err != nil { return err } err = os.WriteFile(filename, data, 0600) if err != nil { return err } return nil } func expandEnvPlaceholdersInFile(codec BotOptsFileCodec, data []byte) []byte { return envParameterRegex.ReplaceAllFunc(data, func(match []byte) []byte { group := envParameterRegex.FindSubmatch(match) if len(group) != 2 { return match } key := group[1] value := os.Getenv(string(key)) if escaper, ok := codec.(botOptsFileEnvEscaper); ok { value = escaper.EscapeEnv(value) } return []byte(value) }) }