fix: align status bot delivery behavior

This commit is contained in:
tiennm99 committed 2026-06-19 15:56:59 +07:00
1 parent 87f091beb8
commit 2a4519106e
28 files changed
+1915 -290

No files matched your search

+10 -2
View File
@@ -7,8 +7,10 @@ Telegram bot that watches [OpenAI Status](https://status.openai.com/) every minu
- 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 incident-only, component-only, and component-filtered subscriptions
- Uses Redis for subscribers, delivery state, component checkpoints, and seen incident update versions
- Supports Telegram supergroup topics via `message_thread_id`
- Clears an existing Telegram webhook before long polling, for migration from webhook deployments
- Includes Docker Compose for local Redis + bot runtime
## Bot Commands
@@ -17,9 +19,13 @@ Telegram bot that watches [OpenAI Status](https://status.openai.com/) every minu
|---------|-------------|
| `/start` | Subscribe current chat or topic |
| `/stop` | Unsubscribe current chat or topic |
| `/status` | Show current OpenAI status |
| `/status [component]` | Show current OpenAI status, optionally for one component |
| `/components` | Show all OpenAI component statuses |
| `/subscribe <incident|component|all>` | Set notification types |
| `/subscribe component <name|id|all>` | Filter component notifications or clear the filter |
| `/history [count]` | Show recent incidents, default 5, max 10 |
| `/uptime` | Show component health overview |
| `/info` | Show chat ID and subscription settings |
| `/help` | Show command help |
## Quick Start
@@ -53,3 +59,5 @@ go run ./cmd/openai-status-bot
## Notes
The first successful poll seeds Redis and does not send historical incidents. Notifications start from later changes.
Incident update dedupe tracks the update content/version, so edited Statuspage updates can notify again. Delivery is checkpointed after successful fan-out; retryable Telegram failures may be retried on a later poll without advancing the global checkpoint.
+12 -1
View File
@@ -45,9 +45,20 @@ func main() {
store := redisstore.New(redisClient)
statusClient := openai.NewClient(cfg.OpenAIStatusBaseURL, cfg.HTTPTimeout)
telegramClient := telegram.NewClient(cfg.TelegramBotToken, cfg.HTTPTimeout)
if err := telegramClient.DeleteWebhook(ctx); err != nil {
logger.Error("delete telegram webhook", "error", err)
os.Exit(1)
}
botUsername := ""
if me, err := telegramClient.GetMe(ctx); err != nil {
logger.Warn("get telegram bot profile", "error", err)
} else {
botUsername = me.Username
}
statusPoller := poller.NewRunner(statusClient, store, telegramClient, cfg.PollInterval, logger)
commandBot := bot.New(telegramClient, statusClient, store, logger)
commandBot := bot.New(telegramClient, statusClient, store, logger, botUsername)
go statusPoller.Run(ctx)
+1 -1
View File
@@ -37,4 +37,4 @@ go test ./...
go build ./cmd/openai-status-bot
```
Then send `/start` to the Telegram bot and run `/status`.
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.
+18 -9
View File
@@ -7,36 +7,45 @@
- Telegram long polling loop for user commands.
- OpenAI status polling loop, default every minute.
Redis stores subscribers and polling checkpoints.
Redis stores subscribers, subscription settings, polling checkpoints, delivery retry state, and the Telegram update offset.
## 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:
2. Bot stores chat ID, optional topic thread ID, and default subscription settings in Redis.
3. Users can adjust settings with `/subscribe` and inspect them with `/info`.
4. 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.
5. Poller compares fetched state with Redis checkpoints and builds notification events without mutating checkpoints.
6. Events are sent to eligible subscribers, respecting incident/component preferences and component ID filters.
7. Component and incident checkpoints are written only after delivery succeeds or terminal subscriber failures are removed.
## Redis Keys
| Key | Type | Purpose |
|-----|------|---------|
| `openai-status:subscribers` | set | Telegram chat or topic subscribers |
| `openai-status:subscriber-settings` | hash | Subscription types and component ID filters by subscriber key |
| `openai-status:component-statuses` | hash | Last seen component status by component ID |
| `openai-status:incident-updates` | set | Seen incident update IDs |
| `openai-status:incident-updates` | set | Legacy seen incident update IDs, retained for migration |
| `openai-status:incident-update-versions` | hash | Seen incident update version by update ID |
| `openai-status:event-delivery:<hash>` | set | Temporary per-event subscriber delivery state for retry isolation |
| `openai-status:telegram-offset` | string | Last processed Telegram update offset |
| `openai-status:initialized` | string | Baseline seed marker |
Subscriber set members are `chatID` or `chatID:threadID`.
Subscriber set members are `chatID` or `chatID:threadID`. Missing settings default to incident and component notifications for backward compatibility with older Redis state.
## Runtime
The service uses Telegram `getUpdates`, so it does not need a public webhook URL. 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. 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.
- Retryable Telegram failures do not advance component or incident checkpoints.
- Successful per-subscriber deliveries are tracked temporarily, so retrying one failed subscriber does not resend to already-delivered subscribers.
- Telegram 403 and selected terminal 400 errors remove the unreachable subscriber, then delivery continues.
- Malformed subscriber keys are skipped instead of blocking all fan-out.
- Redis connection failure at startup exits the process.
+36 -45
View File
@@ -23,7 +23,12 @@ type StatusClient interface {
type Store interface {
AddSubscriber(ctx context.Context, sub redisstore.Subscriber) error
GetSubscriber(ctx context.Context, sub redisstore.Subscriber) (redisstore.Subscriber, bool, error)
RemoveSubscriber(ctx context.Context, sub redisstore.Subscriber) error
SaveTelegramOffset(ctx context.Context, offset int64) error
TelegramOffset(ctx context.Context) (int64, error)
UpdateSubscriberComponents(ctx context.Context, sub redisstore.Subscriber, components []string) (bool, error)
UpdateSubscriberTypes(ctx context.Context, sub redisstore.Subscriber, types []string) (bool, error)
}
type Bot struct {
@@ -31,19 +36,28 @@ type Bot struct {
statusClient StatusClient
store Store
logger *slog.Logger
username string
}
func New(telegramClient TelegramClient, statusClient StatusClient, store Store, logger *slog.Logger) *Bot {
func New(telegramClient TelegramClient, statusClient StatusClient, store Store, logger *slog.Logger, username ...string) *Bot {
botUsername := ""
if len(username) > 0 {
botUsername = username[0]
}
return &Bot{
telegramClient: telegramClient,
statusClient: statusClient,
store: store,
logger: logger,
username: botUsername,
}
}
func (b *Bot) Run(ctx context.Context) error {
var offset int64
offset, err := b.store.TelegramOffset(ctx)
if err != nil {
b.logger.Warn("load telegram offset", "error", err)
}
for {
select {
@@ -63,11 +77,16 @@ func (b *Bot) Run(ctx context.Context) error {
}
for _, update := range updates {
offset = update.UpdateID + 1
if update.Message == nil {
if update.UpdateID < offset {
continue
}
b.handleMessage(ctx, *update.Message)
if update.Message != nil {
b.handleMessage(ctx, *update.Message)
}
offset = update.UpdateID + 1
if err := b.store.SaveTelegramOffset(ctx, offset); err != nil {
b.logger.Warn("save telegram offset", "offset", offset, "error", err)
}
}
}
}
@@ -77,18 +96,26 @@ func (b *Bot) handleMessage(ctx context.Context, message telegram.Message) {
return
}
command, fields := normalizeCommand(message.Text)
command, fields := normalizeCommand(message.Text, b.username)
switch command {
case "":
return
case "/start":
b.subscribe(ctx, message)
case "/stop":
b.unsubscribe(ctx, message)
case "/status":
b.replyStatus(ctx, message)
b.replyStatus(ctx, message, fields)
case "/components":
b.replyComponents(ctx, message)
case "/subscribe":
b.replySubscribe(ctx, message, fields)
case "/history":
b.replyHistory(ctx, message, parseHistoryCount(fields))
case "/uptime":
b.replyUptime(ctx, message)
case "/info":
b.replyInfo(ctx, message)
case "/help":
b.reply(ctx, message, helpText)
default:
@@ -103,7 +130,7 @@ func (b *Bot) subscribe(ctx context.Context, message telegram.Message) {
b.reply(ctx, message, "Could not subscribe right now.")
return
}
b.reply(ctx, message, "Subscribed to OpenAI status updates.")
b.reply(ctx, message, "Subscribed to OpenAI status updates (incidents + components). Use /subscribe to change preferences.")
}
func (b *Bot) unsubscribe(ctx context.Context, message telegram.Message) {
@@ -113,41 +140,5 @@ func (b *Bot) unsubscribe(ctx context.Context, message telegram.Message) {
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)
}
b.reply(ctx, message, "Unsubscribed from OpenAI status updates. Use /start to resubscribe.")
}
+155
View File
@@ -0,0 +1,155 @@
package bot
import (
"context"
"log/slog"
"strings"
"testing"
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 fakeTelegramClient struct {
replies []string
}
func (f *fakeTelegramClient) GetUpdates(context.Context, int64, int) ([]telegram.Update, error) {
return nil, nil
}
func (f *fakeTelegramClient) SendText(_ context.Context, _ int64, _ *int, text string) error {
f.replies = append(f.replies, text)
return nil
}
type fakeBotStatusClient struct {
summary openai.Summary
}
func (f fakeBotStatusClient) FetchSummary(context.Context) (openai.Summary, error) {
return f.summary, nil
}
func (f fakeBotStatusClient) FetchIncidents(context.Context) (openai.IncidentsResponse, error) {
return openai.IncidentsResponse{}, nil
}
type fakeBotStore struct {
sub redisstore.Subscriber
subscribed bool
types []string
components []string
}
func (f *fakeBotStore) AddSubscriber(_ context.Context, sub redisstore.Subscriber) error {
f.sub = sub
f.subscribed = true
f.types = redisstore.DefaultSubscriptionTypes()
return nil
}
func (f *fakeBotStore) GetSubscriber(context.Context, redisstore.Subscriber) (redisstore.Subscriber, bool, error) {
if !f.subscribed {
return redisstore.Subscriber{}, false, nil
}
sub := f.sub
sub.Types = append([]string{}, f.types...)
sub.Components = append([]string{}, f.components...)
return sub, true, nil
}
func (f *fakeBotStore) RemoveSubscriber(context.Context, redisstore.Subscriber) error {
f.subscribed = false
return nil
}
func (f *fakeBotStore) SaveTelegramOffset(context.Context, int64) error { return nil }
func (f *fakeBotStore) TelegramOffset(context.Context) (int64, error) { return 0, nil }
func (f *fakeBotStore) UpdateSubscriberComponents(_ context.Context, _ redisstore.Subscriber, components []string) (bool, error) {
if !f.subscribed {
return false, nil
}
f.components = append([]string{}, components...)
return true, nil
}
func (f *fakeBotStore) UpdateSubscriberTypes(_ context.Context, _ redisstore.Subscriber, types []string) (bool, error) {
if !f.subscribed {
return false, nil
}
f.types = append([]string{}, types...)
return true, nil
}
func TestSubscribeComponentStoresComponentID(t *testing.T) {
bot, store, tg := newTestBot([]openai.Component{{ID: "c-api", Name: "API", Status: "operational"}})
store.subscribed = true
store.sub = redisstore.NewSubscriber(123, nil)
store.types = []string{redisstore.SubscriptionTypeIncident}
bot.handleMessage(context.Background(), messageText("/subscribe component api"))
if len(store.components) != 1 || store.components[0] != "c-api" {
t.Fatalf("components = %v, want [c-api]", store.components)
}
if !containsComponent(store.types, redisstore.SubscriptionTypeComponent) {
t.Fatalf("types = %v, want component enabled", store.types)
}
if len(tg.replies) != 1 || !strings.Contains(tg.replies[0], "Subscribed to component") {
t.Fatalf("reply = %v", tg.replies)
}
}
func TestSubscribeComponentAllEnablesComponentType(t *testing.T) {
bot, store, _ := newTestBot(nil)
store.subscribed = true
store.sub = redisstore.NewSubscriber(123, nil)
store.types = []string{redisstore.SubscriptionTypeIncident}
store.components = []string{"c-api"}
bot.handleMessage(context.Background(), messageText("/subscribe component all"))
if len(store.components) != 0 {
t.Fatalf("components = %v, want cleared", store.components)
}
if !containsComponent(store.types, redisstore.SubscriptionTypeComponent) {
t.Fatalf("types = %v, want component enabled", store.types)
}
}
func TestStatusAmbiguousComponentNameRequiresID(t *testing.T) {
bot, _, tg := newTestBot([]openai.Component{
{ID: "login-a", Name: "Login", Status: "operational"},
{ID: "login-b", Name: "Login", Status: "operational"},
})
bot.handleMessage(context.Background(), messageText("/status Login"))
if len(tg.replies) != 1 || !strings.Contains(tg.replies[0], "ambiguous") || !strings.Contains(tg.replies[0], "login-a") {
t.Fatalf("reply = %v", tg.replies)
}
}
func TestCommandForOtherBotIsIgnored(t *testing.T) {
bot, _, tg := newTestBot(nil)
bot.handleMessage(context.Background(), messageText("/start@OtherBot"))
if len(tg.replies) != 0 {
t.Fatalf("replies = %v, want none", tg.replies)
}
}
func newTestBot(components []openai.Component) (*Bot, *fakeBotStore, *fakeTelegramClient) {
tg := &fakeTelegramClient{}
store := &fakeBotStore{}
statusClient := fakeBotStatusClient{summary: openai.Summary{Components: components}}
return New(tg, statusClient, store, slog.Default(), "OpenAIStatusBot"), store, tg
}
func messageText(text string) telegram.Message {
return telegram.Message{Text: text, Chat: telegram.Chat{ID: 123}}
}
+19
View File
@@ -0,0 +1,19 @@
package bot
import "strings"
func normalizeCommand(text, botUsername 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 {
target := command[at+1:]
if botUsername != "" && !strings.EqualFold(target, botUsername) {
return "", fields
}
command = command[:at]
}
return command, fields
}
+160
View File
@@ -0,0 +1,160 @@
package bot
import (
"context"
"fmt"
"strings"
"github.com/tiennm99/openai-status-bot/internal/redisstore"
"github.com/tiennm99/openai-status-bot/internal/telegram"
)
func (b *Bot) replySubscribe(ctx context.Context, message telegram.Message, fields []string) {
sub := redisstore.NewSubscriber(message.Chat.ID, message.MessageThreadID)
current, subscribed, err := b.store.GetSubscriber(ctx, sub)
if err != nil {
b.logger.Error("get subscription", "error", err)
b.reply(ctx, message, "Could not load subscription right now.")
return
}
if len(fields) < 2 {
b.reply(ctx, message, formatSubscribeUsage(currentOrTarget(current, sub, subscribed), subscribed))
return
}
args := fields[1:]
arg := strings.ToLower(args[0])
if arg == redisstore.SubscriptionTypeComponent && len(args) > 1 {
b.updateComponentFilter(ctx, message, sub, strings.Join(args[1:], " "), subscribed)
return
}
var types []string
switch arg {
case redisstore.SubscriptionTypeIncident:
types = []string{redisstore.SubscriptionTypeIncident}
case redisstore.SubscriptionTypeComponent:
types = []string{redisstore.SubscriptionTypeComponent}
case "all":
types = redisstore.DefaultSubscriptionTypes()
default:
b.reply(ctx, message, formatSubscribeUsage(currentOrTarget(current, sub, subscribed), subscribed))
return
}
updated, err := b.store.UpdateSubscriberTypes(ctx, sub, types)
if err != nil {
b.logger.Error("update subscription", "error", err)
b.reply(ctx, message, "Could not update subscription right now.")
return
}
if !updated {
b.reply(ctx, message, "Not subscribed yet. Use /start first.")
return
}
b.reply(ctx, message, fmt.Sprintf("Subscription updated: <code>%s</code>", escape(strings.Join(types, ", "))))
}
func (b *Bot) updateComponentFilter(ctx context.Context, message telegram.Message, sub redisstore.Subscriber, componentArg string, subscribed bool) {
if !subscribed {
b.reply(ctx, message, "Not subscribed yet. Use /start first.")
return
}
componentArg = strings.TrimSpace(componentArg)
if strings.EqualFold(componentArg, "all") {
current, _, err := b.store.GetSubscriber(ctx, sub)
if err != nil {
b.logger.Error("load subscription", "error", err)
b.reply(ctx, message, "Could not update component filter right now.")
return
}
if _, err := b.store.UpdateSubscriberComponents(ctx, sub, nil); err != nil {
b.logger.Error("clear component filter", "error", err)
b.reply(ctx, message, "Could not update component filter right now.")
return
}
if !containsComponent(current.Types, redisstore.SubscriptionTypeComponent) {
current.Types = append(current.Types, redisstore.SubscriptionTypeComponent)
if _, err := b.store.UpdateSubscriberTypes(ctx, sub, current.Types); err != nil {
b.logger.Error("update component type", "error", err)
b.reply(ctx, message, "Could not update subscription right now.")
return
}
}
b.reply(ctx, message, "Component filter cleared. Receiving all component updates.")
return
}
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
}
resolution := resolveComponent(summary.Components, componentArg)
if !resolution.Found {
b.reply(ctx, message, fmt.Sprintf("Component <code>%s</code> not found.", escape(componentArg)))
return
}
if resolution.Ambiguous {
b.reply(ctx, message, formatAmbiguousComponents(componentArg, resolution.Matches))
return
}
component := resolution.Component
current, _, err := b.store.GetSubscriber(ctx, sub)
if err != nil {
b.logger.Error("load component filters", "error", err)
b.reply(ctx, message, "Could not update component filter right now.")
return
}
components := append([]string{}, current.Components...)
if !containsComponent(components, component.ID) {
components = append(components, component.ID)
}
if _, err := b.store.UpdateSubscriberComponents(ctx, sub, components); err != nil {
b.logger.Error("update component filter", "error", err)
b.reply(ctx, message, "Could not update component filter right now.")
return
}
if !containsComponent(current.Types, redisstore.SubscriptionTypeComponent) {
current.Types = append(current.Types, redisstore.SubscriptionTypeComponent)
if _, err := b.store.UpdateSubscriberTypes(ctx, sub, current.Types); err != nil {
b.logger.Error("update component type", "error", err)
b.reply(ctx, message, "Could not update subscription right now.")
return
}
}
b.reply(ctx, message, fmt.Sprintf("Subscribed to component: <code>%s</code>\nActive filters: <code>%s</code>", escape(componentLabel(component, duplicateComponentNames(summary.Components)[component.Name])), escape(componentFilterLabels(summary.Components, components))))
}
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) replyUptime(ctx context.Context, message telegram.Message) {
summary, err := b.statusClient.FetchSummary(ctx)
if err != nil {
b.logger.Error("fetch uptime", "error", err)
b.reply(ctx, message, "Could not fetch OpenAI uptime right now.")
return
}
b.reply(ctx, message, formatUptime(summary))
}
func (b *Bot) replyInfo(ctx context.Context, message telegram.Message) {
sub := redisstore.NewSubscriber(message.Chat.ID, message.MessageThreadID)
current, subscribed, err := b.store.GetSubscriber(ctx, sub)
if err != nil {
b.logger.Error("get subscription info", "error", err)
b.reply(ctx, message, "Could not load subscription right now.")
return
}
b.reply(ctx, message, formatInfo(currentOrTarget(current, sub, subscribed), subscribed))
}
+121
View File
@@ -0,0 +1,121 @@
package bot
import (
"fmt"
"strings"
openai "github.com/tiennm99/openai-status-bot/internal/openai"
)
type componentResolution struct {
Component openai.Component
Matches []openai.Component
Found bool
Ambiguous bool
}
func resolveComponent(components []openai.Component, query string) componentResolution {
query = strings.TrimSpace(query)
if query == "" {
return componentResolution{}
}
for _, component := range components {
if component.Group {
continue
}
if strings.EqualFold(component.ID, query) {
return componentResolution{Component: component, Found: true}
}
}
exact := matchingComponents(components, query, func(name, query string) bool {
return strings.EqualFold(name, query)
})
if len(exact) == 1 {
return componentResolution{Component: exact[0], Found: true}
}
if len(exact) > 1 {
return componentResolution{Matches: exact, Found: true, Ambiguous: true}
}
contains := matchingComponents(components, query, func(name, query string) bool {
return strings.Contains(strings.ToLower(name), strings.ToLower(query))
})
if len(contains) == 1 {
return componentResolution{Component: contains[0], Found: true}
}
if len(contains) > 1 {
return componentResolution{Matches: contains, Found: true, Ambiguous: true}
}
return componentResolution{}
}
func matchingComponents(components []openai.Component, query string, matches func(name, query string) bool) []openai.Component {
result := make([]openai.Component, 0)
for _, component := range components {
if component.Group {
continue
}
if matches(component.Name, query) {
result = append(result, component)
}
}
return result
}
func formatAmbiguousComponents(query string, matches []openai.Component) string {
duplicates := duplicateComponentNames(matches)
lines := []string{fmt.Sprintf("Component <code>%s</code> is ambiguous. Use one of these IDs:", escape(query)), ""}
for _, component := range matches {
lines = append(lines, fmt.Sprintf("- %s: <code>%s</code>", escape(componentLabel(component, duplicates[component.Name])), escape(component.ID)))
}
return truncateMessage(strings.Join(lines, "\n"))
}
func componentFilterLabels(components []openai.Component, ids []string) string {
if len(ids) == 0 {
return "all"
}
duplicates := duplicateComponentNames(components)
labels := make([]string, 0, len(ids))
for _, id := range ids {
label := id
for _, component := range components {
if component.ID == id {
label = componentLabel(component, duplicates[component.Name])
break
}
}
labels = append(labels, label)
}
return strings.Join(labels, ", ")
}
func duplicateComponentNames(components []openai.Component) map[string]bool {
counts := map[string]int{}
for _, component := range components {
if component.Group {
continue
}
counts[component.Name]++
}
duplicates := map[string]bool{}
for name, count := range counts {
duplicates[name] = count > 1
}
return duplicates
}
func componentLabel(component openai.Component, duplicate bool) string {
if !duplicate || component.ID == "" {
return component.Name
}
return fmt.Sprintf("%s (ID: %s)", component.Name, shortID(component.ID))
}
func shortID(value string) string {
if len(value) <= 8 {
return value
}
return value[:8]
}
+97 -28
View File
@@ -2,35 +2,44 @@ package bot
import (
"fmt"
"html"
"strconv"
"strings"
"time"
openai "github.com/tiennm99/openai-status-bot/internal/openai"
"github.com/tiennm99/openai-status-bot/internal/poller"
)
const helpText = `OpenAI Status Bot
const statusURL = "https://status.openai.com/"
const helpText = `<b>OpenAI Status Bot</b>
/start - subscribe this chat or topic
/stop - unsubscribe this chat or topic
/status - show current OpenAI status
/status [component] - show current OpenAI status
/components - show all component statuses
/subscribe &lt;incident|component|all&gt; - set notification types
/subscribe component &lt;name|all&gt; - filter component updates
/history [count] - show recent incidents, default 5, max 10
/uptime - show component health overview
/info - show chat and subscription details
/help - show this help`
func formatStatus(summary openai.Summary) string {
lines := []string{
"OpenAI status",
"<b>OpenAI Status</b>",
"",
fmt.Sprintf("Overall: %s", summary.Status.Description),
fmt.Sprintf("Overall: <code>%s</code>", escape(summary.Status.Description)),
}
duplicates := duplicateComponentNames(summary.Components)
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)))
degraded = append(degraded, fmt.Sprintf("- %s: <code>%s</code>", escape(componentLabel(component, duplicates[component.Name])), escape(poller.StatusLabel(component.Status))))
}
if len(degraded) == 0 {
@@ -39,18 +48,29 @@ func formatStatus(summary openai.Summary) string {
lines = append(lines, "", "Affected components:")
lines = append(lines, degraded...)
}
lines = append(lines, "", "https://status.openai.com/")
lines = append(lines, "", fmt.Sprintf("<a href=\"%s\">View full status page</a>", statusURL))
return strings.Join(lines, "\n")
}
func formatComponentStatus(component openai.Component, duplicate bool) string {
return fmt.Sprintf(
"<b>%s</b>\n\nStatus: <code>%s</code>\nLast change: %s UTC\n\n<a href=\"%s\">View full status page</a>",
escape(componentLabel(component, duplicate)),
escape(poller.StatusLabel(component.Status)),
escape(formatTime(component.UpdatedAt)),
statusURL,
)
}
func formatComponents(summary openai.Summary) string {
lines := []string{"OpenAI components", ""}
lines := []string{"<b>OpenAI Components</b>", ""}
duplicates := duplicateComponentNames(summary.Components)
for _, component := range summary.Components {
if component.Group {
continue
}
lines = append(lines, fmt.Sprintf("- %s: %s", component.Name, poller.StatusLabel(component.Status)))
lines = append(lines, fmt.Sprintf("- %s: <code>%s</code>", escape(componentLabel(component, duplicates[component.Name])), escape(poller.StatusLabel(component.Status))))
}
return truncateMessage(strings.Join(lines, "\n"))
}
@@ -63,17 +83,46 @@ func formatHistory(incidents []openai.Incident, count int) string {
count = len(incidents)
}
lines := []string{"Recent OpenAI incidents", ""}
lines := []string{"<b>Recent OpenAI Incidents</b>", ""}
for i := 0; i < count; i++ {
incident := incidents[i]
lines = append(lines, fmt.Sprintf(
"%d. %s\n Status: %s | Impact: %s",
link := incident.Shortlink
if link == "" && incident.ID != "" {
link = statusURL + "incidents/" + incident.ID
}
entry := fmt.Sprintf(
"%d. <b>[%s]</b> %s\n Created: %s UTC\n Status: <code>%s</code>",
i+1,
incident.Name,
poller.StatusLabel(incident.Status),
poller.StatusLabel(incident.Impact),
escape(strings.ToUpper(emptyDefault(incident.Impact, "unknown"))),
escape(incident.Name),
escape(formatDate(incident.CreatedAt)),
escape(poller.StatusLabel(incident.Status)),
)
if link != "" {
entry += fmt.Sprintf("\n <a href=\"%s\">Details</a>", escapeAttr(link))
}
lines = append(lines, entry)
}
lines = append(lines, "", fmt.Sprintf("<a href=\"%shistory\">View full history</a>", statusURL))
return truncateMessage(strings.Join(lines, "\n\n"))
}
func formatUptime(summary openai.Summary) string {
lines := []string{"<b>OpenAI Component Health</b>", ""}
duplicates := duplicateComponentNames(summary.Components)
for _, component := range summary.Components {
if component.Group {
continue
}
lines = append(lines, fmt.Sprintf(
"%s\n Status: <code>%s</code>\n Last change: %s UTC",
escape(componentLabel(component, duplicates[component.Name])),
escape(poller.StatusLabel(component.Status)),
escape(formatTime(component.UpdatedAt)),
))
}
lines = append(lines, "", "Uptime percentage is not available from the public Statuspage API.")
lines = append(lines, fmt.Sprintf("<a href=\"%s\">View full status page</a>", statusURL))
return truncateMessage(strings.Join(lines, "\n"))
}
@@ -95,22 +144,42 @@ func parseHistoryCount(fields []string) int {
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 {
runes := []rune(value)
if len(runes) <= telegramLimit {
return value
}
return strings.TrimSpace(value[:telegramLimit-3]) + "..."
return strings.TrimSpace(string(runes[:telegramLimit-3])) + "..."
}
func formatDate(value string) string {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return emptyDefault(value, "unknown")
}
return parsed.UTC().Format("Jan 2, 2006")
}
func formatTime(value string) string {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return emptyDefault(value, "unknown")
}
return parsed.UTC().Format("Jan 2, 2006 15:04")
}
func emptyDefault(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func escape(value string) string {
return html.EscapeString(value)
}
func escapeAttr(value string) string {
return html.EscapeString(value)
}
+9 -2
View File
@@ -2,8 +2,8 @@ package bot
import "testing"
func TestNormalizeCommandStripsBotUsername(t *testing.T) {
command, fields := normalizeCommand("/history@OpenAIStatusBot 10")
func TestNormalizeCommandStripsOwnBotUsername(t *testing.T) {
command, fields := normalizeCommand("/history@OpenAIStatusBot 10", "OpenAIStatusBot")
if command != "/history" {
t.Fatalf("command = %q, want /history", command)
}
@@ -12,6 +12,13 @@ func TestNormalizeCommandStripsBotUsername(t *testing.T) {
}
}
func TestNormalizeCommandIgnoresOtherBotUsername(t *testing.T) {
command, _ := normalizeCommand("/start@OtherBot", "OpenAIStatusBot")
if command != "" {
t.Fatalf("command = %q, want empty", command)
}
}
func TestParseHistoryCount(t *testing.T) {
tests := []struct {
name string
+67
View File
@@ -0,0 +1,67 @@
package bot
import (
"context"
"fmt"
"strings"
"github.com/tiennm99/openai-status-bot/internal/redisstore"
"github.com/tiennm99/openai-status-bot/internal/telegram"
)
func (b *Bot) replyStatus(ctx context.Context, message telegram.Message, fields []string) {
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
}
if len(fields) > 1 {
query := strings.Join(fields[1:], " ")
resolution := resolveComponent(summary.Components, query)
if !resolution.Found {
b.reply(ctx, message, fmt.Sprintf("Component <code>%s</code> not found.", escape(query)))
return
}
if resolution.Ambiguous {
b.reply(ctx, message, formatAmbiguousComponents(query, resolution.Matches))
return
}
duplicates := duplicateComponentNames(summary.Components)
b.reply(ctx, message, formatComponentStatus(resolution.Component, duplicates[resolution.Component.Name]))
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) 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)
}
}
func currentOrTarget(current, target redisstore.Subscriber, subscribed bool) redisstore.Subscriber {
if subscribed {
return current
}
return target
}
func containsComponent(values []string, target string) bool {
for _, value := range values {
if strings.EqualFold(value, target) {
return true
}
}
return false
}
+45
View File
@@ -0,0 +1,45 @@
package bot
import (
"fmt"
"strconv"
"strings"
"github.com/tiennm99/openai-status-bot/internal/redisstore"
)
func formatInfo(sub redisstore.Subscriber, subscribed bool) string {
chatID := strconv.FormatInt(sub.ChatID, 10)
if sub.ThreadID != nil {
chatID = fmt.Sprintf("%s:%d", chatID, *sub.ThreadID)
}
if !subscribed {
return fmt.Sprintf("<b>Chat Info</b>\n\nChat ID: <code>%s</code>\n\nNot subscribed. Use /start to subscribe.", escape(chatID))
}
components := "all"
if len(sub.Components) > 0 {
components = strings.Join(sub.Components, ", ")
}
return fmt.Sprintf(
"<b>Chat Info</b>\n\nChat ID: <code>%s</code>\n\nTypes: <code>%s</code>\nComponents: <code>%s</code>",
escape(chatID),
escape(strings.Join(sub.Types, ", ")),
escape(components),
)
}
func formatSubscribeUsage(sub redisstore.Subscriber, subscribed bool) string {
current := "none (use /start first)"
components := "all"
if subscribed {
current = strings.Join(sub.Types, ", ")
if len(sub.Components) > 0 {
components = strings.Join(sub.Components, ", ")
}
}
return fmt.Sprintf(
"<b>Usage:</b> /subscribe &lt;type&gt; [component]\n\nTypes: <code>incident</code>, <code>component</code>, <code>all</code>\nComponent filter: <code>/subscribe component api</code>\nClear filter: <code>/subscribe component all</code>\n\nCurrent types: <code>%s</code>\nComponents: <code>%s</code>",
escape(current),
escape(components),
)
}
+2
View File
@@ -20,6 +20,7 @@ type Component struct {
UpdatedAt string `json:"updated_at"`
Position int `json:"position"`
PageID string `json:"page_id"`
GroupID string `json:"group_id"`
Group bool `json:"group"`
}
@@ -38,6 +39,7 @@ type Incident struct {
UpdatedAt string `json:"updated_at"`
ResolvedAt string `json:"resolved_at"`
Impact string `json:"impact"`
Shortlink string `json:"shortlink"`
PageID string `json:"page_id"`
IncidentUpdates []IncidentUpdate `json:"incident_updates"`
}
+47
View File
@@ -0,0 +1,47 @@
package poller
import (
"context"
"github.com/tiennm99/openai-status-bot/internal/redisstore"
"github.com/tiennm99/openai-status-bot/internal/telegram"
)
func (r *Runner) notifySubscribers(ctx context.Context, event notificationEvent, subscribers []redisstore.Subscriber, removed map[string]bool) error {
for _, subscriber := range subscribers {
subscriberKey := subscriber.Key()
if removed[subscriberKey] {
continue
}
if !subscriber.Accepts(event.eventType, event.componentID, event.componentName) {
continue
}
if event.deliveryKey != "" {
delivered, err := r.store.HasDelivered(ctx, event.deliveryKey, subscriberKey)
if err != nil {
return err
}
if delivered {
continue
}
}
if err := r.notifier.SendMessage(ctx, subscriber, event.text); err != nil {
if telegram.IsTerminalSendError(err) {
if removeErr := r.store.RemoveSubscriber(ctx, subscriber); removeErr != nil {
return removeErr
}
removed[subscriberKey] = true
r.logger.Info("removed unreachable telegram subscriber", "subscriber", subscriberKey, "error", err)
continue
}
r.logger.Warn("send telegram message", "subscriber", subscriberKey, "error", err)
return err
}
if event.deliveryKey != "" {
if err := r.store.MarkDelivered(ctx, event.deliveryKey, subscriberKey); err != nil {
return err
}
}
}
return nil
}
+198
View File
@@ -0,0 +1,198 @@
package poller
import (
"context"
"crypto/sha256"
"fmt"
"sort"
"strings"
openai "github.com/tiennm99/openai-status-bot/internal/openai"
"github.com/tiennm99/openai-status-bot/internal/redisstore"
)
func (r *Runner) collectEvents(ctx context.Context, summary openai.Summary, incidents openai.IncidentsResponse, initialized bool) ([]notificationEvent, []checkpoint, []checkpoint, error) {
componentEvents, componentBefore, componentAfter, err := r.collectComponentEvents(ctx, summary, initialized)
if err != nil {
return nil, nil, nil, err
}
incidentEvents, incidentAfter, err := r.collectIncidentEvents(ctx, incidents, initialized)
if err != nil {
return nil, nil, nil, err
}
events := append(componentEvents, incidentEvents...)
sort.SliceStable(events, func(i, j int) bool {
if events[i].sortTime == "" {
return false
}
if events[j].sortTime == "" {
return true
}
return events[i].sortTime < events[j].sortTime
})
after := append(componentAfter, incidentAfter...)
return events, componentBefore, after, nil
}
func (r *Runner) collectComponentEvents(ctx context.Context, summary openai.Summary, initialized bool) ([]notificationEvent, []checkpoint, []checkpoint, error) {
knownStatuses, err := r.store.ComponentStatuses(ctx)
if err != nil {
return nil, nil, nil, err
}
pending, err := r.store.PendingComponentEvents(ctx)
if err != nil {
return nil, nil, nil, err
}
componentsForLabels := append([]openai.Component{}, summary.Components...)
for _, pendingEvent := range pending {
componentsForLabels = append(componentsForLabels, pendingComponent(pendingEvent))
}
duplicates := duplicateComponentNames(componentsForLabels)
events := make([]notificationEvent, 0)
before := make([]checkpoint, 0)
after := make([]checkpoint, 0, len(summary.Components)+len(pending))
for _, pendingEvent := range pending {
pendingEvent := pendingEvent
component := pendingComponent(pendingEvent)
events = append(events, notificationEvent{
eventType: redisstore.SubscriptionTypeComponent,
componentID: pendingEvent.ComponentID,
componentName: pendingEvent.ComponentName,
deliveryKey: pendingEvent.DeliveryKey,
sortTime: pendingEvent.UpdatedAt,
text: FormatComponentChange(component, pendingEvent.PreviousStatus, duplicates[component.Name]),
})
after = append(after, func(ctx context.Context) error {
return r.store.SaveComponentStatus(ctx, pendingEvent.ComponentID, pendingEvent.Status)
})
after = append(after, func(ctx context.Context) error {
return r.store.RemovePendingComponentEvent(ctx, pendingEvent.ComponentID)
})
after = append(after, func(ctx context.Context) error {
return r.store.ClearDelivery(ctx, pendingEvent.DeliveryKey)
})
}
for _, component := range summary.Components {
if component.ID == "" || component.Group {
continue
}
component := component
if _, hasPending := pending[component.ID]; hasPending {
continue
}
previousStatus, found := knownStatuses[component.ID]
if !initialized {
after = append(after, func(ctx context.Context) error {
return r.store.SaveComponentStatus(ctx, component.ID, component.Status)
})
continue
}
if found && previousStatus == component.Status {
after = append(after, func(ctx context.Context) error {
return r.store.SaveComponentStatus(ctx, component.ID, component.Status)
})
continue
}
if !found {
previousStatus = "unknown"
}
deliveryKey := fmt.Sprintf("component:%s:%s:%s", component.ID, component.Status, component.UpdatedAt)
pendingEvent := redisstore.PendingComponentEvent{
ComponentID: component.ID,
ComponentName: component.Name,
Status: component.Status,
UpdatedAt: component.UpdatedAt,
Position: component.Position,
PreviousStatus: previousStatus,
DeliveryKey: deliveryKey,
}
before = append(before, func(ctx context.Context) error {
return r.store.SavePendingComponentEvent(ctx, pendingEvent)
})
events = append(events, notificationEvent{
eventType: redisstore.SubscriptionTypeComponent,
componentID: component.ID,
componentName: component.Name,
deliveryKey: deliveryKey,
sortTime: component.UpdatedAt,
text: FormatComponentChange(component, previousStatus, duplicates[component.Name]),
})
after = append(after, func(ctx context.Context) error {
return r.store.SaveComponentStatus(ctx, component.ID, component.Status)
})
after = append(after, func(ctx context.Context) error {
return r.store.RemovePendingComponentEvent(ctx, component.ID)
})
after = append(after, func(ctx context.Context) error {
return r.store.ClearDelivery(ctx, deliveryKey)
})
}
return events, before, after, nil
}
func (r *Runner) collectIncidentEvents(ctx context.Context, response openai.IncidentsResponse, initialized bool) ([]notificationEvent, []checkpoint, error) {
events := make([]notificationEvent, 0)
checkpoints := make([]checkpoint, 0)
for _, incident := range response.Incidents {
incident := incident
for _, update := range incident.IncidentUpdates {
if update.ID == "" {
continue
}
update := update
version := IncidentUpdateVersion(update)
seen, err := r.store.HasIncidentUpdateVersion(ctx, update.ID, version)
if err != nil {
return nil, nil, err
}
checkpoints = append(checkpoints, func(ctx context.Context) error {
return r.store.MarkIncidentUpdateVersion(ctx, update.ID, version)
})
deliveryKey := fmt.Sprintf("incident:%s:%s", update.ID, version)
if initialized && !seen {
events = append(events, notificationEvent{
eventType: redisstore.SubscriptionTypeIncident,
deliveryKey: deliveryKey,
sortTime: incidentUpdateSortTime(update),
text: FormatIncidentUpdate(incident, update),
})
checkpoints = append(checkpoints, func(ctx context.Context) error {
return r.store.ClearDelivery(ctx, deliveryKey)
})
}
}
}
return events, checkpoints, nil
}
func pendingComponent(event redisstore.PendingComponentEvent) openai.Component {
return openai.Component{
ID: event.ComponentID,
Name: event.ComponentName,
Status: event.Status,
UpdatedAt: event.UpdatedAt,
Position: event.Position,
}
}
func IncidentUpdateVersion(update openai.IncidentUpdate) string {
parts := []string{update.UpdatedAt, update.Status, update.Body, update.DisplayAt, update.CreatedAt}
sum := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
return fmt.Sprintf("%x", sum[:])
}
func incidentUpdateSortTime(update openai.IncidentUpdate) string {
for _, value := range []string{update.DisplayAt, update.CreatedAt, update.UpdatedAt} {
if value != "" {
return value
}
}
return ""
}
+60 -19
View File
@@ -2,18 +2,18 @@ package poller
import (
"fmt"
"html"
"strings"
openai "github.com/tiennm99/openai-status-bot/internal/openai"
)
func FormatComponentChange(component openai.Component, previousStatus string) string {
func FormatComponentChange(component openai.Component, previousStatus string, duplicate bool) 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/",
"<b>OpenAI component update</b>\n\nComponent: <b>%s</b>\nStatus: <code>%s</code> -&gt; <code>%s</code>\n\n<a href=\"https://status.openai.com/\">View full status page</a>",
escape(componentLabel(component, duplicate)),
escape(StatusLabel(previousStatus)),
escape(StatusLabel(component.Status)),
)
}
@@ -23,13 +23,18 @@ func FormatIncidentUpdate(incident openai.Incident, update openai.IncidentUpdate
body = "No update message provided."
}
link := incident.Shortlink
if link == "" && incident.ID != "" {
link = "https://status.openai.com/incidents/" + incident.ID
}
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,
"<b>OpenAI incident update</b>\n\n<b>%s</b>\nStatus: <code>%s</code>\nImpact: <code>%s</code>\n\n%s\n\n<a href=\"%s\">View incident</a>",
escape(incident.Name),
escape(StatusLabel(update.Status)),
escape(StatusLabel(incident.Impact)),
escape(truncate(body, 2600)),
escape(link),
)
}
@@ -40,13 +45,13 @@ func StatusLabel(value string) string {
case "operational":
return "Operational"
case "degraded_performance":
return "Degraded performance"
return "Degraded Performance"
case "partial_outage":
return "Partial outage"
return "Partial Outage"
case "major_outage":
return "Major outage"
return "Major Outage"
case "under_maintenance":
return "Under maintenance"
return "Under Maintenance"
case "none":
return "None"
case "minor":
@@ -55,6 +60,8 @@ func StatusLabel(value string) string {
return "Major"
case "critical":
return "Critical"
case "maintenance":
return "Maintenance"
case "investigating":
return "Investigating"
case "identified":
@@ -68,12 +75,46 @@ func StatusLabel(value string) string {
}
}
func duplicateComponentNames(components []openai.Component) map[string]bool {
counts := map[string]int{}
for _, component := range components {
if component.Group {
continue
}
counts[component.Name]++
}
duplicates := map[string]bool{}
for name, count := range counts {
duplicates[name] = count > 1
}
return duplicates
}
func componentLabel(component openai.Component, duplicate bool) string {
if !duplicate || component.ID == "" {
return component.Name
}
return fmt.Sprintf("%s (ID: %s)", component.Name, shortID(component.ID))
}
func shortID(value string) string {
if len(value) <= 8 {
return value
}
return value[:8]
}
func truncate(value string, limit int) string {
if len(value) <= limit {
runes := []rune(value)
if len(runes) <= limit {
return value
}
if limit <= 3 {
return value[:limit]
return string(runes[:limit])
}
return strings.TrimSpace(value[:limit-3]) + "..."
return strings.TrimSpace(string(runes[:limit-3])) + "..."
}
func escape(value string) string {
return html.EscapeString(value)
}
+2 -2
View File
@@ -10,8 +10,8 @@ import (
func TestStatusLabel(t *testing.T) {
tests := map[string]string{
"operational": "Operational",
"degraded_performance": "Degraded performance",
"partial_outage": "Partial outage",
"degraded_performance": "Degraded Performance",
"partial_outage": "Partial Outage",
"minor": "Minor",
"": "Unknown",
}
+44 -76
View File
@@ -16,10 +16,17 @@ type StatusClient interface {
type Store interface {
ComponentStatuses(ctx context.Context) (map[string]string, error)
HasIncidentUpdate(ctx context.Context, updateID string) (bool, error)
ClearDelivery(ctx context.Context, eventKey string) error
HasDelivered(ctx context.Context, eventKey, subscriberKey string) (bool, error)
HasIncidentUpdateVersion(ctx context.Context, updateID, version string) (bool, error)
IsInitialized(ctx context.Context) (bool, error)
ListSubscribers(ctx context.Context) ([]redisstore.Subscriber, error)
MarkIncidentUpdate(ctx context.Context, updateID string) error
MarkDelivered(ctx context.Context, eventKey, subscriberKey string) error
MarkIncidentUpdateVersion(ctx context.Context, updateID, version string) error
PendingComponentEvents(ctx context.Context) (map[string]redisstore.PendingComponentEvent, error)
RemovePendingComponentEvent(ctx context.Context, componentID string) error
RemoveSubscriber(ctx context.Context, sub redisstore.Subscriber) error
SavePendingComponentEvent(ctx context.Context, event redisstore.PendingComponentEvent) error
SaveComponentStatus(ctx context.Context, componentID, status string) error
SetInitialized(ctx context.Context) error
}
@@ -36,6 +43,17 @@ type Runner struct {
logger *slog.Logger
}
type notificationEvent struct {
eventType string
componentID string
componentName string
deliveryKey string
sortTime string
text string
}
type checkpoint func(ctx context.Context) error
func NewRunner(statusClient StatusClient, store Store, notifier Notifier, interval time.Duration, logger *slog.Logger) *Runner {
return &Runner{
statusClient: statusClient,
@@ -77,18 +95,34 @@ func (r *Runner) CheckOnce(ctx context.Context) error {
return err
}
messages, err := r.collectComponentMessages(ctx, summary, initialized)
events, beforeDelivery, checkpoints, err := r.collectEvents(ctx, summary, incidents, 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)
for _, save := range beforeDelivery {
if err := save(ctx); err != nil {
return err
}
}
if initialized && len(events) > 0 {
subscribers, err := r.store.ListSubscribers(ctx)
if err != nil {
return err
}
removedSubscribers := map[string]bool{}
for _, event := range events {
if err := r.notifySubscribers(ctx, event, subscribers, removedSubscribers); err != nil {
return err
}
}
}
for _, save := range checkpoints {
if err := save(ctx); err != nil {
return err
}
}
if !initialized {
if err := r.store.SetInitialized(ctx); err != nil {
@@ -99,72 +133,6 @@ func (r *Runner) CheckOnce(ctx context.Context) error {
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)
+266
View File
@@ -0,0 +1,266 @@
package poller
import (
"context"
"errors"
"log/slog"
"testing"
"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 fakeStatusClient struct {
summary openai.Summary
incidents openai.IncidentsResponse
}
func (f fakeStatusClient) FetchSummary(context.Context) (openai.Summary, error) {
return f.summary, nil
}
func (f fakeStatusClient) FetchIncidents(context.Context) (openai.IncidentsResponse, error) {
return f.incidents, nil
}
type fakePollerStore struct {
initialized bool
componentStatuses map[string]string
incidentVersions map[string]string
subscribers []redisstore.Subscriber
delivered map[string]map[string]bool
savedComponents map[string]string
markedVersions map[string]string
pendingComponents map[string]redisstore.PendingComponentEvent
removed []string
}
func newFakePollerStore() *fakePollerStore {
return &fakePollerStore{
componentStatuses: map[string]string{},
incidentVersions: map[string]string{},
delivered: map[string]map[string]bool{},
savedComponents: map[string]string{},
markedVersions: map[string]string{},
pendingComponents: map[string]redisstore.PendingComponentEvent{},
}
}
func (f *fakePollerStore) ComponentStatuses(context.Context) (map[string]string, error) {
return f.componentStatuses, nil
}
func (f *fakePollerStore) ClearDelivery(_ context.Context, eventKey string) error {
delete(f.delivered, eventKey)
return nil
}
func (f *fakePollerStore) HasDelivered(_ context.Context, eventKey, subscriberKey string) (bool, error) {
return f.delivered[eventKey][subscriberKey], nil
}
func (f *fakePollerStore) HasIncidentUpdateVersion(_ context.Context, updateID, version string) (bool, error) {
stored, ok := f.incidentVersions[updateID]
return ok && stored == version, nil
}
func (f *fakePollerStore) IsInitialized(context.Context) (bool, error) {
return f.initialized, nil
}
func (f *fakePollerStore) ListSubscribers(context.Context) ([]redisstore.Subscriber, error) {
return f.subscribers, nil
}
func (f *fakePollerStore) MarkDelivered(_ context.Context, eventKey, subscriberKey string) error {
if f.delivered[eventKey] == nil {
f.delivered[eventKey] = map[string]bool{}
}
f.delivered[eventKey][subscriberKey] = true
return nil
}
func (f *fakePollerStore) MarkIncidentUpdateVersion(_ context.Context, updateID, version string) error {
f.incidentVersions[updateID] = version
f.markedVersions[updateID] = version
return nil
}
func (f *fakePollerStore) PendingComponentEvents(context.Context) (map[string]redisstore.PendingComponentEvent, error) {
result := make(map[string]redisstore.PendingComponentEvent, len(f.pendingComponents))
for key, value := range f.pendingComponents {
result[key] = value
}
return result, nil
}
func (f *fakePollerStore) SavePendingComponentEvent(_ context.Context, event redisstore.PendingComponentEvent) error {
f.pendingComponents[event.ComponentID] = event
return nil
}
func (f *fakePollerStore) RemovePendingComponentEvent(_ context.Context, componentID string) error {
delete(f.pendingComponents, componentID)
return nil
}
func (f *fakePollerStore) RemoveSubscriber(_ context.Context, sub redisstore.Subscriber) error {
f.removed = append(f.removed, sub.Key())
return nil
}
func (f *fakePollerStore) SaveComponentStatus(_ context.Context, componentID, status string) error {
f.componentStatuses[componentID] = status
f.savedComponents[componentID] = status
return nil
}
func (f *fakePollerStore) SetInitialized(context.Context) error {
f.initialized = true
return nil
}
type fakeNotifier struct {
errors map[string]error
sends []string
}
func (f *fakeNotifier) SendMessage(_ context.Context, sub redisstore.Subscriber, _ string) error {
f.sends = append(f.sends, sub.Key())
if f.errors != nil {
return f.errors[sub.Key()]
}
return nil
}
func TestCheckOnceDoesNotCheckpointWhenSendFails(t *testing.T) {
store := newFakePollerStore()
store.initialized = true
store.componentStatuses["c1"] = "operational"
store.subscribers = []redisstore.Subscriber{redisstore.NewSubscriber(1, nil)}
notifier := &fakeNotifier{errors: map[string]error{"1": errors.New("network")}}
runner := NewRunner(fakeStatusClient{summary: summaryWithComponent("c1", "API", "degraded_performance")}, store, notifier, time.Minute, slog.Default())
if err := runner.CheckOnce(context.Background()); err == nil {
t.Fatal("expected send error")
}
if got := store.savedComponents["c1"]; got != "" {
t.Fatalf("component checkpoint = %q, want empty", got)
}
}
func TestCheckOnceSkipsAlreadyDeliveredSubscriberOnRetry(t *testing.T) {
store := newFakePollerStore()
store.initialized = true
store.componentStatuses["c1"] = "operational"
store.subscribers = []redisstore.Subscriber{redisstore.NewSubscriber(1, nil), redisstore.NewSubscriber(2, nil)}
notifier := &fakeNotifier{errors: map[string]error{"2": errors.New("rate limit")}}
runner := NewRunner(fakeStatusClient{summary: summaryWithComponent("c1", "API", "degraded_performance")}, store, notifier, time.Minute, slog.Default())
if err := runner.CheckOnce(context.Background()); err == nil {
t.Fatal("expected first send error")
}
if len(notifier.sends) != 2 || notifier.sends[0] != "1" || notifier.sends[1] != "2" {
t.Fatalf("first sends = %v, want [1 2]", notifier.sends)
}
notifier.errors = nil
notifier.sends = nil
if err := runner.CheckOnce(context.Background()); err != nil {
t.Fatalf("second CheckOnce returned error: %v", err)
}
if len(notifier.sends) != 1 || notifier.sends[0] != "2" {
t.Fatalf("second sends = %v, want [2]", notifier.sends)
}
if got := store.savedComponents["c1"]; got != "degraded_performance" {
t.Fatalf("component checkpoint = %q", got)
}
}
func TestCheckOnceRemovesTerminalSubscriberAndCheckpoints(t *testing.T) {
store := newFakePollerStore()
store.initialized = true
store.componentStatuses["c1"] = "operational"
store.subscribers = []redisstore.Subscriber{redisstore.NewSubscriber(1, nil), redisstore.NewSubscriber(2, nil)}
notifier := &fakeNotifier{errors: map[string]error{"2": &telegram.APIError{StatusCode: 403, Description: "Forbidden: bot was blocked by the user"}}}
runner := NewRunner(fakeStatusClient{summary: summaryWithComponent("c1", "API", "degraded_performance")}, store, notifier, time.Minute, slog.Default())
if err := runner.CheckOnce(context.Background()); err != nil {
t.Fatalf("CheckOnce returned error: %v", err)
}
if len(store.removed) != 1 || store.removed[0] != "2" {
t.Fatalf("removed = %v, want [2]", store.removed)
}
if got := store.savedComponents["c1"]; got != "degraded_performance" {
t.Fatalf("component checkpoint = %q", got)
}
}
func TestIncidentUpdateVersionEditSendsAgain(t *testing.T) {
update := openai.IncidentUpdate{ID: "u1", Body: "old", UpdatedAt: "2026-01-01T00:00:00Z"}
store := newFakePollerStore()
store.initialized = true
store.incidentVersions["u1"] = IncidentUpdateVersion(update)
store.subscribers = []redisstore.Subscriber{redisstore.NewSubscriber(1, nil)}
updated := openai.IncidentUpdate{ID: "u1", Body: "new", UpdatedAt: "2026-01-01T00:01:00Z"}
notifier := &fakeNotifier{}
runner := NewRunner(fakeStatusClient{incidents: openai.IncidentsResponse{Incidents: []openai.Incident{{ID: "i1", Name: "incident", Impact: "minor", IncidentUpdates: []openai.IncidentUpdate{updated}}}}}, store, notifier, time.Minute, slog.Default())
if err := runner.CheckOnce(context.Background()); err != nil {
t.Fatalf("CheckOnce returned error: %v", err)
}
if len(notifier.sends) != 1 {
t.Fatalf("sends = %v, want one send", notifier.sends)
}
if got := store.markedVersions["u1"]; got != IncidentUpdateVersion(updated) {
t.Fatalf("marked version = %q", got)
}
}
func TestPendingComponentEventSurvivesReturnToPreviousStatus(t *testing.T) {
store := newFakePollerStore()
store.initialized = true
store.componentStatuses["c1"] = "operational"
store.subscribers = []redisstore.Subscriber{redisstore.NewSubscriber(1, nil), redisstore.NewSubscriber(2, nil)}
notifier := &fakeNotifier{errors: map[string]error{"2": errors.New("rate limit")}}
runner := NewRunner(fakeStatusClient{summary: summaryWithComponent("c1", "API", "degraded_performance")}, store, notifier, time.Minute, slog.Default())
if err := runner.CheckOnce(context.Background()); err == nil {
t.Fatal("expected first send error")
}
if len(store.pendingComponents) != 1 {
t.Fatalf("pending components = %v, want one", store.pendingComponents)
}
notifier.errors = nil
notifier.sends = nil
runner.statusClient = fakeStatusClient{summary: summaryWithComponent("c1", "API", "operational")}
if err := runner.CheckOnce(context.Background()); err != nil {
t.Fatalf("second CheckOnce returned error: %v", err)
}
if len(notifier.sends) != 1 || notifier.sends[0] != "2" {
t.Fatalf("second sends = %v, want retry only for subscriber 2", notifier.sends)
}
if got := store.savedComponents["c1"]; got != "degraded_performance" {
t.Fatalf("component checkpoint = %q, want pending degraded status", got)
}
if len(store.pendingComponents) != 0 {
t.Fatalf("pending components = %v, want cleared", store.pendingComponents)
}
notifier.sends = nil
if err := runner.CheckOnce(context.Background()); err != nil {
t.Fatalf("third CheckOnce returned error: %v", err)
}
if len(notifier.sends) != 2 {
t.Fatalf("third sends = %v, want recovery sent to both subscribers", notifier.sends)
}
if got := store.savedComponents["c1"]; got != "operational" {
t.Fatalf("component checkpoint = %q, want recovery status", got)
}
}
func summaryWithComponent(id, name, status string) openai.Summary {
return openai.Summary{Components: []openai.Component{{ID: id, Name: name, Status: status, UpdatedAt: "2026-01-01T00:00:00Z"}}}
}
+139
View File
@@ -0,0 +1,139 @@
package redisstore
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
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()
}
type PendingComponentEvent struct {
ComponentID string `json:"component_id"`
ComponentName string `json:"component_name"`
Status string `json:"status"`
UpdatedAt string `json:"updated_at"`
Position int `json:"position"`
PreviousStatus string `json:"previous_status"`
DeliveryKey string `json:"delivery_key"`
}
func (s *Store) PendingComponentEvents(ctx context.Context) (map[string]PendingComponentEvent, error) {
values, err := s.client.HGetAll(ctx, pendingComponentsKey).Result()
if err != nil {
return nil, err
}
events := make(map[string]PendingComponentEvent, len(values))
for componentID, value := range values {
var event PendingComponentEvent
if err := json.Unmarshal([]byte(value), &event); err != nil {
continue
}
if event.ComponentID == "" {
event.ComponentID = componentID
}
events[componentID] = event
}
return events, nil
}
func (s *Store) SavePendingComponentEvent(ctx context.Context, event PendingComponentEvent) error {
payload, err := json.Marshal(event)
if err != nil {
return err
}
return s.client.HSet(ctx, pendingComponentsKey, event.ComponentID, payload).Err()
}
func (s *Store) RemovePendingComponentEvent(ctx context.Context, componentID string) error {
return s.client.HDel(ctx, pendingComponentsKey, componentID).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()
}
func (s *Store) HasIncidentUpdateVersion(ctx context.Context, updateID, version string) (bool, error) {
stored, err := s.client.HGet(ctx, incidentVersionsKey, updateID).Result()
if err == nil {
return stored == version, nil
}
if err != redis.Nil {
return false, err
}
return s.client.SIsMember(ctx, incidentUpdatesKey, updateID).Result()
}
func (s *Store) MarkIncidentUpdateVersion(ctx context.Context, updateID, version string) error {
pipe := s.client.TxPipeline()
pipe.HSet(ctx, incidentVersionsKey, updateID, version)
pipe.SAdd(ctx, incidentUpdatesKey, updateID)
_, err := pipe.Exec(ctx)
return err
}
func (s *Store) HasDelivered(ctx context.Context, eventKey, subscriberKey string) (bool, error) {
return s.client.SIsMember(ctx, deliveryStateKey(eventKey), subscriberKey).Result()
}
func (s *Store) MarkDelivered(ctx context.Context, eventKey, subscriberKey string) error {
key := deliveryStateKey(eventKey)
pipe := s.client.TxPipeline()
pipe.SAdd(ctx, key, subscriberKey)
pipe.Expire(ctx, key, 7*24*time.Hour)
_, err := pipe.Exec(ctx)
return err
}
func (s *Store) ClearDelivery(ctx context.Context, eventKey string) error {
return s.client.Del(ctx, deliveryStateKey(eventKey)).Err()
}
func (s *Store) TelegramOffset(ctx context.Context) (int64, error) {
value, err := s.client.Get(ctx, telegramOffsetKey).Result()
if err == redis.Nil {
return 0, nil
}
if err != nil {
return 0, err
}
offset, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid telegram offset: %w", err)
}
return offset, nil
}
func (s *Store) SaveTelegramOffset(ctx context.Context, offset int64) error {
return s.client.Set(ctx, telegramOffsetKey, strconv.FormatInt(offset, 10), 0).Err()
}
func deliveryStateKey(eventKey string) string {
sum := sha256.Sum256([]byte(eventKey))
return "openai-status:event-delivery:" + hex.EncodeToString(sum[:])
}
+12 -102
View File
@@ -1,63 +1,23 @@
package redisstore
import (
"context"
"fmt"
"strconv"
"strings"
import "github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9"
const (
subscribersKey = "openai-status:subscribers"
subscriberSettingsKey = "openai-status:subscriber-settings"
componentStatusesKey = "openai-status:component-statuses"
pendingComponentsKey = "openai-status:pending-component-events"
incidentUpdatesKey = "openai-status:incident-updates"
incidentVersionsKey = "openai-status:incident-update-versions"
initializedKey = "openai-status:initialized"
telegramOffsetKey = "openai-status:telegram-offset"
)
const (
subscribersKey = "openai-status:subscribers"
componentStatusesKey = "openai-status:component-statuses"
incidentUpdatesKey = "openai-status:incident-updates"
initializedKey = "openai-status:initialized"
SubscriptionTypeIncident = "incident"
SubscriptionTypeComponent = "component"
)
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
}
@@ -65,53 +25,3 @@ type Store struct {
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()
}
+14
View File
@@ -30,3 +30,17 @@ func TestParseSubscriberKeyRejectsInvalidValue(t *testing.T) {
t.Fatal("expected invalid key error")
}
}
func TestSubscriberAcceptsComponentIDAndLegacyNameFilters(t *testing.T) {
sub := NewSubscriber(12345, nil)
sub.Components = []string{"component-id", "Legacy API Name"}
if !sub.Accepts(SubscriptionTypeComponent, "component-id", "API") {
t.Fatal("expected component ID filter to match")
}
if !sub.Accepts(SubscriptionTypeComponent, "other-id", "legacy api name") {
t.Fatal("expected legacy component name filter to match")
}
if sub.Accepts(SubscriptionTypeComponent, "other-id", "Other") {
t.Fatal("unexpected component filter match")
}
}
+200
View File
@@ -0,0 +1,200 @@
package redisstore
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/redis/go-redis/v9"
)
type Subscriber struct {
ChatID int64
ThreadID *int
Types []string
Components []string
}
type subscriberSettings struct {
Types []string `json:"types"`
Components []string `json:"components"`
}
func NewSubscriber(chatID int64, threadID *int) Subscriber {
var copiedThreadID *int
if threadID != nil {
value := *threadID
copiedThreadID = &value
}
return Subscriber{
ChatID: chatID,
ThreadID: copiedThreadID,
Types: DefaultSubscriptionTypes(),
Components: []string{},
}
}
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 NewSubscriber(chatID, nil), nil
}
threadID, err := strconv.Atoi(parts[1])
if err != nil {
return Subscriber{}, fmt.Errorf("invalid thread ID: %w", err)
}
return NewSubscriber(chatID, &threadID), nil
}
func DefaultSubscriptionTypes() []string {
return []string{SubscriptionTypeIncident, SubscriptionTypeComponent}
}
func (s Subscriber) Accepts(eventType, componentID, componentName string) bool {
if !containsFold(s.Types, eventType) {
return false
}
if eventType != SubscriptionTypeComponent || len(s.Components) == 0 {
return true
}
return containsFold(s.Components, componentID) || containsFold(s.Components, componentName)
}
func (s *Store) AddSubscriber(ctx context.Context, sub Subscriber) error {
key := sub.Key()
settings, err := s.loadSubscriberSettings(ctx, key)
if err != nil {
return err
}
if err := s.client.SAdd(ctx, subscribersKey, key).Err(); err != nil {
return err
}
return s.saveSubscriberSettings(ctx, key, settings)
}
func (s *Store) RemoveSubscriber(ctx context.Context, sub Subscriber) error {
key := sub.Key()
pipe := s.client.TxPipeline()
pipe.SRem(ctx, subscribersKey, key)
pipe.HDel(ctx, subscriberSettingsKey, key)
_, err := pipe.Exec(ctx)
return 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, ok, err := s.loadSubscriber(ctx, key)
if err != nil {
return nil, err
}
if !ok {
continue
}
subscribers = append(subscribers, sub)
}
return subscribers, nil
}
func (s *Store) GetSubscriber(ctx context.Context, sub Subscriber) (Subscriber, bool, error) {
key := sub.Key()
exists, err := s.client.SIsMember(ctx, subscribersKey, key).Result()
if err != nil || !exists {
return Subscriber{}, false, err
}
return s.loadSubscriber(ctx, key)
}
func (s *Store) UpdateSubscriberTypes(ctx context.Context, sub Subscriber, types []string) (bool, error) {
key := sub.Key()
settings, exists, err := s.loadExistingSubscriberSettings(ctx, key)
if err != nil || !exists {
return false, err
}
settings.Types = normalizeTypes(types)
return true, s.saveSubscriberSettings(ctx, key, settings)
}
func (s *Store) UpdateSubscriberComponents(ctx context.Context, sub Subscriber, components []string) (bool, error) {
key := sub.Key()
settings, exists, err := s.loadExistingSubscriberSettings(ctx, key)
if err != nil || !exists {
return false, err
}
settings.Components = normalizeComponents(components)
return true, s.saveSubscriberSettings(ctx, key, settings)
}
func (s *Store) loadSubscriber(ctx context.Context, key string) (Subscriber, bool, error) {
sub, err := ParseSubscriberKey(key)
if err != nil {
return Subscriber{}, false, nil
}
settings, err := s.loadSubscriberSettings(ctx, key)
if err != nil {
return Subscriber{}, false, err
}
sub.Types = settings.Types
sub.Components = settings.Components
return sub, true, nil
}
func (s *Store) loadExistingSubscriberSettings(ctx context.Context, key string) (subscriberSettings, bool, error) {
exists, err := s.client.SIsMember(ctx, subscribersKey, key).Result()
if err != nil || !exists {
return subscriberSettings{}, false, err
}
settings, err := s.loadSubscriberSettings(ctx, key)
return settings, true, err
}
func (s *Store) loadSubscriberSettings(ctx context.Context, key string) (subscriberSettings, error) {
value, err := s.client.HGet(ctx, subscriberSettingsKey, key).Result()
if err == redis.Nil {
return subscriberSettings{Types: DefaultSubscriptionTypes(), Components: []string{}}, nil
}
if err != nil {
return subscriberSettings{}, err
}
var settings subscriberSettings
if err := json.Unmarshal([]byte(value), &settings); err != nil {
return subscriberSettings{Types: DefaultSubscriptionTypes(), Components: []string{}}, nil
}
settings.Types = normalizeTypes(settings.Types)
settings.Components = normalizeComponents(settings.Components)
return settings, nil
}
func (s *Store) saveSubscriberSettings(ctx context.Context, key string, settings subscriberSettings) error {
settings.Types = normalizeTypes(settings.Types)
settings.Components = normalizeComponents(settings.Components)
payload, err := json.Marshal(settings)
if err != nil {
return err
}
return s.client.HSet(ctx, subscriberSettingsKey, key, payload).Err()
}
@@ -0,0 +1,44 @@
package redisstore
import "strings"
func normalizeTypes(types []string) []string {
normalized := make([]string, 0, len(types))
for _, value := range types {
switch strings.ToLower(strings.TrimSpace(value)) {
case SubscriptionTypeIncident:
if !containsFold(normalized, SubscriptionTypeIncident) {
normalized = append(normalized, SubscriptionTypeIncident)
}
case SubscriptionTypeComponent:
if !containsFold(normalized, SubscriptionTypeComponent) {
normalized = append(normalized, SubscriptionTypeComponent)
}
}
}
if len(normalized) == 0 {
return DefaultSubscriptionTypes()
}
return normalized
}
func normalizeComponents(components []string) []string {
normalized := make([]string, 0, len(components))
for _, component := range components {
component = strings.TrimSpace(component)
if component == "" || containsFold(normalized, component) {
continue
}
normalized = append(normalized, component)
}
return normalized
}
func containsFold(values []string, target string) bool {
for _, value := range values {
if strings.EqualFold(value, target) {
return true
}
}
return false
}
+57 -3
View File
@@ -4,8 +4,10 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/tiennm99/openai-status-bot/internal/redisstore"
@@ -16,6 +18,42 @@ type Client struct {
httpClient *http.Client
}
type APIError struct {
StatusCode int
Description string
}
func (e *APIError) Error() string {
return fmt.Sprintf("telegram API %d: %s", e.StatusCode, e.Description)
}
func IsTerminalSendError(err error) bool {
var apiErr *APIError
if !errors.As(err, &apiErr) {
return false
}
if apiErr.StatusCode == http.StatusForbidden {
return true
}
if apiErr.StatusCode != http.StatusBadRequest {
return false
}
description := strings.ToLower(apiErr.Description)
terminalPhrases := []string{
"chat not found",
"message thread not found",
"message thread is closed",
"bot was kicked",
"bot is not a member",
}
for _, phrase := range terminalPhrases {
if strings.Contains(description, phrase) {
return true
}
}
return false
}
func NewClient(token string, timeout time.Duration) *Client {
return &Client{
baseURL: "https://api.telegram.org/bot" + token,
@@ -25,6 +63,20 @@ func NewClient(token string, timeout time.Duration) *Client {
}
}
func (c *Client) DeleteWebhook(ctx context.Context) error {
payload := map[string]any{"drop_pending_updates": false}
var result json.RawMessage
return c.postJSON(ctx, "/deleteWebhook", payload, &result)
}
func (c *Client) GetMe(ctx context.Context) (User, error) {
var user User
if err := c.postJSON(ctx, "/getMe", map[string]any{}, &user); err != nil {
return User{}, err
}
return user, nil
}
func (c *Client) GetUpdates(ctx context.Context, offset int64, timeoutSeconds int) ([]Update, error) {
payload := map[string]any{
"offset": offset,
@@ -45,8 +97,10 @@ func (c *Client) SendMessage(ctx context.Context, sub redisstore.Subscriber, tex
func (c *Client) SendText(ctx context.Context, chatID int64, threadID *int, text string) error {
payload := map[string]any{
"chat_id": chatID,
"text": text,
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": true,
}
if threadID != nil {
payload["message_thread_id"] = *threadID
@@ -83,7 +137,7 @@ func (c *Client) postJSON(ctx context.Context, path string, payload any, target
return fmt.Errorf("decode telegram response: %w", err)
}
if !envelope.OK {
return fmt.Errorf("telegram API %s: %s", res.Status, envelope.Description)
return &APIError{StatusCode: res.StatusCode, Description: envelope.Description}
}
if target != nil {
if err := json.Unmarshal(envelope.Result, target); err != nil {
+73
View File
@@ -0,0 +1,73 @@
package telegram
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestDeleteWebhookKeepsPendingUpdates(t *testing.T) {
var gotPath string
var payload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
_, _ = w.Write([]byte(`{"ok":true,"result":true}`))
}))
defer server.Close()
client := &Client{baseURL: server.URL, httpClient: server.Client()}
if err := client.DeleteWebhook(context.Background()); err != nil {
t.Fatalf("DeleteWebhook returned error: %v", err)
}
if gotPath != "/deleteWebhook" {
t.Fatalf("path = %s", gotPath)
}
if payload["drop_pending_updates"] != false {
t.Fatalf("drop_pending_updates = %v", payload["drop_pending_updates"])
}
}
func TestSendTextUsesHTMLAndDisablesPreview(t *testing.T) {
var payload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/sendMessage" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
_, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
}))
defer server.Close()
client := &Client{baseURL: server.URL, httpClient: server.Client()}
if err := client.SendText(context.Background(), 123, nil, "<b>hello</b>"); err != nil {
t.Fatalf("SendText returned error: %v", err)
}
if payload["parse_mode"] != "HTML" {
t.Fatalf("parse_mode = %v", payload["parse_mode"])
}
if payload["disable_web_page_preview"] != true {
t.Fatalf("disable_web_page_preview = %v", payload["disable_web_page_preview"])
}
}
func TestIsTerminalSendError(t *testing.T) {
terminal := &APIError{StatusCode: 403, Description: "Forbidden: bot was blocked by the user"}
if !IsTerminalSendError(terminal) {
t.Fatal("403 blocked should be terminal")
}
chatNotFound := &APIError{StatusCode: 400, Description: "Bad Request: chat not found"}
if !IsTerminalSendError(chatNotFound) {
t.Fatal("chat not found should be terminal")
}
parseError := &APIError{StatusCode: 400, Description: "Bad Request: can't parse entities"}
if IsTerminalSendError(parseError) {
t.Fatal("parse errors should not remove subscribers")
}
}
+7
View File
@@ -17,3 +17,10 @@ type Chat struct {
Type string `json:"type"`
Title string `json:"title,omitempty"`
}
type User struct {
ID int64 `json:"id"`
IsBot bool `json:"is_bot"`
FirstName string `json:"first_name"`
Username string `json:"username"`
}