mirror of
https://github.com/tiennm99/openai-status-bot.git
synced 2026-10-11 03:13:41 +00:00
feat: scaffold openai status bot
This commit is contained in:
commit
87f091beb8
25 files changed
+1418
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
REDIS_ADDR=localhost:6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
OPENAI_STATUS_BASE_URL=https://status.openai.com
|
||||||
|
POLL_INTERVAL=1m
|
||||||
|
HTTP_TIMEOUT=10s
|
||||||
|
LOG_LEVEL=info
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
/openai-status-bot
|
||||||
|
/dist/
|
||||||
|
/tmp/
|
||||||
|
|
||||||
|
*.log
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
FROM golang:1.24-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum* ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/openai-status-bot ./cmd/openai-status-bot
|
||||||
|
|
||||||
|
FROM alpine:3.21
|
||||||
|
RUN adduser -D -H appuser
|
||||||
|
USER appuser
|
||||||
|
COPY --from=build /out/openai-status-bot /usr/local/bin/openai-status-bot
|
||||||
|
ENTRYPOINT ["openai-status-bot"]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# openai-status-bot
|
||||||
|
|
||||||
|
Telegram bot that watches [OpenAI Status](https://status.openai.com/) every minute and sends updates to subscribed chats. State and subscriptions are stored in Redis.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Checks OpenAI status on a configurable interval, default `1m`
|
||||||
|
- Notifies subscribers about new incident updates
|
||||||
|
- Notifies subscribers when component status changes
|
||||||
|
- Uses Redis for subscribers, component status checkpoints, and seen incident updates
|
||||||
|
- Supports Telegram supergroup topics via `message_thread_id`
|
||||||
|
- Includes Docker Compose for local Redis + bot runtime
|
||||||
|
|
||||||
|
## Bot Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/start` | Subscribe current chat or topic |
|
||||||
|
| `/stop` | Unsubscribe current chat or topic |
|
||||||
|
| `/status` | Show current OpenAI status |
|
||||||
|
| `/components` | Show all OpenAI component statuses |
|
||||||
|
| `/history [count]` | Show recent incidents, default 5, max 10 |
|
||||||
|
| `/help` | Show command help |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env and set TELEGRAM_BOT_TOKEN
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
For local development without Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go mod tidy
|
||||||
|
go run ./cmd/openai-status-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| `OPENAI_STATUS_BASE_URL` | `https://status.openai.com` | OpenAI status page base URL |
|
||||||
|
| `POLL_INTERVAL` | `1m` | Status check interval |
|
||||||
|
| `HTTP_TIMEOUT` | `10s` | HTTP request timeout |
|
||||||
|
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
The first successful poll seeds Redis and does not send historical incidents. Notifications start from later changes.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"github.com/tiennm99/openai-status-bot/internal/bot"
|
||||||
|
"github.com/tiennm99/openai-status-bot/internal/config"
|
||||||
|
openai "github.com/tiennm99/openai-status-bot/internal/openai"
|
||||||
|
"github.com/tiennm99/openai-status-bot/internal/poller"
|
||||||
|
"github.com/tiennm99/openai-status-bot/internal/redisstore"
|
||||||
|
"github.com/tiennm99/openai-status-bot/internal/telegram"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.LoadFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("load config", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: cfg.LogLevel,
|
||||||
|
}))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
if err := redisClient.Ping(ctx).Err(); err != nil {
|
||||||
|
logger.Error("connect redis", "addr", cfg.RedisAddr, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer redisClient.Close()
|
||||||
|
|
||||||
|
store := redisstore.New(redisClient)
|
||||||
|
statusClient := openai.NewClient(cfg.OpenAIStatusBaseURL, cfg.HTTPTimeout)
|
||||||
|
telegramClient := telegram.NewClient(cfg.TelegramBotToken, cfg.HTTPTimeout)
|
||||||
|
|
||||||
|
statusPoller := poller.NewRunner(statusClient, store, telegramClient, cfg.PollInterval, logger)
|
||||||
|
commandBot := bot.New(telegramClient, statusClient, store, logger)
|
||||||
|
|
||||||
|
go statusPoller.Run(ctx)
|
||||||
|
|
||||||
|
logger.Info("openai status bot started", "poll_interval", cfg.PollInterval.String())
|
||||||
|
if err := commandBot.Run(ctx); err != nil && ctx.Err() == nil {
|
||||||
|
logger.Error("telegram bot stopped", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
|
||||||
|
bot:
|
||||||
|
build: .
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
REDIS_ADDR: redis:6379
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Setup Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Go 1.24+
|
||||||
|
- Redis 7+
|
||||||
|
- Telegram bot token from BotFather
|
||||||
|
|
||||||
|
## Local Docker Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# set TELEGRAM_BOT_TOKEN in .env
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Go Run
|
||||||
|
|
||||||
|
Start Redis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 6379:6379 redis:7-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
Run bot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# export TELEGRAM_BOT_TOKEN or source .env with your shell workflow
|
||||||
|
go run ./cmd/openai-status-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
go build ./cmd/openai-status-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
Then send `/start` to the Telegram bot and run `/status`.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# System Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`openai-status-bot` is one Go process with two loops:
|
||||||
|
|
||||||
|
- Telegram long polling loop for user commands.
|
||||||
|
- OpenAI status polling loop, default every minute.
|
||||||
|
|
||||||
|
Redis stores subscribers and polling checkpoints.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. User sends `/start` in Telegram.
|
||||||
|
2. Bot stores chat ID and optional topic thread ID in Redis.
|
||||||
|
3. Poller fetches OpenAI status JSON:
|
||||||
|
- `GET /api/v2/summary.json`
|
||||||
|
- `GET /api/v2/incidents.json`
|
||||||
|
4. Poller compares fetched state with Redis checkpoints.
|
||||||
|
5. New incident updates or component status changes are sent to all subscribers.
|
||||||
|
|
||||||
|
## Redis Keys
|
||||||
|
|
||||||
|
| Key | Type | Purpose |
|
||||||
|
|-----|------|---------|
|
||||||
|
| `openai-status:subscribers` | set | Telegram chat or topic subscribers |
|
||||||
|
| `openai-status:component-statuses` | hash | Last seen component status by component ID |
|
||||||
|
| `openai-status:incident-updates` | set | Seen incident update IDs |
|
||||||
|
| `openai-status:initialized` | string | Baseline seed marker |
|
||||||
|
|
||||||
|
Subscriber set members are `chatID` or `chatID:threadID`.
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
The service uses Telegram `getUpdates`, so it does not need a public webhook URL. Docker Compose starts Redis and the bot.
|
||||||
|
|
||||||
|
## Failure Behavior
|
||||||
|
|
||||||
|
- First successful poll seeds state without notification.
|
||||||
|
- OpenAI fetch failures are logged and retried on the next interval.
|
||||||
|
- Telegram send failures are logged; other subscribers still receive messages.
|
||||||
|
- Redis connection failure at startup exits the process.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
module github.com/tiennm99/openai-status-bot
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require github.com/redis/go-redis/v9 v9.17.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
|
||||||
|
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
openai "github.com/tiennm99/openai-status-bot/internal/openai"
|
||||||
|
"github.com/tiennm99/openai-status-bot/internal/redisstore"
|
||||||
|
"github.com/tiennm99/openai-status-bot/internal/telegram"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TelegramClient interface {
|
||||||
|
GetUpdates(ctx context.Context, offset int64, timeoutSeconds int) ([]telegram.Update, error)
|
||||||
|
SendText(ctx context.Context, chatID int64, threadID *int, text string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusClient interface {
|
||||||
|
FetchSummary(ctx context.Context) (openai.Summary, error)
|
||||||
|
FetchIncidents(ctx context.Context) (openai.IncidentsResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
AddSubscriber(ctx context.Context, sub redisstore.Subscriber) error
|
||||||
|
RemoveSubscriber(ctx context.Context, sub redisstore.Subscriber) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bot struct {
|
||||||
|
telegramClient TelegramClient
|
||||||
|
statusClient StatusClient
|
||||||
|
store Store
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(telegramClient TelegramClient, statusClient StatusClient, store Store, logger *slog.Logger) *Bot {
|
||||||
|
return &Bot{
|
||||||
|
telegramClient: telegramClient,
|
||||||
|
statusClient: statusClient,
|
||||||
|
store: store,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) Run(ctx context.Context) error {
|
||||||
|
var offset int64
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
updates, err := b.telegramClient.GetUpdates(ctx, offset, 50)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b.logger.Warn("get telegram updates", "error", err)
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, update := range updates {
|
||||||
|
offset = update.UpdateID + 1
|
||||||
|
if update.Message == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.handleMessage(ctx, *update.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleMessage(ctx context.Context, message telegram.Message) {
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(message.Text), "/") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
command, fields := normalizeCommand(message.Text)
|
||||||
|
switch command {
|
||||||
|
case "/start":
|
||||||
|
b.subscribe(ctx, message)
|
||||||
|
case "/stop":
|
||||||
|
b.unsubscribe(ctx, message)
|
||||||
|
case "/status":
|
||||||
|
b.replyStatus(ctx, message)
|
||||||
|
case "/components":
|
||||||
|
b.replyComponents(ctx, message)
|
||||||
|
case "/history":
|
||||||
|
b.replyHistory(ctx, message, parseHistoryCount(fields))
|
||||||
|
case "/help":
|
||||||
|
b.reply(ctx, message, helpText)
|
||||||
|
default:
|
||||||
|
b.reply(ctx, message, "Unknown command. Use /help.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) subscribe(ctx context.Context, message telegram.Message) {
|
||||||
|
sub := redisstore.NewSubscriber(message.Chat.ID, message.MessageThreadID)
|
||||||
|
if err := b.store.AddSubscriber(ctx, sub); err != nil {
|
||||||
|
b.logger.Error("subscribe", "error", err)
|
||||||
|
b.reply(ctx, message, "Could not subscribe right now.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.reply(ctx, message, "Subscribed to OpenAI status updates.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) unsubscribe(ctx context.Context, message telegram.Message) {
|
||||||
|
sub := redisstore.NewSubscriber(message.Chat.ID, message.MessageThreadID)
|
||||||
|
if err := b.store.RemoveSubscriber(ctx, sub); err != nil {
|
||||||
|
b.logger.Error("unsubscribe", "error", err)
|
||||||
|
b.reply(ctx, message, "Could not unsubscribe right now.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.reply(ctx, message, "Unsubscribed from OpenAI status updates.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) replyStatus(ctx context.Context, message telegram.Message) {
|
||||||
|
summary, err := b.statusClient.FetchSummary(ctx)
|
||||||
|
if err != nil {
|
||||||
|
b.logger.Error("fetch status", "error", err)
|
||||||
|
b.reply(ctx, message, "Could not fetch OpenAI status right now.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.reply(ctx, message, formatStatus(summary))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) replyComponents(ctx context.Context, message telegram.Message) {
|
||||||
|
summary, err := b.statusClient.FetchSummary(ctx)
|
||||||
|
if err != nil {
|
||||||
|
b.logger.Error("fetch components", "error", err)
|
||||||
|
b.reply(ctx, message, "Could not fetch OpenAI components right now.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.reply(ctx, message, formatComponents(summary))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) replyHistory(ctx context.Context, message telegram.Message, count int) {
|
||||||
|
incidents, err := b.statusClient.FetchIncidents(ctx)
|
||||||
|
if err != nil {
|
||||||
|
b.logger.Error("fetch incidents", "error", err)
|
||||||
|
b.reply(ctx, message, "Could not fetch OpenAI incident history right now.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.reply(ctx, message, formatHistory(incidents.Incidents, count))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) reply(ctx context.Context, message telegram.Message, text string) {
|
||||||
|
if err := b.telegramClient.SendText(ctx, message.Chat.ID, message.MessageThreadID, text); err != nil {
|
||||||
|
b.logger.Warn("send telegram reply", "chat_id", message.Chat.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
openai "github.com/tiennm99/openai-status-bot/internal/openai"
|
||||||
|
"github.com/tiennm99/openai-status-bot/internal/poller"
|
||||||
|
)
|
||||||
|
|
||||||
|
const helpText = `OpenAI Status Bot
|
||||||
|
|
||||||
|
/start - subscribe this chat or topic
|
||||||
|
/stop - unsubscribe this chat or topic
|
||||||
|
/status - show current OpenAI status
|
||||||
|
/components - show all component statuses
|
||||||
|
/history [count] - show recent incidents, default 5, max 10
|
||||||
|
/help - show this help`
|
||||||
|
|
||||||
|
func formatStatus(summary openai.Summary) string {
|
||||||
|
lines := []string{
|
||||||
|
"OpenAI status",
|
||||||
|
"",
|
||||||
|
fmt.Sprintf("Overall: %s", summary.Status.Description),
|
||||||
|
}
|
||||||
|
|
||||||
|
degraded := make([]string, 0)
|
||||||
|
for _, component := range summary.Components {
|
||||||
|
if component.Group || component.Status == "operational" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
degraded = append(degraded, fmt.Sprintf("- %s: %s", component.Name, poller.StatusLabel(component.Status)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(degraded) == 0 {
|
||||||
|
lines = append(lines, "", "All listed components are operational.")
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "", "Affected components:")
|
||||||
|
lines = append(lines, degraded...)
|
||||||
|
}
|
||||||
|
lines = append(lines, "", "https://status.openai.com/")
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatComponents(summary openai.Summary) string {
|
||||||
|
lines := []string{"OpenAI components", ""}
|
||||||
|
for _, component := range summary.Components {
|
||||||
|
if component.Group {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("- %s: %s", component.Name, poller.StatusLabel(component.Status)))
|
||||||
|
}
|
||||||
|
return truncateMessage(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatHistory(incidents []openai.Incident, count int) string {
|
||||||
|
if len(incidents) == 0 {
|
||||||
|
return "No recent incidents found."
|
||||||
|
}
|
||||||
|
if count > len(incidents) {
|
||||||
|
count = len(incidents)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := []string{"Recent OpenAI incidents", ""}
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
incident := incidents[i]
|
||||||
|
lines = append(lines, fmt.Sprintf(
|
||||||
|
"%d. %s\n Status: %s | Impact: %s",
|
||||||
|
i+1,
|
||||||
|
incident.Name,
|
||||||
|
poller.StatusLabel(incident.Status),
|
||||||
|
poller.StatusLabel(incident.Impact),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return truncateMessage(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHistoryCount(fields []string) int {
|
||||||
|
const (
|
||||||
|
defaultCount = 5
|
||||||
|
maxCount = 10
|
||||||
|
)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return defaultCount
|
||||||
|
}
|
||||||
|
count, err := strconv.Atoi(fields[1])
|
||||||
|
if err != nil || count < 1 {
|
||||||
|
return defaultCount
|
||||||
|
}
|
||||||
|
if count > maxCount {
|
||||||
|
return maxCount
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCommand(text string) (string, []string) {
|
||||||
|
fields := strings.Fields(strings.TrimSpace(text))
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
command := strings.ToLower(fields[0])
|
||||||
|
if at := strings.Index(command, "@"); at >= 0 {
|
||||||
|
command = command[:at]
|
||||||
|
}
|
||||||
|
return command, fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateMessage(value string) string {
|
||||||
|
const telegramLimit = 3900
|
||||||
|
if len(value) <= telegramLimit {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(value[:telegramLimit-3]) + "..."
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeCommandStripsBotUsername(t *testing.T) {
|
||||||
|
command, fields := normalizeCommand("/history@OpenAIStatusBot 10")
|
||||||
|
if command != "/history" {
|
||||||
|
t.Fatalf("command = %q, want /history", command)
|
||||||
|
}
|
||||||
|
if len(fields) != 2 || fields[1] != "10" {
|
||||||
|
t.Fatalf("fields = %#v", fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseHistoryCount(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
fields []string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{name: "default", fields: []string{"/history"}, want: 5},
|
||||||
|
{name: "valid", fields: []string{"/history", "3"}, want: 3},
|
||||||
|
{name: "invalid", fields: []string{"/history", "abc"}, want: 5},
|
||||||
|
{name: "max", fields: []string{"/history", "99"}, want: 10},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := parseHistoryCount(tt.fields); got != tt.want {
|
||||||
|
t.Fatalf("parseHistoryCount(%v) = %d, want %d", tt.fields, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
TelegramBotToken string
|
||||||
|
RedisAddr string
|
||||||
|
RedisPassword string
|
||||||
|
RedisDB int
|
||||||
|
OpenAIStatusBaseURL string
|
||||||
|
PollInterval time.Duration
|
||||||
|
HTTPTimeout time.Duration
|
||||||
|
LogLevel slog.Level
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadFromEnv() (Config, error) {
|
||||||
|
cfg := Config{
|
||||||
|
TelegramBotToken: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")),
|
||||||
|
RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"),
|
||||||
|
RedisPassword: 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.RedisDB, err = parseIntEnv("REDIS_DB", 0)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
cfg.PollInterval, err = parseDurationEnv("POLL_INTERVAL", time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
cfg.HTTPTimeout, err = parseDurationEnv("HTTP_TIMEOUT", 10*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
cfg.LogLevel = parseLogLevel(getEnv("LOG_LEVEL", "info"))
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntEnv(key string, fallback 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)
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDurationEnv(key string, fallback time.Duration) (time.Duration, error) {
|
||||||
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if value == "" {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
parsed, err := time.ParseDuration(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%s must be a duration: %w", key, err)
|
||||||
|
}
|
||||||
|
if parsed <= 0 {
|
||||||
|
return 0, fmt.Errorf("%s must be positive", key)
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLogLevel(value string) slog.Level {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "debug":
|
||||||
|
return slog.LevelDebug
|
||||||
|
case "warn", "warning":
|
||||||
|
return slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
return slog.LevelError
|
||||||
|
default:
|
||||||
|
return slog.LevelInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(baseURL string, timeout time.Duration) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) FetchSummary(ctx context.Context) (Summary, error) {
|
||||||
|
var summary Summary
|
||||||
|
if err := c.getJSON(ctx, "/api/v2/summary.json", &summary); err != nil {
|
||||||
|
return Summary{}, err
|
||||||
|
}
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) FetchIncidents(ctx context.Context) (IncidentsResponse, error) {
|
||||||
|
var incidents IncidentsResponse
|
||||||
|
if err := c.getJSON(ctx, "/api/v2/incidents.json", &incidents); err != nil {
|
||||||
|
return IncidentsResponse{}, err
|
||||||
|
}
|
||||||
|
return incidents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getJSON(ctx context.Context, path string, target any) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "openai-status-bot/1.0")
|
||||||
|
|
||||||
|
res, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("openai status API returned %s", res.Status)
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(target); err != nil {
|
||||||
|
return fmt.Errorf("decode openai status response: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetchSummary(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v2/summary.json" {
|
||||||
|
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"page": {"id": "page", "name": "OpenAI"},
|
||||||
|
"status": {"description": "All Systems Operational", "indicator": "none"},
|
||||||
|
"components": [{"id": "c1", "name": "Codex Web", "status": "operational"}],
|
||||||
|
"incidents": []
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, time.Second)
|
||||||
|
summary, err := client.FetchSummary(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchSummary returned error: %v", err)
|
||||||
|
}
|
||||||
|
if summary.Status.Indicator != "none" || len(summary.Components) != 1 {
|
||||||
|
t.Fatalf("unexpected summary: %+v", summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchIncidentsReturnsHTTPError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "nope", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, time.Second)
|
||||||
|
if _, err := client.FetchIncidents(context.Background()); err == nil {
|
||||||
|
t.Fatal("expected HTTP error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
type Page struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OverallStatus struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
Indicator string `json:"indicator"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Component struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
PageID string `json:"page_id"`
|
||||||
|
Group bool `json:"group"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Summary struct {
|
||||||
|
Page Page `json:"page"`
|
||||||
|
Status OverallStatus `json:"status"`
|
||||||
|
Components []Component `json:"components"`
|
||||||
|
Incidents []Incident `json:"incidents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Incident struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
ResolvedAt string `json:"resolved_at"`
|
||||||
|
Impact string `json:"impact"`
|
||||||
|
PageID string `json:"page_id"`
|
||||||
|
IncidentUpdates []IncidentUpdate `json:"incident_updates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncidentUpdate struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
DisplayAt string `json:"display_at"`
|
||||||
|
IncidentID string `json:"incident_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncidentsResponse struct {
|
||||||
|
Page Page `json:"page"`
|
||||||
|
Incidents []Incident `json:"incidents"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package poller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
openai "github.com/tiennm99/openai-status-bot/internal/openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
func FormatComponentChange(component openai.Component, previousStatus string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"OpenAI component status changed\n\nComponent: %s\nStatus: %s -> %s\n\n%s",
|
||||||
|
component.Name,
|
||||||
|
StatusLabel(previousStatus),
|
||||||
|
StatusLabel(component.Status),
|
||||||
|
"https://status.openai.com/",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatIncidentUpdate(incident openai.Incident, update openai.IncidentUpdate) string {
|
||||||
|
body := strings.TrimSpace(update.Body)
|
||||||
|
if body == "" {
|
||||||
|
body = "No update message provided."
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"OpenAI incident update\n\n%s\nStatus: %s\nImpact: %s\n\n%s\n\n%s",
|
||||||
|
incident.Name,
|
||||||
|
StatusLabel(update.Status),
|
||||||
|
StatusLabel(incident.Impact),
|
||||||
|
truncate(body, 2600),
|
||||||
|
"https://status.openai.com/incidents/"+incident.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func StatusLabel(value string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "":
|
||||||
|
return "Unknown"
|
||||||
|
case "operational":
|
||||||
|
return "Operational"
|
||||||
|
case "degraded_performance":
|
||||||
|
return "Degraded performance"
|
||||||
|
case "partial_outage":
|
||||||
|
return "Partial outage"
|
||||||
|
case "major_outage":
|
||||||
|
return "Major outage"
|
||||||
|
case "under_maintenance":
|
||||||
|
return "Under maintenance"
|
||||||
|
case "none":
|
||||||
|
return "None"
|
||||||
|
case "minor":
|
||||||
|
return "Minor"
|
||||||
|
case "major":
|
||||||
|
return "Major"
|
||||||
|
case "critical":
|
||||||
|
return "Critical"
|
||||||
|
case "investigating":
|
||||||
|
return "Investigating"
|
||||||
|
case "identified":
|
||||||
|
return "Identified"
|
||||||
|
case "monitoring":
|
||||||
|
return "Monitoring"
|
||||||
|
case "resolved":
|
||||||
|
return "Resolved"
|
||||||
|
default:
|
||||||
|
return strings.ReplaceAll(value, "_", " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(value string, limit int) string {
|
||||||
|
if len(value) <= limit {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if limit <= 3 {
|
||||||
|
return value[:limit]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(value[:limit-3]) + "..."
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package poller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
openai "github.com/tiennm99/openai-status-bot/internal/openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatusLabel(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"operational": "Operational",
|
||||||
|
"degraded_performance": "Degraded performance",
|
||||||
|
"partial_outage": "Partial outage",
|
||||||
|
"minor": "Minor",
|
||||||
|
"": "Unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
for input, expected := range tests {
|
||||||
|
if got := StatusLabel(input); got != expected {
|
||||||
|
t.Fatalf("StatusLabel(%q) = %q, want %q", input, got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatIncidentUpdateIncludesCoreFields(t *testing.T) {
|
||||||
|
message := FormatIncidentUpdate(
|
||||||
|
openai.Incident{
|
||||||
|
ID: "inc_123",
|
||||||
|
Name: "Codex outage",
|
||||||
|
Impact: "major",
|
||||||
|
},
|
||||||
|
openai.IncidentUpdate{
|
||||||
|
Status: "identified",
|
||||||
|
Body: "We found the issue.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, want := range []string{"Codex outage", "Identified", "Major", "We found the issue.", "inc_123"} {
|
||||||
|
if !strings.Contains(message, want) {
|
||||||
|
t.Fatalf("message missing %q: %s", want, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package poller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
openai "github.com/tiennm99/openai-status-bot/internal/openai"
|
||||||
|
"github.com/tiennm99/openai-status-bot/internal/redisstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatusClient interface {
|
||||||
|
FetchSummary(ctx context.Context) (openai.Summary, error)
|
||||||
|
FetchIncidents(ctx context.Context) (openai.IncidentsResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
ComponentStatuses(ctx context.Context) (map[string]string, error)
|
||||||
|
HasIncidentUpdate(ctx context.Context, updateID string) (bool, error)
|
||||||
|
IsInitialized(ctx context.Context) (bool, error)
|
||||||
|
ListSubscribers(ctx context.Context) ([]redisstore.Subscriber, error)
|
||||||
|
MarkIncidentUpdate(ctx context.Context, updateID string) error
|
||||||
|
SaveComponentStatus(ctx context.Context, componentID, status string) error
|
||||||
|
SetInitialized(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Notifier interface {
|
||||||
|
SendMessage(ctx context.Context, sub redisstore.Subscriber, text string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Runner struct {
|
||||||
|
statusClient StatusClient
|
||||||
|
store Store
|
||||||
|
notifier Notifier
|
||||||
|
interval time.Duration
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRunner(statusClient StatusClient, store Store, notifier Notifier, interval time.Duration, logger *slog.Logger) *Runner {
|
||||||
|
return &Runner{
|
||||||
|
statusClient: statusClient,
|
||||||
|
store: store,
|
||||||
|
notifier: notifier,
|
||||||
|
interval: interval,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) Run(ctx context.Context) {
|
||||||
|
r.checkAndLog(ctx)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(r.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
r.checkAndLog(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) CheckOnce(ctx context.Context) error {
|
||||||
|
initialized, err := r.store.IsInitialized(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := r.statusClient.FetchSummary(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
incidents, err := r.statusClient.FetchIncidents(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, err := r.collectComponentMessages(ctx, summary, initialized)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
incidentMessages, err := r.collectIncidentMessages(ctx, incidents, initialized)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
messages = append(messages, incidentMessages...)
|
||||||
|
|
||||||
|
if initialized && len(messages) > 0 {
|
||||||
|
r.notifyAll(ctx, messages)
|
||||||
|
}
|
||||||
|
if !initialized {
|
||||||
|
if err := r.store.SetInitialized(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.logger.Info("seeded status baseline")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) collectComponentMessages(ctx context.Context, summary openai.Summary, initialized bool) ([]string, error) {
|
||||||
|
knownStatuses, err := r.store.ComponentStatuses(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := make([]string, 0)
|
||||||
|
for _, component := range summary.Components {
|
||||||
|
if component.ID == "" || component.Group {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
previousStatus, found := knownStatuses[component.ID]
|
||||||
|
if initialized && found && previousStatus != component.Status {
|
||||||
|
messages = append(messages, FormatComponentChange(component, previousStatus))
|
||||||
|
}
|
||||||
|
if err := r.store.SaveComponentStatus(ctx, component.ID, component.Status); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) collectIncidentMessages(ctx context.Context, response openai.IncidentsResponse, initialized bool) ([]string, error) {
|
||||||
|
messages := make([]string, 0)
|
||||||
|
for _, incident := range response.Incidents {
|
||||||
|
for i := len(incident.IncidentUpdates) - 1; i >= 0; i-- {
|
||||||
|
update := incident.IncidentUpdates[i]
|
||||||
|
if update.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen, err := r.store.HasIncidentUpdate(ctx, update.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if initialized && !seen {
|
||||||
|
messages = append(messages, FormatIncidentUpdate(incident, update))
|
||||||
|
}
|
||||||
|
if !seen {
|
||||||
|
if err := r.store.MarkIncidentUpdate(ctx, update.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) notifyAll(ctx context.Context, messages []string) {
|
||||||
|
subscribers, err := r.store.ListSubscribers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("list subscribers", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(subscribers) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, message := range messages {
|
||||||
|
for _, subscriber := range subscribers {
|
||||||
|
if err := r.notifier.SendMessage(ctx, subscriber, message); err != nil {
|
||||||
|
r.logger.Warn("send telegram message", "subscriber", subscriber.Key(), "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) checkAndLog(ctx context.Context) {
|
||||||
|
if err := r.CheckOnce(ctx); err != nil && ctx.Err() == nil {
|
||||||
|
r.logger.Error("poll openai status", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package redisstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
subscribersKey = "openai-status:subscribers"
|
||||||
|
componentStatusesKey = "openai-status:component-statuses"
|
||||||
|
incidentUpdatesKey = "openai-status:incident-updates"
|
||||||
|
initializedKey = "openai-status:initialized"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Subscriber struct {
|
||||||
|
ChatID int64
|
||||||
|
ThreadID *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSubscriber(chatID int64, threadID *int) Subscriber {
|
||||||
|
var copiedThreadID *int
|
||||||
|
if threadID != nil {
|
||||||
|
value := *threadID
|
||||||
|
copiedThreadID = &value
|
||||||
|
}
|
||||||
|
return Subscriber{ChatID: chatID, ThreadID: copiedThreadID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Subscriber) Key() string {
|
||||||
|
if s.ThreadID == nil {
|
||||||
|
return strconv.FormatInt(s.ChatID, 10)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d:%d", s.ChatID, *s.ThreadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseSubscriberKey(value string) (Subscriber, error) {
|
||||||
|
parts := strings.Split(value, ":")
|
||||||
|
if len(parts) != 1 && len(parts) != 2 {
|
||||||
|
return Subscriber{}, fmt.Errorf("invalid subscriber key %q", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return Subscriber{}, fmt.Errorf("invalid chat ID: %w", err)
|
||||||
|
}
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return Subscriber{ChatID: chatID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
threadID, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return Subscriber{}, fmt.Errorf("invalid thread ID: %w", err)
|
||||||
|
}
|
||||||
|
return NewSubscriber(chatID, &threadID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
client redis.UniversalClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(client redis.UniversalClient) *Store {
|
||||||
|
return &Store{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AddSubscriber(ctx context.Context, sub Subscriber) error {
|
||||||
|
return s.client.SAdd(ctx, subscribersKey, sub.Key()).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RemoveSubscriber(ctx context.Context, sub Subscriber) error {
|
||||||
|
return s.client.SRem(ctx, subscribersKey, sub.Key()).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListSubscribers(ctx context.Context) ([]Subscriber, error) {
|
||||||
|
keys, err := s.client.SMembers(ctx, subscribersKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribers := make([]Subscriber, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
sub, err := ParseSubscriberKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
subscribers = append(subscribers, sub)
|
||||||
|
}
|
||||||
|
return subscribers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) IsInitialized(ctx context.Context) (bool, error) {
|
||||||
|
count, err := s.client.Exists(ctx, initializedKey).Result()
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetInitialized(ctx context.Context) error {
|
||||||
|
return s.client.Set(ctx, initializedKey, "1", 0).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ComponentStatuses(ctx context.Context) (map[string]string, error) {
|
||||||
|
return s.client.HGetAll(ctx, componentStatusesKey).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveComponentStatus(ctx context.Context, componentID, status string) error {
|
||||||
|
return s.client.HSet(ctx, componentStatusesKey, componentID, status).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) HasIncidentUpdate(ctx context.Context, updateID string) (bool, error) {
|
||||||
|
return s.client.SIsMember(ctx, incidentUpdatesKey, updateID).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MarkIncidentUpdate(ctx context.Context, updateID string) error {
|
||||||
|
return s.client.SAdd(ctx, incidentUpdatesKey, updateID).Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package redisstore
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSubscriberKeyRoundTripWithoutThread(t *testing.T) {
|
||||||
|
sub := NewSubscriber(12345, nil)
|
||||||
|
parsed, err := ParseSubscriberKey(sub.Key())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSubscriberKey returned error: %v", err)
|
||||||
|
}
|
||||||
|
if parsed.ChatID != sub.ChatID || parsed.ThreadID != nil {
|
||||||
|
t.Fatalf("parsed subscriber = %+v, want %+v", parsed, sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscriberKeyRoundTripWithThreadZero(t *testing.T) {
|
||||||
|
threadID := 0
|
||||||
|
sub := NewSubscriber(-10012345, &threadID)
|
||||||
|
parsed, err := ParseSubscriberKey(sub.Key())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSubscriberKey returned error: %v", err)
|
||||||
|
}
|
||||||
|
if parsed.ChatID != sub.ChatID || parsed.ThreadID == nil || *parsed.ThreadID != threadID {
|
||||||
|
t.Fatalf("parsed subscriber = %+v, want thread ID %d", parsed, threadID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSubscriberKeyRejectsInvalidValue(t *testing.T) {
|
||||||
|
if _, err := ParseSubscriberKey("abc:def:ghi"); err == nil {
|
||||||
|
t.Fatal("expected invalid key error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tiennm99/openai-status-bot/internal/redisstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(token string, timeout time.Duration) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: "https://api.telegram.org/bot" + token,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: timeout + 60*time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetUpdates(ctx context.Context, offset int64, timeoutSeconds int) ([]Update, error) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"offset": offset,
|
||||||
|
"timeout": timeoutSeconds,
|
||||||
|
"allowed_updates": []string{"message"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates []Update
|
||||||
|
if err := c.postJSON(ctx, "/getUpdates", payload, &updates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return updates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SendMessage(ctx context.Context, sub redisstore.Subscriber, text string) error {
|
||||||
|
return c.SendText(ctx, sub.ChatID, sub.ThreadID, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) SendText(ctx context.Context, chatID int64, threadID *int, text string) error {
|
||||||
|
payload := map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"text": text,
|
||||||
|
}
|
||||||
|
if threadID != nil {
|
||||||
|
payload["message_thread_id"] = *threadID
|
||||||
|
}
|
||||||
|
|
||||||
|
var result json.RawMessage
|
||||||
|
return c.postJSON(ctx, "/sendMessage", payload, &result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) postJSON(ctx context.Context, path string, payload any, target any) error {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
res, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
var envelope struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil {
|
||||||
|
return fmt.Errorf("decode telegram response: %w", err)
|
||||||
|
}
|
||||||
|
if !envelope.OK {
|
||||||
|
return fmt.Errorf("telegram API %s: %s", res.Status, envelope.Description)
|
||||||
|
}
|
||||||
|
if target != nil {
|
||||||
|
if err := json.Unmarshal(envelope.Result, target); err != nil {
|
||||||
|
return fmt.Errorf("decode telegram result: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package telegram
|
||||||
|
|
||||||
|
type Update struct {
|
||||||
|
UpdateID int64 `json:"update_id"`
|
||||||
|
Message *Message `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
|
MessageThreadID *int `json:"message_thread_id,omitempty"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Chat Chat `json:"chat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Chat struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Initial Scaffold Plan
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Completed.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
| Phase | Status | Output |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| Scaffold Go project | Completed | `go.mod`, source layout, Docker files |
|
||||||
|
| Implement bot runtime | Completed | Telegram long polling, commands |
|
||||||
|
| Implement status checks | Completed | OpenAI JSON client, one-minute poller |
|
||||||
|
| Implement Redis storage | Completed | Subscribers and checkpoint keys |
|
||||||
|
| Verify | Completed | Unit tests and build |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- Go project exists at `/config/workspace/tiennm99/openai-status-bot`.
|
||||||
|
- Uses Redis as database for subscribers and poll state.
|
||||||
|
- Checks OpenAI status every minute by default.
|
||||||
|
- Telegram users can subscribe and query current status.
|
||||||
|
- Tests and build pass.
|
||||||
|
|
||||||
|
## Unresolved Questions
|
||||||
|
|
||||||
|
- None.
|
||||||
Reference in new issue
Block a user