diff --git a/.env.example b/.env.example index aa45cf2..499acc7 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ PATREON_CLIENT_ID=your_client_id PATREON_CLIENT_SECRET=your_client_secret PATREON_CAMPAIGN_ID=your_campaign_id PATREON_TOKENS_FILE_PATH=/data/tokens.json +TIERS=12345:Super,67890:Ultra SERVER_ADDR=:8080 PRODUCTION_MODE=true SENTRY_DSN=your_sentry_dsn \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7f983cb..29c5e8d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea/ *.iml .env -tokens.json \ No newline at end of file +tokens.json +config.json \ No newline at end of file diff --git a/README.md b/README.md index 2c2d695..9d47383 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,11 @@ Some experience with Discord app development is assumed. refresh token in a `tokens.json` file. An example is provided in [`tokens.json.example`](/tokens.json.example). 4. Run the main binary: there are 2 ways of doing this - either by building and running the main binary directly (`go build cmd/app/main.go`), or via Docker (recommended). If running the binary directly, see the - [envvars.md](/envvars.md) file for a list of environment variables that need to be set. + [envvars.md](/envvars.md) file for a list of environment variables that need to be set. + + + Alternatively, a `config.json` file can be used to configure the application. See the + [config.json.example](/config.json.example) file for an example. Note, anyone is able to use the command, as long as the command is run in a guild listed in the `DISCORD_ALLOWED_GUILDS` environment variable. You should use Discord's built-in application command permission system to restrict usage to diff --git a/cmd/app/main.go b/cmd/app/main.go index 5c9e2e6..758c43e 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -21,35 +21,39 @@ func main() { var logger *zap.Logger if conf.ProductionMode { - if err := sentry.Init(sentry.ClientOptions{ - Dsn: conf.SentryDsn, - }); err != nil { - panic(err) - } + if conf.SentryDsn != nil { + if err := sentry.Init(sentry.ClientOptions{ + Dsn: *conf.SentryDsn, + }); err != nil { + panic(err) + } - defer sentry.Flush(time.Second * 2) - - logger, err = zap.NewProduction(zap.WrapCore(func(core zapcore.Core) zapcore.Core { - return zapcore.RegisterHooks(core, func(entry zapcore.Entry) error { - if entry.Level == zapcore.ErrorLevel { - hostname, _ := os.Hostname() - - sentry.CaptureEvent(&sentry.Event{ - Extra: map[string]any{ - "caller": entry.Caller.String(), - "stack": entry.Stack, - }, - Level: sentry.LevelError, - Message: entry.Message, - ServerName: hostname, - Timestamp: entry.Time, - Logger: entry.LoggerName, - }) - } - - return nil - }) - })) + defer sentry.Flush(time.Second * 2) + + logger, err = zap.NewProduction(zap.WrapCore(func(core zapcore.Core) zapcore.Core { + return zapcore.RegisterHooks(core, func(entry zapcore.Entry) error { + if entry.Level == zapcore.ErrorLevel { + hostname, _ := os.Hostname() + + sentry.CaptureEvent(&sentry.Event{ + Extra: map[string]any{ + "caller": entry.Caller.String(), + "stack": entry.Stack, + }, + Level: sentry.LevelError, + Message: entry.Message, + ServerName: hostname, + Timestamp: entry.Time, + Logger: entry.LoggerName, + }) + } + + return nil + }) + })) + } else { + logger, err = zap.NewProduction() + } } else { logger, err = zap.NewDevelopment() } diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..6dd26fe --- /dev/null +++ b/config.json.example @@ -0,0 +1,19 @@ +{ + "server_address": "0.0.0.0:8080", + "production_mode": true, + "sentry_dsn": null, + "discord": { + "public_key": "", + "allowed_guilds": [12345678901234567] + }, + "patreon": { + "client_id": "", + "client_secret": "", + "campaign_id": 1111111, + "tokens_file_path": "tokens.json" + }, + "tiers": { + "1234": "Super", + "5678": "Ultra" + } +} \ No newline at end of file diff --git a/envvars.md b/envvars.md index e17ddf4..e638ebe 100644 --- a/envvars.md +++ b/envvars.md @@ -1,8 +1,9 @@ -- DISCORD_PUBLIC_KEY -- DISCORD_ALLOWED_GUILDS -- PATREON_CLIENT_ID -- PATREON_CLIENT_SECRET -- PATREON_CAMPAIN_ID -- SERVER_ADDR -- SENTRY_DSN -- PRODUCTION_MODE \ No newline at end of file +- **DISCORD_PUBLIC_KEY**: The public key for your Discord application to verify interactions. +- **DISCORD_ALLOWED_GUILDS**: A comma-separated list of Discord guild IDs that commands will be accepted in. +- **PATREON_CLIENT_ID**: The client ID string for your Patreon app. +- **PATREON_CLIENT_SECRET**: The client secret string for your Patreon app. +- **PATREON_CAMPAIGN_ID**: The ID of the Patreon campaign to use for fetching pledges. +- **SERVER_ADDR**: The address to bind the web server for HTTP interactions to (e.g. `:8080). +- **SENTRY_DSN**: Optional, used for error reporting. +- **PRODUCTION_MODE**: Currently only used to determine the log format. +- **TIERS**: A comma-separated list of Patreon tier IDs and names, in the format `1234:Name,5678:Name`, and so on. \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index e0e1b33..4a6e60e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,28 +1,54 @@ package config import ( + "encoding/json" "github.com/caarlos0/env/v9" + "github.com/pkg/errors" + "os" ) type Config struct { - ServerAddr string `env:"SERVER_ADDR,required"` - ProductionMode bool `env:"PRODUCTION_MODE" envDefault:"false"` - SentryDsn string `env:"SENTRY_DSN"` + ServerAddr string `env:"SERVER_ADDR,required" json:"server_address"` + ProductionMode bool `env:"PRODUCTION_MODE" envDefault:"false" json:"production_mode"` + SentryDsn *string `env:"SENTRY_DSN" json:"sentry_dsn"` Discord struct { - PublicKey string `env:"PUBLIC_KEY,required"` - AllowedGuilds []uint64 `env:"ALLOWED_GUILDS,required"` - } `envPrefix:"DISCORD_"` + PublicKey string `env:"PUBLIC_KEY,required" json:"public_key"` + AllowedGuilds []uint64 `env:"ALLOWED_GUILDS,required" json:"allowed_guilds"` + } `envPrefix:"DISCORD_" json:"discord"` Patreon struct { - ClientId string `env:"CLIENT_ID,required"` - ClientSecret string `env:"CLIENT_SECRET,required"` - CampaignId int `env:"CAMPAIGN_ID,required"` - TokensFilePath string `env:"TOKENS_FILE_PATH" envDefault:"tokens.json"` - } `envPrefix:"PATREON_"` + ClientId string `env:"CLIENT_ID,required" json:"client_id"` + ClientSecret string `env:"CLIENT_SECRET,required" json:"client_secret"` + CampaignId int `env:"CAMPAIGN_ID,required" json:"campaign_id"` + TokensFilePath string `env:"TOKENS_FILE_PATH" envDefault:"tokens.json" json:"tokens_file_path"` + } `envPrefix:"PATREON_" json:"patreon"` + + Tiers map[uint64]string `env:"TIERS" json:"tiers"` } -func LoadConfig() (conf Config, err error) { - err = env.Parse(&conf) - return +func LoadConfig() (Config, error) { + var conf Config + if _, err := os.Stat("config.json"); err == nil { + f, err := os.Open("config.json") + if err != nil { + return Config{}, errors.Wrap(err, "failed to open config.json") + } + + if err := json.NewDecoder(f).Decode(&conf); err != nil { + return Config{}, errors.Wrap(err, "failed to decode config.json") + } + + if conf.Patreon.TokensFilePath == "" { + conf.Patreon.TokensFilePath = "tokens.json" + } + } else if errors.Is(err, os.ErrNotExist) { // If config.json does not exist, load from envvars + if err := env.Parse(&conf); err != nil { + return Config{}, errors.Wrap(err, "failed to parse env vars") + } + } else { + return conf, errors.Wrap(err, "failed to check if config.json exists") + } + + return conf, nil } diff --git a/internal/server/handler.go b/internal/server/handler.go index 842dc88..e8d153e 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -93,7 +93,12 @@ func handleCommand(s *Server, data interaction.ApplicationCommandInteraction) in if ok { tiers := make([]string, len(patron.Tiers)) for i, tier := range patron.Tiers { - tiers[i] = tier.String() + tierName, ok := s.config.Tiers[tier] + if !ok { + tierName = fmt.Sprintf("Unknown (ID: %d)", tier) + } + + tiers[i] = tierName } discord := "Not linked" diff --git a/pkg/patreon/client.go b/pkg/patreon/client.go index 998e70e..22dcfd3 100644 --- a/pkg/patreon/client.go +++ b/pkg/patreon/client.go @@ -51,15 +51,15 @@ func (c *Client) FetchPledges(ctx context.Context) (map[string]Patron, error) { } // Parse tiers - var tiers []Tier + var tiers []uint64 for _, tier := range member.Relationships.CurrentlyEntitledTiers.Data { - internalTier, ok := GetTierFromId(tier.TierId) - if !ok { + // Check if tier is known + if _, ok := c.config.Tiers[tier.TierId]; !ok { c.logger.Warn("unknown tier", zap.Uint64("tier_id", tier.TierId)) continue } - tiers = append(tiers, internalTier) + tiers = append(tiers, tier.TierId) } // Parse "included" metadata diff --git a/pkg/patreon/types.go b/pkg/patreon/types.go index e3212ef..c77ccdc 100644 --- a/pkg/patreon/types.go +++ b/pkg/patreon/types.go @@ -6,7 +6,7 @@ type ( Patron struct { Attributes Id uint64 - Tiers []Tier + Tiers []uint64 DiscordId *uint64 }