feat: scaffold openai status bot

This commit is contained in:
tiennm99 committed 2026-06-19 14:39:19 +07:00
commit 87f091beb8
25 files changed
+1418

No files matched your search

+8
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
.env
.env.*
!.env.example
/openai-status-bot
/dist/
/tmp/
*.log
+13
View File
@@ -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"]
+55
View File
@@ -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.
+59
View File
@@ -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)
}
}
+21
View File
@@ -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:
+40
View File
@@ -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`.
+42
View File
@@ -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.
+10
View File
@@ -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
)
+10
View File
@@ -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=
+153
View File
@@ -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)
}
}
+116
View File
@@ -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]) + "..."
}
+34
View File
@@ -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)
}
})
}
}
+97
View File
@@ -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
}
}
+63
View File
@@ -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
}
+46
View File
@@ -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")
}
}
+58
View File
@@ -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"`
}
+79
View File
@@ -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]) + "..."
}
+44
View File
@@ -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)
}
}
}
+172
View File
@@ -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)
}
}
+117
View File
@@ -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()
}
+32
View File
@@ -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")
}
}
+94
View File
@@ -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
}
+19
View File
@@ -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.