mirror of
https://github.com/tiennm99/openai-status-bot.git
synced 2026-10-13 16:15:26 +00:00
feat(config): use REDIS_URL for redis
This commit is contained in:
1 parent
f022e8463c
commit
204a76a32d
8 files changed
+82
-50
No files matched your search
+1
-3
@@ -1,7 +1,5 @@
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
REDIS_ADDR=localhost:6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
OPENAI_STATUS_BASE_URL=https://status.openai.com
|
||||
POLL_INTERVAL=1m
|
||||
HTTP_TIMEOUT=10s
|
||||
|
||||
@@ -48,14 +48,14 @@ go run ./cmd/openai-status-bot
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `TELEGRAM_BOT_TOKEN` | required | Telegram bot token from BotFather |
|
||||
| `REDIS_ADDR` | `localhost:6379` | Redis address |
|
||||
| `REDIS_PASSWORD` | empty | Redis password |
|
||||
| `REDIS_DB` | `0` | Redis database number, valid `0`-`15` |
|
||||
| `REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL, e.g. `redis://:password@localhost:6379/0` |
|
||||
| `OPENAI_STATUS_BASE_URL` | `https://status.openai.com` | OpenAI status page base URL |
|
||||
| `POLL_INTERVAL` | `1m` | Status check interval, valid `5s`-`1h` |
|
||||
| `HTTP_TIMEOUT` | `10s` | HTTP request timeout, valid `1s`-`5m` |
|
||||
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`/`warning`, or `error` |
|
||||
|
||||
Percent-encode Redis usernames or passwords that contain URL-reserved characters such as `@`, `:`, `/`, `#`, or `%`.
|
||||
|
||||
## Notes
|
||||
|
||||
The first successful poll seeds Redis and does not send historical incidents. Notifications start from later changes.
|
||||
|
||||
@@ -31,13 +31,9 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.RedisAddr,
|
||||
Password: cfg.RedisPassword,
|
||||
DB: cfg.RedisDB,
|
||||
})
|
||||
redisClient := redis.NewClient(cfg.RedisOptions)
|
||||
if err := redisClient.Ping(ctx).Err(); err != nil {
|
||||
logger.Error("connect redis", "addr", cfg.RedisAddr, "error", err)
|
||||
logger.Error("connect redis", "network", cfg.RedisOptions.Network, "addr", cfg.RedisOptions.Addr, "db", cfg.RedisOptions.DB, "tls", cfg.RedisOptions.TLSConfig != nil, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer redisClient.Close()
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
REDIS_ADDR: redis:6379
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
depends_on:
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
|
||||
+3
-1
@@ -11,6 +11,7 @@
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# set TELEGRAM_BOT_TOKEN in .env
|
||||
# REDIS_URL is overridden to redis://redis:6379/0 by docker compose
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
@@ -27,6 +28,7 @@ Run bot:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# export TELEGRAM_BOT_TOKEN or source .env with your shell workflow
|
||||
# REDIS_URL defaults to redis://localhost:6379/0
|
||||
go run ./cmd/openai-status-bot
|
||||
```
|
||||
|
||||
@@ -34,7 +36,7 @@ go run ./cmd/openai-status-bot
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go build ./cmd/openai-status-bot
|
||||
go build -o /tmp/openai-status-bot ./cmd/openai-status-bot
|
||||
```
|
||||
|
||||
Then send `/start` to the Telegram bot and run `/status`. If the same token was previously used by a webhook deployment, startup clears that webhook before long polling begins.
|
||||
@@ -38,7 +38,7 @@ Subscriber set members are `chatID` or `chatID:threadID`. Missing settings defau
|
||||
|
||||
## Runtime
|
||||
|
||||
The service uses Telegram `getUpdates`, so it does not need a public webhook URL. On startup it calls `deleteWebhook` before long polling, which allows migration from a webhook-based deployment using the same bot token. Docker Compose starts Redis and the bot.
|
||||
The service uses Telegram `getUpdates`, so it does not need a public webhook URL. On startup it calls `deleteWebhook` before long polling, which allows migration from a webhook-based deployment using the same bot token. Redis is configured with `REDIS_URL`. Docker Compose starts Redis and the bot.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
|
||||
+19
-26
@@ -5,16 +5,15 @@ import (
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
TelegramBotToken string
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
RedisOptions *redis.Options
|
||||
OpenAIStatusBaseURL string
|
||||
PollInterval time.Duration
|
||||
HTTPTimeout time.Duration
|
||||
@@ -33,22 +32,20 @@ const (
|
||||
func LoadFromEnv() (Config, error) {
|
||||
cfg := Config{
|
||||
TelegramBotToken: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")),
|
||||
RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"),
|
||||
RedisPassword: strings.TrimSpace(os.Getenv("REDIS_PASSWORD")),
|
||||
OpenAIStatusBaseURL: strings.TrimRight(getEnv("OPENAI_STATUS_BASE_URL", "https://status.openai.com"), "/"),
|
||||
}
|
||||
if cfg.TelegramBotToken == "" {
|
||||
return Config{}, fmt.Errorf("TELEGRAM_BOT_TOKEN is required")
|
||||
}
|
||||
var err error
|
||||
cfg.RedisOptions, err = parseRedisURL("REDIS_URL", getEnv("REDIS_URL", "redis://localhost:6379/0"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := validateBaseURL("OPENAI_STATUS_BASE_URL", cfg.OpenAIStatusBaseURL); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
var err error
|
||||
cfg.RedisDB, err = parseBoundedIntEnv("REDIS_DB", 0, minRedisDB, maxRedisDB)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.PollInterval, err = parseDurationEnv("POLL_INTERVAL", time.Minute, minPollInterval, maxPollInterval)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
@@ -72,21 +69,6 @@ func getEnv(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
func parseBoundedIntEnv(key string, fallback, minValue, maxValue int) (int, error) {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s must be an integer: %w", key, err)
|
||||
}
|
||||
if parsed < minValue || parsed > maxValue {
|
||||
return 0, fmt.Errorf("%s must be between %d and %d", key, minValue, maxValue)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseDurationEnv(key string, fallback, minValue, maxValue time.Duration) (time.Duration, error) {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
@@ -102,6 +84,17 @@ func parseDurationEnv(key string, fallback, minValue, maxValue time.Duration) (t
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseRedisURL(key, value string) (*redis.Options, error) {
|
||||
options, err := redis.ParseURL(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s must be a valid Redis URL", key)
|
||||
}
|
||||
if options.DB < minRedisDB || options.DB > maxRedisDB {
|
||||
return nil, fmt.Errorf("%s database must be between %d and %d", key, minRedisDB, maxRedisDB)
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func validateBaseURL(key, value string) error {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
func TestLoadFromEnvDefaultsAndTrimming(t *testing.T) {
|
||||
setMinimalEnv(t)
|
||||
t.Setenv("TELEGRAM_BOT_TOKEN", " token ")
|
||||
t.Setenv("REDIS_PASSWORD", " redis-pass ")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
if err != nil {
|
||||
@@ -19,14 +18,58 @@ func TestLoadFromEnvDefaultsAndTrimming(t *testing.T) {
|
||||
if cfg.TelegramBotToken != "token" {
|
||||
t.Fatalf("TelegramBotToken = %q", cfg.TelegramBotToken)
|
||||
}
|
||||
if cfg.RedisPassword != "redis-pass" {
|
||||
t.Fatalf("RedisPassword = %q", cfg.RedisPassword)
|
||||
if cfg.RedisOptions == nil || cfg.RedisOptions.Addr != "localhost:6379" || cfg.RedisOptions.DB != 0 {
|
||||
t.Fatalf("unexpected Redis options: %+v", cfg.RedisOptions)
|
||||
}
|
||||
if cfg.RedisDB != 0 || cfg.PollInterval != time.Minute || cfg.HTTPTimeout != 10*time.Second {
|
||||
if cfg.PollInterval != time.Minute || cfg.HTTPTimeout != 10*time.Second {
|
||||
t.Fatalf("unexpected defaults: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvIgnoresLegacyRedisVariables(t *testing.T) {
|
||||
setMinimalEnv(t)
|
||||
t.Setenv("REDIS_ADDR", "legacy-host:6380")
|
||||
t.Setenv("REDIS_PASSWORD", "legacy-password")
|
||||
t.Setenv("REDIS_DB", "5")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromEnv returned error: %v", err)
|
||||
}
|
||||
if cfg.RedisOptions == nil || cfg.RedisOptions.Addr != "localhost:6379" || cfg.RedisOptions.Password != "" || cfg.RedisOptions.DB != 0 {
|
||||
t.Fatalf("legacy Redis variables affected options: %+v", cfg.RedisOptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvAcceptsRedisURLCredentials(t *testing.T) {
|
||||
setMinimalEnv(t)
|
||||
t.Setenv("REDIS_URL", " redis://user:redis%40pass%3Awith%2Freserved@localhost:6380/2 ")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromEnv returned error: %v", err)
|
||||
}
|
||||
if cfg.RedisOptions == nil || cfg.RedisOptions.Addr != "localhost:6380" || cfg.RedisOptions.Username != "user" || cfg.RedisOptions.Password != "redis@pass:with/reserved" || cfg.RedisOptions.DB != 2 {
|
||||
t.Fatalf("unexpected Redis options: %+v", cfg.RedisOptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvDoesNotLeakRedisURLCredentialsInParseErrors(t *testing.T) {
|
||||
setMinimalEnv(t)
|
||||
t.Setenv("REDIS_URL", "redis://:secret%zz@localhost:6379/0")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "secret%zz") {
|
||||
t.Fatalf("error leaked Redis URL credentials: %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "REDIS_URL must be a valid Redis URL") {
|
||||
t.Fatalf("error = %q, want generic Redis URL error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvAcceptsWarningLogAlias(t *testing.T) {
|
||||
setMinimalEnv(t)
|
||||
t.Setenv("LOG_LEVEL", "warning")
|
||||
@@ -47,8 +90,10 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
||||
value string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "redis db below range", key: "REDIS_DB", value: "-1", wantErr: "REDIS_DB must be between 0 and 15"},
|
||||
{name: "redis db above range", key: "REDIS_DB", value: "16", wantErr: "REDIS_DB must be between 0 and 15"},
|
||||
{name: "redis url missing scheme", key: "REDIS_URL", value: "localhost:6379", wantErr: "REDIS_URL must be a valid Redis URL"},
|
||||
{name: "redis url invalid db", key: "REDIS_URL", value: "redis://localhost:6379/not-a-number", wantErr: "REDIS_URL must be a valid Redis URL"},
|
||||
{name: "redis url db below range", key: "REDIS_URL", value: "redis://localhost:6379/-1", wantErr: "REDIS_URL database must be between 0 and 15"},
|
||||
{name: "redis url db above range", key: "REDIS_URL", value: "redis://localhost:6379/16", wantErr: "REDIS_URL database must be between 0 and 15"},
|
||||
{name: "poll interval too small", key: "POLL_INTERVAL", value: "1ns", wantErr: "POLL_INTERVAL must be between 5s and 1h0m0s"},
|
||||
{name: "http timeout too small", key: "HTTP_TIMEOUT", value: "500ms", wantErr: "HTTP_TIMEOUT must be between 1s and 5m0s"},
|
||||
{name: "base url missing scheme", key: "OPENAI_STATUS_BASE_URL", value: "status.openai.com", wantErr: "OPENAI_STATUS_BASE_URL must use http or https"},
|
||||
@@ -77,9 +122,7 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
||||
func setMinimalEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("TELEGRAM_BOT_TOKEN", "token")
|
||||
t.Setenv("REDIS_ADDR", "")
|
||||
t.Setenv("REDIS_PASSWORD", "")
|
||||
t.Setenv("REDIS_DB", "")
|
||||
t.Setenv("REDIS_URL", "")
|
||||
t.Setenv("OPENAI_STATUS_BASE_URL", "")
|
||||
t.Setenv("POLL_INTERVAL", "")
|
||||
t.Setenv("HTTP_TIMEOUT", "")
|
||||
|
||||
Reference in new issue
Block a user