Merge pull request #3 from tiennm99/develop

fix(mongo): restore Redis parity safeguards
This commit is contained in:
tiennm99 authored and GitHub committed 2026-06-27 16:08:08 +07:00
commit b13a976ab0
59 files changed
+3541 -1449

No files matched your search

+2 -1
View File
@@ -1,5 +1,6 @@
TELEGRAM_BOT_TOKEN=
REDIS_URL=redis://localhost:6379/0
MONGODB_URI=mongodb+srv://<user>:<pass>@<cluster>/
MONGODB_DATABASE=openai_status_bot
POLL_INTERVAL=1m
HTTP_TIMEOUT=10s
LOG_LEVEL=info
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.24-alpine AS build
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum* ./
+26 -12
View File
@@ -1,6 +1,6 @@
# 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.
Telegram bot that watches [OpenAI Status](https://status.openai.com/) every minute and sends updates to subscribed chats. State and subscriptions are stored in MongoDB.
## Features
@@ -8,12 +8,12 @@ Telegram bot that watches [OpenAI Status](https://status.openai.com/) every minu
- Notifies subscribers about new incident updates
- Notifies subscribers when component status changes
- Supports incident-only, component-only, and component-filtered subscriptions
- Uses Redis for subscribers, delivery state, component checkpoints, and seen incident update versions
- Uses MongoDB 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
- Registers the Telegram command menu on startup
- Exposes a local health endpoint for container health checks
- Includes Docker Compose for local Redis + bot runtime
- Includes Docker Compose for the bot runtime (datastore is managed MongoDB Atlas)
## Bot Commands
@@ -32,18 +32,25 @@ Telegram bot that watches [OpenAI Status](https://status.openai.com/) every minu
## Quick Start
There is no local datastore. Both Compose files run only the bot and connect to
a managed MongoDB Atlas cluster, so a reachable `MONGODB_URI` is required to
start. The development and production setups share one cluster and differ only
by database name (`MONGODB_DATABASE`).
Production runtime (default Compose file; uses the `MONGODB_DATABASE` from `.env`, default `openai_status_bot`):
```bash
cp .env.example .env
# edit .env and set TELEGRAM_BOT_TOKEN
docker compose up --build
# edit .env and set TELEGRAM_BOT_TOKEN, MONGODB_URI, and MONGODB_DATABASE
docker compose up -d --build
```
To deploy only the bot when Redis is hosted elsewhere:
Development runtime (targets the `development` database):
```bash
cp .env.example .env
# edit .env and set TELEGRAM_BOT_TOKEN plus REDIS_URL for the external Redis host
docker compose -f docker-compose.bot.yml up -d --build
# edit .env and set TELEGRAM_BOT_TOKEN and MONGODB_URI (Atlas connection string)
docker compose -f compose.dev.yaml up --build
```
For local development without Docker:
@@ -58,17 +65,24 @@ go run ./cmd/openai-status-bot
| Variable | Default | Description |
|----------|---------|-------------|
| `TELEGRAM_BOT_TOKEN` | required | Telegram bot token from BotFather |
| `REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL, e.g. `redis://:password@localhost:6379/0` |
| `MONGODB_URI` | required | MongoDB connection string, e.g. an Atlas `mongodb+srv://user:pass@cluster/` URI |
| `MONGODB_DATABASE` | `openai_status_bot` | Database name; use a separate name (e.g. `development`) to split dev and prod on one cluster |
| `POLL_INTERVAL` | `1m` | Status check interval, valid `5s`-`1h` |
| `HTTP_TIMEOUT` | `10s` | HTTP request timeout, valid `1s`-`5m` |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`/`warning`, or `error` |
Percent-encode Redis usernames or passwords that contain URL-reserved characters such as `@`, `:`, `/`, `#`, or `%`.
Percent-encode MongoDB usernames or passwords that contain URL-reserved characters such as `@`, `:`, `/`, `#`, or `%`.
The bot always reads OpenAI status from `https://status.openai.com`.
## Notes
The first successful poll seeds Redis and does not send historical incidents. Notifications start from later changes.
The first successful poll seeds the database 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.
Switching from a prior Redis deployment starts from empty state: there is no data migration, so subscribers must re-issue `/start`, component checkpoints reseed on the first poll, and the stored Telegram update offset is not migrated, so retained Telegram updates can be reprocessed once after cutover.
Incident update dedupe tracks the update content/version, so edited Statuspage updates can notify again. Each event is checkpointed independently once it has fully fanned out, so a retryable Telegram failure on one event only defers that event for retry on a later poll and never blocks checkpoints for other events delivered in the same poll.
A 7-day TTL index on the `delivery` collection expires per-event delivery markers automatically; the bot creates required indexes on startup.
Integration tests for the MongoDB store run a real `mongod` via testcontainers and are excluded from the default `go test ./...`. Run them with Docker available: `go test -tags=integration ./internal/mongostore/...`.
+114 -21
View File
@@ -2,19 +2,26 @@ package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"time"
"github.com/redis/go-redis/v9"
tgbot "github.com/go-telegram/bot"
"github.com/tiennm99/openai-status-bot/internal/bot"
"github.com/tiennm99/openai-status-bot/internal/config"
"github.com/tiennm99/openai-status-bot/internal/health"
"github.com/tiennm99/openai-status-bot/internal/mongostore"
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"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func main() {
@@ -32,41 +39,127 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go health.Run(ctx, logger)
// The health endpoint comes up immediately but reports 503 until startup
// finishes, then pings MongoDB on each probe so a dependency outage is
// reflected instead of a static 200.
var (
ready atomic.Bool
mongoClient *mongo.Client
)
go health.Run(ctx, logger, func(ctx context.Context) error {
if !ready.Load() {
return errors.New("bot is still starting")
}
return mongoClient.Ping(ctx, nil)
})
redisClient := redis.NewClient(cfg.RedisOptions)
if err := redisClient.Ping(ctx).Err(); err != nil {
logger.Error("connect redis", "network", cfg.RedisOptions.Network, "addr", cfg.RedisOptions.Addr, "db", cfg.RedisOptions.DB, "tls", cfg.RedisOptions.TLSConfig != nil, "error", err)
mongoClient, err = mongo.Connect(options.Client().ApplyURI(cfg.MongoURI))
if err != nil {
logger.Error("connect mongodb", "database", cfg.MongoDatabase, "error", err)
os.Exit(1)
}
defer func() {
if err := mongoClient.Disconnect(context.Background()); err != nil {
logger.Warn("disconnect mongodb", "error", err)
}
}()
if err := mongoClient.Ping(ctx, nil); err != nil {
logger.Error("ping mongodb", "database", cfg.MongoDatabase, "error", err)
os.Exit(1)
}
defer redisClient.Close()
store := redisstore.New(redisClient)
store := mongostore.New(mongoClient, cfg.MongoDatabase)
if err := store.EnsureIndexes(ctx); err != nil {
logger.Error("ensure mongodb indexes", "database", cfg.MongoDatabase, "error", err)
os.Exit(1)
}
statusClient := openai.NewClient(cfg.HTTPTimeout)
telegramClient := telegram.NewClient(cfg.TelegramBotToken, cfg.HTTPTimeout)
if err := telegramClient.DeleteWebhook(ctx); err != nil {
logger.Error("delete telegram webhook", "error", err)
telegramOffset, err := store.TelegramOffset(ctx)
if err != nil {
logger.Error("load telegram offset", "error", err)
os.Exit(1)
}
if err := telegramClient.SetMyCommands(ctx, bot.MenuCommands()); err != nil {
logger.Warn("set telegram bot commands", "error", err)
telegramPollTimeout := 51 * time.Second
telegramOptions := []tgbot.Option{
tgbot.WithSkipGetMe(),
tgbot.WithAllowedUpdates(tgbot.AllowedUpdates{"message"}),
tgbot.WithWorkers(1),
tgbot.WithUpdatesChannelCap(1),
tgbot.WithNotAsyncHandlers(),
tgbot.WithHTTPClient(telegramPollTimeout, &http.Client{Timeout: telegramPollTimeout + cfg.HTTPTimeout}),
tgbot.WithErrorsHandler(func(err error) {
logger.Warn("telegram runtime", "error", redactTelegramRuntimeError(cfg.TelegramBotToken, err))
}),
}
if initialOffset, ok := frameworkInitialOffset(telegramOffset); ok {
telegramOptions = append(telegramOptions, tgbot.WithInitialOffset(initialOffset))
}
telegramBot, err := tgbot.New(cfg.TelegramBotToken, telegramOptions...)
if err != nil {
logger.Error("create telegram bot", "error", redactTelegramRuntimeError(cfg.TelegramBotToken, err))
os.Exit(1)
}
requestCtx, cancelRequest := contextWithOptionalTimeout(ctx, cfg.HTTPTimeout)
_, err = telegramBot.DeleteWebhook(requestCtx, &tgbot.DeleteWebhookParams{DropPendingUpdates: false})
cancelRequest()
if err != nil {
logger.Error("delete telegram webhook", "error", redactTelegramRuntimeError(cfg.TelegramBotToken, err))
os.Exit(1)
}
requestCtx, cancelRequest = contextWithOptionalTimeout(ctx, cfg.HTTPTimeout)
_, err = telegramBot.SetMyCommands(requestCtx, &tgbot.SetMyCommandsParams{Commands: bot.MenuCommands()})
cancelRequest()
if err != nil {
logger.Warn("set telegram bot commands", "error", redactTelegramRuntimeError(cfg.TelegramBotToken, err))
}
botUsername := ""
if me, err := telegramClient.GetMe(ctx); err != nil {
logger.Warn("get telegram bot profile", "error", err)
requestCtx, cancelRequest = contextWithOptionalTimeout(ctx, cfg.HTTPTimeout)
me, err := telegramBot.GetMe(requestCtx)
cancelRequest()
if err != nil {
logger.Warn("get telegram bot profile", "error", redactTelegramRuntimeError(cfg.TelegramBotToken, err))
} else {
botUsername = me.Username
}
statusPoller := poller.NewRunner(statusClient, store, telegramClient, cfg.PollInterval, logger)
commandBot := bot.New(telegramClient, statusClient, store, logger, botUsername)
telegramSender := telegram.NewSender(telegramBot, cfg.TelegramBotToken, cfg.HTTPTimeout)
commandApp := bot.New(telegramSender, statusClient, store, logger, botUsername)
commandApp.RegisterHandlers(telegramBot)
statusPoller := poller.NewRunner(statusClient, store, telegramSender, cfg.PollInterval, 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)
ready.Store(true)
logger.Info("openai status bot started", "poll_interval", cfg.PollInterval.String(), "telegram_offset", telegramOffset)
telegramBot.Start(ctx)
}
func redactTelegramRuntimeError(token string, err error) error {
if err == nil || token == "" {
return err
}
msg := err.Error()
if !strings.Contains(msg, token) {
return err
}
return errors.New(strings.ReplaceAll(msg, token, "<redacted>"))
}
func contextWithOptionalTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout <= 0 {
return ctx, func() {}
}
return context.WithTimeout(ctx, timeout)
}
func frameworkInitialOffset(nextOffset int64) (int64, bool) {
if nextOffset <= 0 {
return 0, false
}
return nextOffset - 1, true
}
+25
View File
@@ -0,0 +1,25 @@
package main
import "testing"
func TestFrameworkInitialOffsetSeedsLastSeenUpdate(t *testing.T) {
tests := []struct {
name string
stored int64
want int64
ok bool
}{
{name: "empty", stored: 0, ok: false},
{name: "invalid negative", stored: -1, ok: false},
{name: "next update", stored: 42, want: 41, ok: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := frameworkInitialOffset(tt.stored)
if ok != tt.ok || got != tt.want {
t.Fatalf("frameworkInitialOffset(%d) = (%d, %v), want (%d, %v)", tt.stored, got, ok, tt.want, tt.ok)
}
})
}
}
+11
View File
@@ -0,0 +1,11 @@
# Development runtime: bot only, connecting to the shared Atlas cluster.
# There is no local datastore; set MONGODB_URI in .env. This file targets the
# development database (the MONGODB_DATABASE below overrides any .env value).
services:
bot:
build: .
env_file:
- .env
environment:
MONGODB_DATABASE: development
restart: unless-stopped
+9
View File
@@ -0,0 +1,9 @@
# Default production runtime: bot only, connecting to the shared Atlas cluster via .env.
# Uses the production database (MONGODB_DATABASE in .env, default
# openai_status_bot). There is no local datastore.
services:
bot:
build: .
env_file:
- .env
restart: unless-stopped
-6
View File
@@ -1,6 +0,0 @@
services:
bot:
build: .
env_file:
- .env
restart: unless-stopped
-21
View File
@@ -1,21 +0,0 @@
services:
redis:
image: redis:7-alpine
ports:
- "127.0.0.1:6379:6379"
volumes:
- redis-data:/data
command: ["redis-server", "--appendonly", "yes"]
bot:
build: .
env_file:
- .env
environment:
REDIS_URL: redis://redis:6379/0
depends_on:
- redis
restart: unless-stopped
volumes:
redis-data:
+8 -8
View File
@@ -18,9 +18,9 @@ Current decision: monitor next incidents/component updates before changing stora
## Findings
- Component checkpoints are stored in Redis as `component_id -> last_status`.
- Redis key: `openai-status:component-statuses`.
- Component names are not stored in that checkpoint hash.
- Component checkpoints are stored in MongoDB as `component_id -> last_status`.
- MongoDB collection: `component_statuses` (document ID is component ID, `status` field holds the last seen status).
- Component names are not stored in that checkpoint document.
- Component names come from live OpenAI `summary.json` during each poll.
- The bot compares component status by `component.ID`, then formats the alert with `component.Name`.
- Incident `page_id` is not used to detect components.
@@ -35,21 +35,21 @@ If component ID changed:
- Bot treats the component as new.
- Previous status becomes `unknown`.
- Old Redis component ID remains unused.
- Old MongoDB component ID remains unused.
- Component-filtered subscriptions using old ID may stop matching.
- Unfiltered component subscriptions still receive updates.
Other possible causes:
- Redis checkpoint was cleared or partially lost.
- `openai-status:initialized` exists while `openai-status:component-statuses` is missing entries.
- MongoDB checkpoint was cleared or partially lost.
- `meta` collection has `initialized` document while `component_statuses` collection is missing entries.
- OpenAI added a new component.
## Current State
- No code change now.
- Monitor upcoming incidents and component updates.
- Compare current OpenAI component IDs with stored Redis component IDs.
- Compare current OpenAI component IDs with stored MongoDB component IDs (in `component_statuses` collection).
- Decide later whether ID-only storage is enough.
## Future Options
@@ -62,5 +62,5 @@ Other possible causes:
## Unresolved Questions
- Did OpenAI actually change the component ID?
- Are old component IDs present in Redis but absent from current `summary.json`?
- Are old component IDs present in MongoDB but absent from current `summary.json`?
- Should component subscriptions survive ID changes by matching component name?
+17 -19
View File
@@ -2,49 +2,47 @@
## Prerequisites
- Go 1.24+
- Redis 7+
- Go 1.25+
- MongoDB Atlas cluster (managed, no local MongoDB service required)
- Telegram bot token from BotFather
## Local Docker Run
## Development Docker Run
```bash
cp .env.example .env
# set TELEGRAM_BOT_TOKEN in .env
# REDIS_URL is overridden to redis://redis:6379/0 by docker compose
docker compose up --build
# set MONGODB_URI to your MongoDB Atlas connection string
# docker compose sets MONGODB_DATABASE=development for local development
docker compose -f compose.dev.yaml up --build
```
The bundled Redis service is for local development and binds to `127.0.0.1:6379` on the host.
The Docker Compose service connects to MongoDB Atlas; there is no bundled local MongoDB. Ensure `MONGODB_URI` is set in `.env`.
## Bot-Only Docker Run
## Production Docker Run
Use this when Redis is hosted outside Docker Compose:
Use this for production deployment:
```bash
cp .env.example .env
# set TELEGRAM_BOT_TOKEN in .env
# set REDIS_URL to the external Redis URL, not localhost
docker compose -f docker-compose.bot.yml up -d --build
# set MONGODB_URI to your MongoDB Atlas connection string
# MONGODB_DATABASE defaults to openai_status_bot (production)
docker compose up -d --build
```
## Local Go Run
Start Redis:
```bash
docker run --rm -p 127.0.0.1:6379:6379 redis:7-alpine
```
Run bot:
```bash
cp .env.example .env
# set TELEGRAM_BOT_TOKEN in .env
# set MONGODB_URI to your MongoDB Atlas connection string
# export TELEGRAM_BOT_TOKEN or source .env with your shell workflow
# REDIS_URL defaults to redis://localhost:6379/0
# MONGODB_DATABASE defaults to openai_status_bot
go run ./cmd/openai-status-bot
```
MongoDB Atlas connection is required; there is no local MongoDB fallback.
## Verification
```bash
+30 -27
View File
@@ -2,52 +2,55 @@
## Overview
`openai-status-bot` is one Go process with two loops:
`openai-status-bot` is one Go process with two runtimes:
- Telegram long polling loop for user commands.
- `go-telegram/bot` long-poll runtime for Telegram user commands.
- OpenAI status polling loop, default every minute.
Redis stores subscribers, subscription settings, polling checkpoints, delivery retry state, and the Telegram update offset.
MongoDB stores subscribers, subscription settings, polling checkpoints, delivery retry state, and the Telegram restart offset.
## Data Flow
1. User sends `/start` in Telegram.
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:
2. The Telegram framework receives a message update and dispatches it to the command handler.
3. Bot stores chat ID, optional topic thread ID, and default subscription settings in MongoDB.
4. Users can adjust settings with `/subscribe` and inspect them with `/info`.
5. Poller fetches OpenAI status JSON:
- `GET https://status.openai.com/api/v2/summary.json`
- `GET https://status.openai.com/api/v2/incidents.json`
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.
6. Poller compares fetched state with MongoDB checkpoints and builds notification events without mutating checkpoints.
7. Events are sent to eligible subscribers through the Telegram sender adapter, respecting incident/component preferences and component ID filters.
8. Component and incident checkpoints are written only after delivery succeeds or terminal subscriber failures are removed.
## Redis Keys
## MongoDB Collections
| 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:pending-component-events` | hash | Component changes saved before fan-out so retryable delivery failures can be resumed |
| `openai-status:incident-updates` | set | Legacy seen incident update IDs, retained as write-through metadata; version hash is authoritative |
| `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 |
| Collection | Document ID | Fields | Purpose |
|-----------|------------|--------|---------|
| `subscribers` | `chatID` or `chatID:threadID` | chatID, threadID, types, components | Telegram chat or topic subscribers with subscription settings |
| `component_statuses` | component ID | status | Last seen component status by component ID |
| `pending_component_events` | component ID | — | Component changes saved before fan-out so retryable delivery failures can be resumed |
| `incident_update_versions` | update ID | version | Seen incident update version by update ID |
| `delivery` | `eventKey\|subscriber` | eventKey, subscriber, expiresAt (TTL 7 days) | Temporary per-event subscriber delivery state for retry isolation |
| `meta` | `initialized` or `telegramOffset` | value | Baseline seed marker and next Telegram update offset to use on restart |
Subscriber set members are `chatID` or `chatID:threadID`. Missing settings default to incident and component notifications for backward compatibility with older Redis state.
Subscriber document IDs are `chatID` or `chatID:threadID`. Each subscriber document includes subscription types and component ID filters as fields within the document.
## Runtime
The service uses Telegram `getUpdates`, so it does not need a public webhook URL. On startup it starts a local health endpoint at `127.0.0.1:8080/healthz`, calls `deleteWebhook` before long polling, registers the Telegram command menu, and then starts polling. Redis is configured with `REDIS_URL`. The OpenAI status source is fixed to `https://status.openai.com`. Docker Compose starts Redis and the bot for local development; the bundled Redis port is bound to `127.0.0.1` on the host.
The service uses Telegram `getUpdates` through `github.com/go-telegram/bot`, so it does not need a public webhook URL. On startup it starts a local health endpoint at `127.0.0.1:8080/healthz`, loads the stored `telegramOffset`, calls `deleteWebhook` before long polling, registers the Telegram command menu, and then starts the framework polling runtime. The framework is configured for message updates only, one worker, a single-update channel, and synchronous handlers so command side effects remain ordered.
`telegramOffset` stores the next update ID to resume from. Because `go-telegram/bot` stores the last seen update internally and sends `lastUpdateID + 1` to Telegram, startup passes `telegramOffset - 1` as the initial framework offset when a saved offset exists. The command handler saves `update.ID + 1` after every message update, including non-command messages.
MongoDB is configured with `MONGODB_URI` and `MONGODB_DATABASE` (default `openai_status_bot`). The OpenAI status source is fixed to `https://status.openai.com`. Docker Compose starts the bot connecting to a managed MongoDB Atlas cluster; there is no bundled local MongoDB service.
## Failure Behavior
- First successful poll seeds state without notification.
- OpenAI fetch failures are logged and retried on the next interval.
- Retryable Telegram failures do not advance component or incident checkpoints.
- Retryable Telegram send 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.
- Pending component events are stored before delivery and removed only after successful delivery or terminal subscriber cleanup.
- Telegram 403 and selected terminal 400 errors remove the unreachable subscriber, then delivery continues.
- Malformed subscriber keys are removed from Redis and surfaced as an error for the current poll instead of being skipped silently.
- Redis connection failure at startup exits the process.
- Telegram 403 and selected terminal 400 send errors remove the unreachable subscriber, then delivery continues.
- Telegram runtime errors are logged with the bot token redacted.
- Malformed subscriber document IDs are removed from MongoDB and surfaced as an error for the current poll instead of being skipped silently.
- MongoDB connection failure at startup exits the process.
+60 -5
View File
@@ -1,14 +1,69 @@
module github.com/tiennm99/openai-status-bot
go 1.24
go 1.25.0
require (
github.com/alicebob/miniredis/v2 v2.38.0
github.com/redis/go-redis/v9 v9.17.0
github.com/go-telegram/bot v1.21.0
github.com/testcontainers/testcontainers-go/modules/mongodb v0.43.0
go.mongodb.org/mongo-driver/v2 v2.7.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.2 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/shirou/gopsutil/v4 v4.26.5 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/testcontainers/testcontainers-go v0.43.0 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+175 -12
View File
@@ -1,14 +1,177 @@
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
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=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
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=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-telegram/bot v1.21.0 h1:Va/PbGc2vBDdv57GCUEEVV6ROlHWiC6SklJY9Hvhzps=
github.com/go-telegram/bot v1.21.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.43.0 h1:RJlAAz8dv61icyHvFmrr1xZHJEWZdvBfVBAU6wnmGPM=
github.com/testcontainers/testcontainers-go/modules/mongodb v0.43.0/go.mod h1:1zaDEZyV1SK+mpz83tmB2KthF6DHaS4Y+lRvy3d2XiE=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.7.0 h1:RO+zqavD2/GCL3cxOMyZhx6R9Irzr8/6gsoqx5tcY/c=
go.mongodb.org/mongo-driver/v2 v2.7.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
+67 -66
View File
@@ -2,18 +2,16 @@ package bot
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
tgbot "github.com/go-telegram/bot"
tgmodels "github.com/go-telegram/bot/models"
"github.com/tiennm99/openai-status-bot/internal/mongostore"
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)
type ReplySender interface {
SendText(ctx context.Context, chatID int64, threadID *int, text string) error
}
@@ -23,76 +21,79 @@ 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
AddSubscriber(ctx context.Context, sub mongostore.Subscriber) error
GetSubscriber(ctx context.Context, sub mongostore.Subscriber) (mongostore.Subscriber, bool, error)
RemoveSubscriber(ctx context.Context, sub mongostore.Subscriber) error
SaveTelegramOffset(ctx context.Context, offset int64) error
TelegramOffset(ctx context.Context) (int64, error)
UpdateSubscriberSettings(ctx context.Context, sub redisstore.Subscriber, types, components []string) (bool, error)
UpdateSubscriberTypes(ctx context.Context, sub redisstore.Subscriber, types []string) (bool, error)
UpdateSubscriberSettings(ctx context.Context, sub mongostore.Subscriber, types, components []string) (bool, error)
UpdateSubscriberTypes(ctx context.Context, sub mongostore.Subscriber, types []string) (bool, error)
}
type Bot struct {
telegramClient TelegramClient
statusClient StatusClient
store Store
logger *slog.Logger
username string
type App struct {
sender ReplySender
statusClient StatusClient
store Store
logger *slog.Logger
username string
}
func New(telegramClient TelegramClient, statusClient StatusClient, store Store, logger *slog.Logger, username string) *Bot {
return &Bot{
telegramClient: telegramClient,
statusClient: statusClient,
store: store,
logger: logger,
username: username,
type MessageContext struct {
ChatID int64
ThreadID *int
Text string
}
func New(sender ReplySender, statusClient StatusClient, store Store, logger *slog.Logger, username string) *App {
return &App{
sender: sender,
statusClient: statusClient,
store: store,
logger: logger,
username: username,
}
}
func (b *Bot) Run(ctx context.Context) error {
offset, err := b.store.TelegramOffset(ctx)
if err != nil {
return fmt.Errorf("load telegram offset: %w", err)
func (b *App) RegisterHandlers(telegramBot *tgbot.Bot) {
telegramBot.RegisterHandlerMatchFunc(hasMessage, b.HandleUpdate)
}
func hasMessage(update *tgmodels.Update) bool {
return update != nil && update.Message != nil
}
func (b *App) HandleUpdate(ctx context.Context, _ *tgbot.Bot, update *tgmodels.Update) {
message, ok := messageContextFromUpdate(update)
if !ok {
return
}
for {
select {
case <-ctx.Done():
return nil
default:
}
b.handleMessage(ctx, message)
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)
select {
case <-ctx.Done():
return nil
case <-time.After(3 * time.Second):
}
continue
}
for _, update := range updates {
if update.UpdateID < offset {
continue
}
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)
}
}
if err := b.store.SaveTelegramOffset(ctx, update.ID+1); err != nil {
b.logger.Warn("save telegram offset", "offset", update.ID+1, "error", err)
}
}
func (b *Bot) handleMessage(ctx context.Context, message telegram.Message) {
func messageContextFromUpdate(update *tgmodels.Update) (MessageContext, bool) {
if update == nil || update.Message == nil {
return MessageContext{}, false
}
message := update.Message
var threadID *int
if message.MessageThreadID != 0 {
id := message.MessageThreadID
threadID = &id
}
return MessageContext{
ChatID: message.Chat.ID,
ThreadID: threadID,
Text: message.Text,
}, true
}
func (b *App) handleMessage(ctx context.Context, message MessageContext) {
if !strings.HasPrefix(strings.TrimSpace(message.Text), "/") {
return
}
@@ -124,8 +125,8 @@ func (b *Bot) handleMessage(ctx context.Context, message telegram.Message) {
}
}
func (b *Bot) subscribe(ctx context.Context, message telegram.Message) {
sub := redisstore.NewSubscriber(message.Chat.ID, message.MessageThreadID)
func (b *App) subscribe(ctx context.Context, message MessageContext) {
sub := mongostore.NewSubscriber(message.ChatID, message.ThreadID)
if err := b.store.AddSubscriber(ctx, sub); err != nil {
b.logger.Error("subscribe", "error", err)
b.reply(ctx, message, "Could not subscribe right now.")
@@ -134,8 +135,8 @@ func (b *Bot) subscribe(ctx context.Context, message telegram.Message) {
b.reply(ctx, message, "Subscribed to OpenAI status updates. Use /subscribe to change preferences.")
}
func (b *Bot) unsubscribe(ctx context.Context, message telegram.Message) {
sub := redisstore.NewSubscriber(message.Chat.ID, message.MessageThreadID)
func (b *App) unsubscribe(ctx context.Context, message MessageContext) {
sub := mongostore.NewSubscriber(message.ChatID, message.ThreadID)
if err := b.store.RemoveSubscriber(ctx, sub); err != nil {
b.logger.Error("unsubscribe", "error", err)
b.reply(ctx, message, "Could not unsubscribe right now.")
+72 -57
View File
@@ -2,32 +2,27 @@ package bot
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
"time"
tgmodels "github.com/go-telegram/bot/models"
"github.com/tiennm99/openai-status-bot/internal/mongostore"
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
getUpdatesErr error
onGetUpdates func()
type fakeTelegramSender struct {
replies []sentReply
}
func (f *fakeTelegramClient) GetUpdates(context.Context, int64, int) ([]telegram.Update, error) {
if f.onGetUpdates != nil {
f.onGetUpdates()
}
return nil, f.getUpdatesErr
type sentReply struct {
chatID int64
threadID *int
text string
}
func (f *fakeTelegramClient) SendText(_ context.Context, _ int64, _ *int, text string) error {
f.replies = append(f.replies, text)
func (f *fakeTelegramSender) SendText(_ context.Context, chatID int64, threadID *int, text string) error {
f.replies = append(f.replies, sentReply{chatID: chatID, threadID: threadID, text: text})
return nil
}
@@ -44,23 +39,23 @@ func (f fakeBotStatusClient) FetchIncidents(context.Context) (openai.IncidentsRe
}
type fakeBotStore struct {
sub redisstore.Subscriber
subscribed bool
types []string
components []string
offsetErr error
sub mongostore.Subscriber
subscribed bool
types []string
components []string
savedOffsets []int64
}
func (f *fakeBotStore) AddSubscriber(_ context.Context, sub redisstore.Subscriber) error {
func (f *fakeBotStore) AddSubscriber(_ context.Context, sub mongostore.Subscriber) error {
f.sub = sub
f.subscribed = true
f.types = redisstore.DefaultSubscriptionTypes()
f.types = mongostore.DefaultSubscriptionTypes()
return nil
}
func (f *fakeBotStore) GetSubscriber(context.Context, redisstore.Subscriber) (redisstore.Subscriber, bool, error) {
func (f *fakeBotStore) GetSubscriber(context.Context, mongostore.Subscriber) (mongostore.Subscriber, bool, error) {
if !f.subscribed {
return redisstore.Subscriber{}, false, nil
return mongostore.Subscriber{}, false, nil
}
sub := f.sub
sub.Types = append([]string{}, f.types...)
@@ -68,15 +63,17 @@ func (f *fakeBotStore) GetSubscriber(context.Context, redisstore.Subscriber) (re
return sub, true, nil
}
func (f *fakeBotStore) RemoveSubscriber(context.Context, redisstore.Subscriber) error {
func (f *fakeBotStore) RemoveSubscriber(context.Context, mongostore.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, f.offsetErr }
func (f *fakeBotStore) SaveTelegramOffset(_ context.Context, offset int64) error {
f.savedOffsets = append(f.savedOffsets, offset)
return nil
}
func (f *fakeBotStore) UpdateSubscriberTypes(_ context.Context, _ redisstore.Subscriber, types []string) (bool, error) {
func (f *fakeBotStore) UpdateSubscriberTypes(_ context.Context, _ mongostore.Subscriber, types []string) (bool, error) {
if !f.subscribed {
return false, nil
}
@@ -84,7 +81,7 @@ func (f *fakeBotStore) UpdateSubscriberTypes(_ context.Context, _ redisstore.Sub
return true, nil
}
func (f *fakeBotStore) UpdateSubscriberSettings(_ context.Context, _ redisstore.Subscriber, types, components []string) (bool, error) {
func (f *fakeBotStore) UpdateSubscriberSettings(_ context.Context, _ mongostore.Subscriber, types, components []string) (bool, error) {
if !f.subscribed {
return false, nil
}
@@ -96,18 +93,18 @@ func (f *fakeBotStore) UpdateSubscriberSettings(_ context.Context, _ redisstore.
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}
store.sub = mongostore.NewSubscriber(123, nil)
store.types = []string{mongostore.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) {
if !containsComponent(store.types, mongostore.SubscriptionTypeComponent) {
t.Fatalf("types = %v, want component enabled", store.types)
}
if len(tg.replies) != 1 || !strings.Contains(tg.replies[0], "Subscribed to component") {
if len(tg.replies) != 1 || !strings.Contains(tg.replies[0].text, "Subscribed to component") {
t.Fatalf("reply = %v", tg.replies)
}
}
@@ -115,8 +112,8 @@ func TestSubscribeComponentStoresComponentID(t *testing.T) {
func TestSubscribeComponentAllEnablesComponentType(t *testing.T) {
bot, store, _ := newTestBot(nil)
store.subscribed = true
store.sub = redisstore.NewSubscriber(123, nil)
store.types = []string{redisstore.SubscriptionTypeIncident}
store.sub = mongostore.NewSubscriber(123, nil)
store.types = []string{mongostore.SubscriptionTypeIncident}
store.components = []string{"c-api"}
bot.handleMessage(context.Background(), messageText("/subscribe component all"))
@@ -124,7 +121,7 @@ func TestSubscribeComponentAllEnablesComponentType(t *testing.T) {
if len(store.components) != 0 {
t.Fatalf("components = %v, want cleared", store.components)
}
if !containsComponent(store.types, redisstore.SubscriptionTypeComponent) {
if !containsComponent(store.types, mongostore.SubscriptionTypeComponent) {
t.Fatalf("types = %v, want component enabled", store.types)
}
}
@@ -137,7 +134,7 @@ func TestStatusAmbiguousComponentNameRequiresID(t *testing.T) {
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") {
if len(tg.replies) != 1 || !strings.Contains(tg.replies[0].text, "ambiguous") || !strings.Contains(tg.replies[0].text, "login-a") {
t.Fatalf("reply = %v", tg.replies)
}
}
@@ -152,38 +149,56 @@ func TestCommandForOtherBotIsIgnored(t *testing.T) {
}
}
func TestRunReturnsPromptlyWhenContextCanceledAfterGetUpdatesError(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
tg := &fakeTelegramClient{getUpdatesErr: errors.New("network"), onGetUpdates: cancel}
store := &fakeBotStore{}
bot := New(tg, fakeBotStatusClient{}, store, slog.Default(), "OpenAIStatusBot")
func TestHandleUpdateRepliesToThreadAndSavesNextOffset(t *testing.T) {
bot, store, tg := newTestBot(nil)
threadID := 42
started := time.Now()
if err := bot.Run(ctx); err != nil {
t.Fatalf("Run returned error: %v", err)
bot.HandleUpdate(context.Background(), nil, &tgmodels.Update{
ID: 77,
Message: &tgmodels.Message{
Chat: tgmodels.Chat{ID: 123},
MessageThreadID: threadID,
Text: "/start",
},
})
if len(store.savedOffsets) != 1 || store.savedOffsets[0] != 78 {
t.Fatalf("savedOffsets = %v, want [78]", store.savedOffsets)
}
if elapsed := time.Since(started); elapsed > 500*time.Millisecond {
t.Fatalf("Run took %s after context cancellation", elapsed)
if len(tg.replies) != 1 {
t.Fatalf("replies = %v, want one reply", tg.replies)
}
if tg.replies[0].chatID != 123 {
t.Fatalf("chatID = %d, want 123", tg.replies[0].chatID)
}
if tg.replies[0].threadID == nil || *tg.replies[0].threadID != threadID {
t.Fatalf("threadID = %v, want %d", tg.replies[0].threadID, threadID)
}
}
func TestRunReturnsTelegramOffsetError(t *testing.T) {
store := &fakeBotStore{offsetErr: errors.New("invalid telegram offset")}
bot := New(&fakeTelegramClient{}, fakeBotStatusClient{}, store, slog.Default(), "OpenAIStatusBot")
func TestHandleUpdateSavesOffsetForNonCommandMessage(t *testing.T) {
bot, store, tg := newTestBot(nil)
err := bot.Run(context.Background())
if err == nil || !strings.Contains(err.Error(), "load telegram offset") {
t.Fatalf("Run error = %v, want telegram offset error", err)
bot.HandleUpdate(context.Background(), nil, &tgmodels.Update{
ID: 80,
Message: &tgmodels.Message{Chat: tgmodels.Chat{ID: 123}, Text: "hello"},
})
if len(tg.replies) != 0 {
t.Fatalf("replies = %v, want none", tg.replies)
}
if len(store.savedOffsets) != 1 || store.savedOffsets[0] != 81 {
t.Fatalf("savedOffsets = %v, want [81]", store.savedOffsets)
}
}
func newTestBot(components []openai.Component) (*Bot, *fakeBotStore, *fakeTelegramClient) {
tg := &fakeTelegramClient{}
func newTestBot(components []openai.Component) (*App, *fakeBotStore, *fakeTelegramSender) {
tg := &fakeTelegramSender{}
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}}
func messageText(text string) MessageContext {
return MessageContext{Text: text, ChatID: 123}
}
+16 -17
View File
@@ -5,13 +5,12 @@ import (
"fmt"
"strings"
"github.com/tiennm99/openai-status-bot/internal/mongostore"
"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 (b *Bot) replySubscribe(ctx context.Context, message telegram.Message, fields []string) {
sub := redisstore.NewSubscriber(message.Chat.ID, message.MessageThreadID)
func (b *App) replySubscribe(ctx context.Context, message MessageContext, fields []string) {
sub := mongostore.NewSubscriber(message.ChatID, message.ThreadID)
current, subscribed, err := b.store.GetSubscriber(ctx, sub)
if err != nil {
b.logger.Error("get subscription", "error", err)
@@ -25,19 +24,19 @@ func (b *Bot) replySubscribe(ctx context.Context, message telegram.Message, fiel
args := fields[1:]
arg := strings.ToLower(args[0])
if arg == redisstore.SubscriptionTypeComponent && len(args) > 1 {
if arg == mongostore.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 mongostore.SubscriptionTypeIncident:
types = []string{mongostore.SubscriptionTypeIncident}
case mongostore.SubscriptionTypeComponent:
types = []string{mongostore.SubscriptionTypeComponent}
case "all":
types = redisstore.DefaultSubscriptionTypes()
types = mongostore.DefaultSubscriptionTypes()
default:
b.reply(ctx, message, formatSubscribeUsage(currentOrTarget(current, sub, subscribed), subscribed))
return
@@ -56,7 +55,7 @@ func (b *Bot) replySubscribe(ctx context.Context, message telegram.Message, fiel
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) {
func (b *App) updateComponentFilter(ctx context.Context, message MessageContext, sub mongostore.Subscriber, componentArg string, subscribed bool) {
if !subscribed {
b.reply(ctx, message, "Not subscribed yet. Use /start first.")
return
@@ -73,7 +72,7 @@ func (b *Bot) updateComponentFilter(ctx context.Context, message telegram.Messag
b.reply(ctx, message, "Not subscribed yet. Use /start first.")
return
}
updated, err := b.store.UpdateSubscriberSettings(ctx, sub, withSubscriptionType(current.Types, redisstore.SubscriptionTypeComponent), nil)
updated, err := b.store.UpdateSubscriberSettings(ctx, sub, withSubscriptionType(current.Types, mongostore.SubscriptionTypeComponent), nil)
if err != nil {
b.logger.Error("clear component filter", "error", err)
b.reply(ctx, message, "Could not update component filter right now.")
@@ -118,7 +117,7 @@ func (b *Bot) updateComponentFilter(ctx context.Context, message telegram.Messag
if !containsComponent(components, component.ID) {
components = append(components, component.ID)
}
types := withSubscriptionType(current.Types, redisstore.SubscriptionTypeComponent)
types := withSubscriptionType(current.Types, mongostore.SubscriptionTypeComponent)
updated, err := b.store.UpdateSubscriberSettings(ctx, sub, types, components)
if err != nil {
b.logger.Error("update component filter", "error", err)
@@ -140,7 +139,7 @@ func withSubscriptionType(types []string, subscriptionType string) []string {
return updated
}
func (b *Bot) replyHistory(ctx context.Context, message telegram.Message, count int) {
func (b *App) replyHistory(ctx context.Context, message MessageContext, count int) {
incidents, err := b.statusClient.FetchIncidents(ctx)
if err != nil {
b.logger.Error("fetch incidents", "error", err)
@@ -150,7 +149,7 @@ func (b *Bot) replyHistory(ctx context.Context, message telegram.Message, count
b.reply(ctx, message, formatHistory(incidents.Incidents, count))
}
func (b *Bot) replyUptime(ctx context.Context, message telegram.Message) {
func (b *App) replyUptime(ctx context.Context, message MessageContext) {
summary, err := b.statusClient.FetchSummary(ctx)
if err != nil {
b.logger.Error("fetch uptime", "error", err)
@@ -160,8 +159,8 @@ func (b *Bot) replyUptime(ctx context.Context, message telegram.Message) {
b.reply(ctx, message, formatUptime(summary))
}
func (b *Bot) replyInfo(ctx context.Context, message telegram.Message) {
sub := redisstore.NewSubscriber(message.Chat.ID, message.MessageThreadID)
func (b *App) replyInfo(ctx context.Context, message MessageContext) {
sub := mongostore.NewSubscriber(message.ChatID, message.ThreadID)
current, subscribed, err := b.store.GetSubscriber(ctx, sub)
if err != nil {
b.logger.Error("get subscription info", "error", err)
+34 -3
View File
@@ -144,13 +144,44 @@ func parseHistoryCount(fields []string) int {
return count
}
const (
telegramMessageLimit = 3900
truncationNotice = "\n\n<i>… truncated</i>"
)
// truncateMessage trims an HTML-formatted message to Telegram's length limit.
// Every formatter assembles messages from newline-delimited fragments whose
// HTML tags are individually balanced, so cutting on a newline boundary keeps
// the markup valid. A naive mid-string cut could split a tag (<co|de>) or an
// entity (&am|p;), or drop a closing tag, making Telegram reject the entire
// message with "can't parse entities".
func truncateMessage(value string) string {
const telegramLimit = 3900
runes := []rune(value)
if len(runes) <= telegramLimit {
if len(runes) <= telegramMessageLimit {
return value
}
return strings.TrimSpace(string(runes[:telegramLimit-3])) + "..."
budget := telegramMessageLimit - len([]rune(truncationNotice))
truncated := string(runes[:budget])
if idx := strings.LastIndexByte(truncated, '\n'); idx >= 0 {
truncated = truncated[:idx]
} else {
truncated = dropPartialMarkup(truncated)
}
return strings.TrimRight(truncated, "\n ") + truncationNotice
}
// dropPartialMarkup removes a trailing incomplete HTML tag or entity left by a
// hard cut, so the result never ends inside "<...>" or "&...;". Only reached
// for a single fragment longer than the whole limit, which the formatters do
// not produce in practice.
func dropPartialMarkup(s string) string {
if lt := strings.LastIndexByte(s, '<'); lt > strings.LastIndexByte(s, '>') {
s = s[:lt]
}
if amp := strings.LastIndexByte(s, '&'); amp > strings.LastIndexByte(s, ';') {
s = s[:amp]
}
return s
}
func formatDate(value string) string {
+29 -1
View File
@@ -1,6 +1,34 @@
package bot
import "testing"
import (
"strings"
"testing"
)
func TestTruncateMessageKeepsHTMLValidOnNewlineBoundary(t *testing.T) {
var b strings.Builder
for b.Len() <= telegramMessageLimit {
b.WriteString("- <b>Component</b>: <code>operational</code>\n")
}
out := truncateMessage(b.String())
if len([]rune(out)) > telegramMessageLimit {
t.Fatalf("len = %d, want <= %d", len([]rune(out)), telegramMessageLimit)
}
if !strings.HasSuffix(out, truncationNotice) {
t.Fatalf("missing truncation notice: %q", out[len(out)-40:])
}
body := strings.TrimSuffix(out, truncationNotice)
// Newline-boundary cut must leave equal numbers of opening/closing tags.
for _, tag := range []string{"b", "code"} {
if open, close := strings.Count(body, "<"+tag+">"), strings.Count(body, "</"+tag+">"); open != close {
t.Fatalf("unbalanced <%s>: %d open, %d close", tag, open, close)
}
}
if strings.Contains(body, "<co") && !strings.Contains(body, "<code>") {
t.Fatal("body ends inside a tag")
}
}
func TestNormalizeCommandStripsOwnBotUsername(t *testing.T) {
command, fields := normalizeCommand("/history@OpenAIStatusBot 10", "OpenAIStatusBot")
+7 -8
View File
@@ -5,12 +5,11 @@ import (
"fmt"
"strings"
"github.com/tiennm99/openai-status-bot/internal/mongostore"
"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 (b *Bot) replyStatus(ctx context.Context, message telegram.Message, fields []string) {
func (b *App) replyStatus(ctx context.Context, message MessageContext, fields []string) {
summary, err := b.statusClient.FetchSummary(ctx)
if err != nil {
b.logger.Error("fetch status", "error", err)
@@ -35,7 +34,7 @@ func (b *Bot) replyStatus(ctx context.Context, message telegram.Message, fields
b.reply(ctx, message, formatStatus(summary))
}
func (b *Bot) replyComponents(ctx context.Context, message telegram.Message) {
func (b *App) replyComponents(ctx context.Context, message MessageContext) {
summary, err := b.statusClient.FetchSummary(ctx)
if err != nil {
b.logger.Error("fetch components", "error", err)
@@ -45,13 +44,13 @@ func (b *Bot) replyComponents(ctx context.Context, message telegram.Message) {
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 (b *App) reply(ctx context.Context, message MessageContext, text string) {
if err := b.sender.SendText(ctx, message.ChatID, message.ThreadID, text); err != nil {
b.logger.Warn("send telegram reply", "chat_id", message.ChatID, "error", err)
}
}
func currentOrTarget(current, target redisstore.Subscriber, subscribed bool) redisstore.Subscriber {
func currentOrTarget(current, target mongostore.Subscriber, subscribed bool) mongostore.Subscriber {
if subscribed {
return current
}
+3 -3
View File
@@ -1,9 +1,9 @@
package bot
import "github.com/tiennm99/openai-status-bot/internal/telegram"
import "github.com/go-telegram/bot/models"
func MenuCommands() []telegram.BotCommand {
return []telegram.BotCommand{
func MenuCommands() []models.BotCommand {
return []models.BotCommand{
{Command: "start", Description: "Subscribe this chat or topic"},
{Command: "stop", Description: "Unsubscribe this chat or topic"},
{Command: "status", Description: "Show current OpenAI status"},
+3 -3
View File
@@ -5,10 +5,10 @@ import (
"strconv"
"strings"
"github.com/tiennm99/openai-status-bot/internal/redisstore"
"github.com/tiennm99/openai-status-bot/internal/mongostore"
)
func formatInfo(sub redisstore.Subscriber, subscribed bool) string {
func formatInfo(sub mongostore.Subscriber, subscribed bool) string {
chatID := strconv.FormatInt(sub.ChatID, 10)
if sub.ThreadID != nil {
chatID = fmt.Sprintf("%s:%d", chatID, *sub.ThreadID)
@@ -28,7 +28,7 @@ func formatInfo(sub redisstore.Subscriber, subscribed bool) string {
)
}
func formatSubscribeUsage(sub redisstore.Subscriber, subscribed bool) string {
func formatSubscribeUsage(sub mongostore.Subscriber, subscribed bool) string {
current := "none (use /start first)"
components := "all"
if subscribed {
+12 -24
View File
@@ -6,25 +6,23 @@ import (
"os"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
type Config struct {
TelegramBotToken string
RedisOptions *redis.Options
MongoURI string
MongoDatabase string
PollInterval time.Duration
HTTPTimeout time.Duration
LogLevel slog.Level
}
const (
minPollInterval = 5 * time.Second
maxPollInterval = time.Hour
minHTTPTimeout = time.Second
maxHTTPTimeout = 5 * time.Minute
minRedisDB = 0
maxRedisDB = 15
minPollInterval = 5 * time.Second
maxPollInterval = time.Hour
minHTTPTimeout = time.Second
maxHTTPTimeout = 5 * time.Minute
defaultMongoDatabase = "openai_status_bot"
)
func LoadFromEnv() (Config, error) {
@@ -34,12 +32,13 @@ func LoadFromEnv() (Config, error) {
if cfg.TelegramBotToken == "" {
return Config{}, fmt.Errorf("TELEGRAM_BOT_TOKEN is required")
}
var err error
cfg.RedisOptions, err = parseRedisURL("REDIS_URL", getEnv("REDIS_URL", "redis://localhost:6379/0"))
if err != nil {
return Config{}, err
cfg.MongoURI = strings.TrimSpace(os.Getenv("MONGODB_URI"))
if cfg.MongoURI == "" {
return Config{}, fmt.Errorf("MONGODB_URI is required")
}
cfg.MongoDatabase = getEnv("MONGODB_DATABASE", defaultMongoDatabase)
var err error
cfg.PollInterval, err = parseDurationEnv("POLL_INTERVAL", time.Minute, minPollInterval, maxPollInterval)
if err != nil {
return Config{}, err
@@ -78,17 +77,6 @@ func parseDurationEnv(key string, fallback, minValue, maxValue time.Duration) (t
return parsed, nil
}
func parseRedisURL(key, value string) (*redis.Options, error) {
options, err := redis.ParseURL(value)
if err != nil {
return nil, fmt.Errorf("%s must be a valid Redis URL", key)
}
if options.DB < minRedisDB || options.DB > maxRedisDB {
return nil, fmt.Errorf("%s database must be between %d and %d", key, minRedisDB, maxRedisDB)
}
return options, nil
}
func parseLogLevel(value string) (slog.Level, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "debug":
+17 -37
View File
@@ -10,6 +10,7 @@ import (
func TestLoadFromEnvDefaultsAndTrimming(t *testing.T) {
setMinimalEnv(t)
t.Setenv("TELEGRAM_BOT_TOKEN", " token ")
t.Setenv("MONGODB_URI", " mongodb+srv://user:pass@cluster.example.net/ ")
cfg, err := LoadFromEnv()
if err != nil {
@@ -18,39 +19,37 @@ func TestLoadFromEnvDefaultsAndTrimming(t *testing.T) {
if cfg.TelegramBotToken != "token" {
t.Fatalf("TelegramBotToken = %q", cfg.TelegramBotToken)
}
if cfg.RedisOptions == nil || cfg.RedisOptions.Addr != "localhost:6379" || cfg.RedisOptions.DB != 0 {
t.Fatalf("unexpected Redis options: %+v", cfg.RedisOptions)
if cfg.MongoURI != "mongodb+srv://user:pass@cluster.example.net/" {
t.Fatalf("MongoURI = %q", cfg.MongoURI)
}
if cfg.MongoDatabase != defaultMongoDatabase {
t.Fatalf("MongoDatabase = %q, want %q", cfg.MongoDatabase, defaultMongoDatabase)
}
if cfg.PollInterval != time.Minute || cfg.HTTPTimeout != 10*time.Second {
t.Fatalf("unexpected defaults: %+v", cfg)
}
}
func TestLoadFromEnvIgnoresLegacyRedisVariables(t *testing.T) {
func TestLoadFromEnvAcceptsCustomMongoDatabase(t *testing.T) {
setMinimalEnv(t)
t.Setenv("REDIS_ADDR", "legacy-host:6380")
t.Setenv("REDIS_PASSWORD", "legacy-password")
t.Setenv("REDIS_DB", "5")
t.Setenv("MONGODB_DATABASE", " openai_status_bot_dev ")
cfg, err := LoadFromEnv()
if err != nil {
t.Fatalf("LoadFromEnv returned error: %v", err)
}
if cfg.RedisOptions == nil || cfg.RedisOptions.Addr != "localhost:6379" || cfg.RedisOptions.Password != "" || cfg.RedisOptions.DB != 0 {
t.Fatalf("legacy Redis variables affected options: %+v", cfg.RedisOptions)
if cfg.MongoDatabase != "openai_status_bot_dev" {
t.Fatalf("MongoDatabase = %q, want openai_status_bot_dev", cfg.MongoDatabase)
}
}
func TestLoadFromEnvAcceptsRedisURLCredentials(t *testing.T) {
func TestLoadFromEnvRequiresMongoURI(t *testing.T) {
setMinimalEnv(t)
t.Setenv("REDIS_URL", " redis://user:redis%40pass%3Awith%2Freserved@localhost:6380/2 ")
t.Setenv("MONGODB_URI", "")
cfg, err := LoadFromEnv()
if err != nil {
t.Fatalf("LoadFromEnv returned error: %v", err)
}
if cfg.RedisOptions == nil || cfg.RedisOptions.Addr != "localhost:6380" || cfg.RedisOptions.Username != "user" || cfg.RedisOptions.Password != "redis@pass:with/reserved" || cfg.RedisOptions.DB != 2 {
t.Fatalf("unexpected Redis options: %+v", cfg.RedisOptions)
_, err := LoadFromEnv()
if err == nil || !strings.Contains(err.Error(), "MONGODB_URI is required") {
t.Fatalf("error = %v, want MONGODB_URI required", err)
}
}
@@ -63,22 +62,6 @@ func TestLoadFromEnvIgnoresOpenAIStatusBaseURL(t *testing.T) {
}
}
func TestLoadFromEnvDoesNotLeakRedisURLCredentialsInParseErrors(t *testing.T) {
setMinimalEnv(t)
t.Setenv("REDIS_URL", "redis://:secret%zz@localhost:6379/0")
_, err := LoadFromEnv()
if err == nil {
t.Fatal("expected error")
}
if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "secret%zz") {
t.Fatalf("error leaked Redis URL credentials: %q", err.Error())
}
if !strings.Contains(err.Error(), "REDIS_URL must be a valid Redis URL") {
t.Fatalf("error = %q, want generic Redis URL error", err.Error())
}
}
func TestLoadFromEnvAcceptsWarningLogAlias(t *testing.T) {
setMinimalEnv(t)
t.Setenv("LOG_LEVEL", "warning")
@@ -99,10 +82,6 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
value string
wantErr string
}{
{name: "redis url missing scheme", key: "REDIS_URL", value: "localhost:6379", wantErr: "REDIS_URL must be a valid Redis URL"},
{name: "redis url invalid db", key: "REDIS_URL", value: "redis://localhost:6379/not-a-number", wantErr: "REDIS_URL must be a valid Redis URL"},
{name: "redis url db below range", key: "REDIS_URL", value: "redis://localhost:6379/-1", wantErr: "REDIS_URL database must be between 0 and 15"},
{name: "redis url db above range", key: "REDIS_URL", value: "redis://localhost:6379/16", wantErr: "REDIS_URL database must be between 0 and 15"},
{name: "poll interval too small", key: "POLL_INTERVAL", value: "1ns", wantErr: "POLL_INTERVAL must be between 5s and 1h0m0s"},
{name: "http timeout too small", key: "HTTP_TIMEOUT", value: "500ms", wantErr: "HTTP_TIMEOUT must be between 1s and 5m0s"},
{name: "invalid log level", key: "LOG_LEVEL", value: "verbose", wantErr: "LOG_LEVEL must be one of"},
@@ -127,7 +106,8 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
func setMinimalEnv(t *testing.T) {
t.Helper()
t.Setenv("TELEGRAM_BOT_TOKEN", "token")
t.Setenv("REDIS_URL", "")
t.Setenv("MONGODB_URI", "mongodb+srv://user:pass@cluster.example.net/")
t.Setenv("MONGODB_DATABASE", "")
t.Setenv("POLL_INTERVAL", "")
t.Setenv("HTTP_TIMEOUT", "")
t.Setenv("LOG_LEVEL", "")
+20 -4
View File
@@ -13,19 +13,35 @@ const (
Path = "/healthz"
)
func Handler() http.Handler {
// Check reports whether the bot is ready to serve. It returns nil when healthy
// and an error describing why not otherwise. A nil Check is always healthy.
type Check func(ctx context.Context) error
// Handler serves the health endpoint. It returns 200 only when check passes,
// and 503 while the bot is still starting or a dependency is unreachable, so an
// orchestrator probe reflects real readiness instead of a static 200.
func Handler(check Check) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(Path, func(w http.ResponseWriter, _ *http.Request) {
mux.HandleFunc(Path, func(w http.ResponseWriter, r *http.Request) {
if check != nil {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := check(ctx); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte("unavailable\n"))
return
}
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok\n"))
})
return mux
}
func Run(ctx context.Context, logger *slog.Logger) {
func Run(ctx context.Context, logger *slog.Logger, check Check) {
server := &http.Server{
Addr: Address,
Handler: Handler(),
Handler: Handler(check),
ReadHeaderTimeout: 3 * time.Second,
}
+26 -2
View File
@@ -1,16 +1,18 @@
package health
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestHandlerReturnsOK(t *testing.T) {
func TestHandlerReturnsOKWhenCheckPasses(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, Path, nil)
res := httptest.NewRecorder()
Handler().ServeHTTP(res, req)
Handler(func(context.Context) error { return nil }).ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", res.Code, http.StatusOK)
@@ -19,3 +21,25 @@ func TestHandlerReturnsOK(t *testing.T) {
t.Fatalf("body = %q, want ok", res.Body.String())
}
}
func TestHandlerNilCheckIsHealthy(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, Path, nil)
res := httptest.NewRecorder()
Handler(nil).ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", res.Code, http.StatusOK)
}
}
func TestHandlerReturnsUnavailableWhenCheckFails(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, Path, nil)
res := httptest.NewRecorder()
Handler(func(context.Context) error { return errors.New("not ready") }).ServeHTTP(res, req)
if res.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", res.Code, http.StatusServiceUnavailable)
}
}
+211
View File
@@ -0,0 +1,211 @@
package mongostore
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// deliveryTTL is the retention window for per-event delivery markers; enforced
// by the TTL index in EnsureIndexes.
const deliveryTTL = 7 * 24 * time.Hour
type PendingComponentEvent struct {
ComponentID string `bson:"_id"`
ComponentName string `bson:"componentName"`
Status string `bson:"status"`
UpdatedAt string `bson:"updatedAt"`
Position int `bson:"position"`
PreviousStatus string `bson:"previousStatus"`
DeliveryKey string `bson:"deliveryKey"`
}
func (s *Store) IsInitialized(ctx context.Context) (bool, error) {
err := s.meta.FindOne(ctx, bson.M{"_id": metaInitializedID}).Err()
if errors.Is(err, mongo.ErrNoDocuments) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (s *Store) SetInitialized(ctx context.Context) error {
_, err := s.meta.UpdateOne(ctx, bson.M{"_id": metaInitializedID},
bson.M{"$set": bson.M{"value": true}}, options.UpdateOne().SetUpsert(true))
return err
}
func (s *Store) ComponentStatuses(ctx context.Context) (map[string]string, error) {
cursor, err := s.componentStatuses.Find(ctx, bson.M{})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
statuses := map[string]string{}
for cursor.Next(ctx) {
var doc struct {
ID string `bson:"_id"`
Status string `bson:"status"`
}
if err := cursor.Decode(&doc); err != nil {
return nil, err
}
statuses[doc.ID] = doc.Status
}
if err := cursor.Err(); err != nil {
return nil, err
}
return statuses, nil
}
func (s *Store) SaveComponentStatus(ctx context.Context, componentID, status string) error {
_, err := s.componentStatuses.UpdateOne(ctx, bson.M{"_id": componentID},
bson.M{"$set": bson.M{"status": status}}, options.UpdateOne().SetUpsert(true))
return err
}
func (s *Store) PendingComponentEvents(ctx context.Context) (map[string]PendingComponentEvent, error) {
cursor, err := s.pendingComponentEvents.Find(ctx, bson.M{})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
events := map[string]PendingComponentEvent{}
for cursor.Next(ctx) {
var event PendingComponentEvent
if err := cursor.Decode(&event); err != nil {
return nil, err
}
events[event.ComponentID] = event
}
if err := cursor.Err(); err != nil {
return nil, err
}
return events, nil
}
func (s *Store) SavePendingComponentEvent(ctx context.Context, event PendingComponentEvent) error {
_, err := s.pendingComponentEvents.ReplaceOne(ctx, bson.M{"_id": event.ComponentID}, event,
options.Replace().SetUpsert(true))
return err
}
func (s *Store) RemovePendingComponentEvent(ctx context.Context, componentID string) error {
_, err := s.pendingComponentEvents.DeleteOne(ctx, bson.M{"_id": componentID})
return err
}
func (s *Store) HasIncidentUpdateVersion(ctx context.Context, updateID, version string) (bool, error) {
var doc struct {
Version string `bson:"version"`
}
err := s.incidentUpdateVersions.FindOne(ctx, bson.M{"_id": updateID}).Decode(&doc)
if errors.Is(err, mongo.ErrNoDocuments) {
return false, nil
}
if err != nil {
return false, err
}
return doc.Version == version, nil
}
func (s *Store) MarkIncidentUpdateVersion(ctx context.Context, updateID, version string) error {
_, err := s.incidentUpdateVersions.UpdateOne(ctx, bson.M{"_id": updateID},
bson.M{"$set": bson.M{"version": version}}, options.UpdateOne().SetUpsert(true))
return err
}
// DeliveredSubscribers returns the subscriber keys already notified for
// eventKey. One Find replaces a per-subscriber membership check.
func (s *Store) DeliveredSubscribers(ctx context.Context, eventKey string) (map[string]bool, error) {
cursor, err := s.delivery.Find(ctx, bson.M{"eventKey": eventKey})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
delivered := map[string]bool{}
for cursor.Next(ctx) {
var doc struct {
Subscriber string `bson:"subscriber"`
}
if err := cursor.Decode(&doc); err != nil {
return nil, err
}
delivered[doc.Subscriber] = true
}
if err := cursor.Err(); err != nil {
return nil, err
}
return delivered, nil
}
func (s *Store) MarkDelivered(ctx context.Context, eventKey, subscriberKey string) error {
id := eventKey + "|" + subscriberKey
update := bson.M{"$set": bson.M{
"eventKey": eventKey,
"subscriber": subscriberKey,
"expiresAt": time.Now().Add(deliveryTTL),
}}
_, err := s.delivery.UpdateOne(ctx, bson.M{"_id": id}, update, options.UpdateOne().SetUpsert(true))
return err
}
func (s *Store) ClearDelivery(ctx context.Context, eventKey string) error {
_, err := s.delivery.DeleteMany(ctx, bson.M{"eventKey": eventKey})
return err
}
func (s *Store) TelegramOffset(ctx context.Context) (int64, error) {
var doc struct {
Value any `bson:"value"`
}
err := s.meta.FindOne(ctx, bson.M{"_id": metaTelegramOffsetID}).Decode(&doc)
if errors.Is(err, mongo.ErrNoDocuments) {
return 0, nil
}
if err != nil {
return 0, err
}
offset, ok := parseTelegramOffsetValue(doc.Value)
if ok {
return offset, nil
}
if _, err := s.meta.DeleteOne(ctx, bson.M{"_id": metaTelegramOffsetID}); err != nil {
return 0, fmt.Errorf("clear invalid telegram offset: %w", err)
}
return 0, nil
}
func parseTelegramOffsetValue(value any) (int64, bool) {
switch value := value.(type) {
case int:
return int64(value), true
case int32:
return int64(value), true
case int64:
return value, true
case string:
offset, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return offset, err == nil
default:
return 0, false
}
}
func (s *Store) SaveTelegramOffset(ctx context.Context, offset int64) error {
_, err := s.meta.UpdateOne(ctx, bson.M{"_id": metaTelegramOffsetID},
bson.M{"$set": bson.M{"value": offset}}, options.UpdateOne().SetUpsert(true))
return err
}
+97
View File
@@ -0,0 +1,97 @@
package mongostore
import (
"context"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// deliveryTTLIndexExpireAfterSeconds makes Mongo expire each delivery marker at
// its own expiresAt timestamp. MarkDelivered sets expiresAt to now+7d, matching
// the Redis delivery marker retention window.
const deliveryTTLIndexExpireAfterSeconds = int32(0)
// EnsureIndexes creates the indexes the store relies on. It is idempotent, and
// replaces the earlier delivery TTL index shape that expired at expiresAt+7d.
func (s *Store) EnsureIndexes(ctx context.Context) error {
if err := s.ensureDeliveryTTLIndex(ctx); err != nil {
return err
}
_, err := s.delivery.Indexes().CreateOne(ctx, mongo.IndexModel{
// Speeds DeliveredSubscribers/ClearDelivery lookups by eventKey.
Keys: bson.D{{Key: "eventKey", Value: 1}},
})
return err
}
func (s *Store) ensureDeliveryTTLIndex(ctx context.Context) error {
cursor, err := s.delivery.Indexes().List(ctx)
if err != nil {
return err
}
defer cursor.Close(ctx)
needsCreate := true
for cursor.Next(ctx) {
var index bson.M
if err := cursor.Decode(&index); err != nil {
return err
}
if !isExpiresAtIndex(index["key"]) {
continue
}
if indexNumber(index["expireAfterSeconds"]) == int64(deliveryTTLIndexExpireAfterSeconds) {
needsCreate = false
continue
}
name, _ := index["name"].(string)
if name == "" {
continue
}
if err := s.delivery.Indexes().DropOne(ctx, name); err != nil {
return err
}
}
if err := cursor.Err(); err != nil {
return err
}
if !needsCreate {
return nil
}
_, err = s.delivery.Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "expiresAt", Value: 1}},
Options: options.Index().SetExpireAfterSeconds(deliveryTTLIndexExpireAfterSeconds),
})
return err
}
func isExpiresAtIndex(key any) bool {
switch key := key.(type) {
case bson.M:
return len(key) == 1 && indexNumber(key["expiresAt"]) == 1
case map[string]any:
return len(key) == 1 && indexNumber(key["expiresAt"]) == 1
case bson.D:
return len(key) == 1 && key[0].Key == "expiresAt" && indexNumber(key[0].Value) == 1
default:
return false
}
}
func indexNumber(value any) int64 {
switch value := value.(type) {
case int:
return int64(value)
case int32:
return int64(value)
case int64:
return value
case float64:
return int64(value)
default:
return -1
}
}
+50
View File
@@ -0,0 +1,50 @@
package mongostore
import "go.mongodb.org/mongo-driver/v2/mongo"
// Collection names. Each former Redis key becomes a MongoDB collection; the
// document model collapses the subscribers set + settings hash into one
// collection and replaces the per-event delivery sets with a single TTL-indexed
// collection.
const (
subscribersCollection = "subscribers"
componentStatusesCollection = "component_statuses"
pendingComponentEventsCollection = "pending_component_events"
incidentUpdateVersionsCollection = "incident_update_versions"
deliveryCollection = "delivery"
metaCollection = "meta"
)
// meta document IDs.
const (
metaInitializedID = "initialized"
metaTelegramOffsetID = "telegramOffset"
)
const (
SubscriptionTypeIncident = "incident"
SubscriptionTypeComponent = "component"
)
type Store struct {
db *mongo.Database
subscribers *mongo.Collection
componentStatuses *mongo.Collection
pendingComponentEvents *mongo.Collection
incidentUpdateVersions *mongo.Collection
delivery *mongo.Collection
meta *mongo.Collection
}
func New(client *mongo.Client, dbName string) *Store {
db := client.Database(dbName)
return &Store{
db: db,
subscribers: db.Collection(subscribersCollection),
componentStatuses: db.Collection(componentStatusesCollection),
pendingComponentEvents: db.Collection(pendingComponentEventsCollection),
incidentUpdateVersions: db.Collection(incidentUpdateVersionsCollection),
delivery: db.Collection(deliveryCollection),
meta: db.Collection(metaCollection),
}
}
+542
View File
@@ -0,0 +1,542 @@
//go:build integration
// Package mongostore integration tests run against a real mongod via
// testcontainers and are excluded from the default `go test ./...`. Run them
// with Docker available:
//
// go test -tags=integration ./internal/mongostore/...
package mongostore
import (
"context"
"strings"
"sync"
"testing"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
tcmongo "github.com/testcontainers/testcontainers-go/modules/mongodb"
)
var (
sharedClientOnce sync.Once
sharedClient *mongo.Client
sharedClientErr error
)
// testMongo starts the shared mongod container once, then hands each test a
// Store bound to a unique database that is dropped on cleanup.
func testMongo(t *testing.T) *Store {
t.Helper()
store := testMongoWithoutIndexes(t)
if err := store.EnsureIndexes(context.Background()); err != nil {
t.Fatalf("EnsureIndexes: %v", err)
}
return store
}
func testMongoWithoutIndexes(t *testing.T) *Store {
t.Helper()
ctx := context.Background()
sharedClientOnce.Do(func() {
container, err := tcmongo.Run(ctx, "mongo:8")
if err != nil {
sharedClientErr = err
return
}
uri, err := container.ConnectionString(ctx)
if err != nil {
sharedClientErr = err
return
}
// The container is intentionally left running for the whole package
// run; testcontainers' reaper removes it after the process exits.
_ = container
sharedClient, sharedClientErr = mongo.Connect(options.Client().ApplyURI(uri))
})
if sharedClientErr != nil {
t.Fatalf("start mongo container: %v", sharedClientErr)
}
dbName := "test_" + strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
db := sharedClient.Database(dbName)
t.Cleanup(func() {
_ = db.Drop(context.Background())
})
return New(sharedClient, dbName)
}
func TestAddAndGetSubscriber(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
sub := NewSubscriber(123, nil)
if err := store.AddSubscriber(ctx, sub); err != nil {
t.Fatalf("AddSubscriber: %v", err)
}
got, exists, err := store.GetSubscriber(ctx, sub)
if err != nil || !exists {
t.Fatalf("GetSubscriber exists=%v err=%v", exists, err)
}
if len(got.Types) != 2 {
t.Fatalf("default Types = %v, want both defaults", got.Types)
}
if len(got.Components) != 0 {
t.Fatalf("default Components = %v, want empty", got.Components)
}
}
func TestAddSubscriberPreservesSettingsOnReStart(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
sub := NewSubscriber(123, nil)
if err := store.AddSubscriber(ctx, sub); err != nil {
t.Fatalf("AddSubscriber: %v", err)
}
if _, err := store.UpdateSubscriberSettings(ctx, sub, []string{SubscriptionTypeComponent}, []string{"c-api"}); err != nil {
t.Fatalf("UpdateSubscriberSettings: %v", err)
}
if err := store.AddSubscriber(ctx, sub); err != nil {
t.Fatalf("re-AddSubscriber: %v", err)
}
got, _, err := store.GetSubscriber(ctx, sub)
if err != nil {
t.Fatalf("GetSubscriber: %v", err)
}
if len(got.Types) != 1 || got.Types[0] != SubscriptionTypeComponent {
t.Fatalf("Types = %v, want preserved [component]", got.Types)
}
if len(got.Components) != 1 || got.Components[0] != "c-api" {
t.Fatalf("Components = %v, want preserved [c-api]", got.Components)
}
}
func TestRemoveSubscriberDeletesDocument(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
sub := NewSubscriber(123, nil)
if err := store.AddSubscriber(ctx, sub); err != nil {
t.Fatalf("AddSubscriber: %v", err)
}
if err := store.RemoveSubscriber(ctx, sub); err != nil {
t.Fatalf("RemoveSubscriber: %v", err)
}
_, exists, err := store.GetSubscriber(ctx, sub)
if err != nil {
t.Fatalf("GetSubscriber after remove: %v", err)
}
if exists {
t.Fatal("subscriber still exists after remove")
}
}
func TestGetSubscriberMissingReturnsFalse(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
_, exists, err := store.GetSubscriber(ctx, NewSubscriber(999, nil))
if err != nil {
t.Fatalf("GetSubscriber: %v", err)
}
if exists {
t.Fatal("exists = true, want false for missing subscriber")
}
}
func TestListSubscribersSelfHealsMalformedKey(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
good := NewSubscriber(1, nil)
if err := store.AddSubscriber(ctx, good); err != nil {
t.Fatalf("AddSubscriber: %v", err)
}
if _, err := store.subscribers.InsertOne(ctx, bson.M{"_id": "abc:def:ghi", "types": []string{}, "components": []string{}}); err != nil {
t.Fatalf("insert malformed subscriber: %v", err)
}
if _, err := store.ListSubscribers(ctx); err == nil {
t.Fatal("expected malformed key error on first list")
}
// Malformed doc was deleted; the next list succeeds with only the good one.
subs, err := store.ListSubscribers(ctx)
if err != nil {
t.Fatalf("second ListSubscribers: %v", err)
}
if len(subs) != 1 || subs[0].Key() != good.Key() {
t.Fatalf("subscribers = %v, want only %q", subs, good.Key())
}
}
func TestListSubscribersSelfHealsMalformedSettings(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
if _, err := store.subscribers.InsertOne(ctx, bson.M{
"_id": "2",
"chatID": int64(2),
"types": "not-an-array",
"components": bson.A{123},
}); err != nil {
t.Fatalf("insert malformed subscriber settings: %v", err)
}
subs, err := store.ListSubscribers(ctx)
if err != nil {
t.Fatalf("ListSubscribers: %v", err)
}
if len(subs) != 1 || subs[0].Key() != "2" {
t.Fatalf("subscribers = %v, want key 2", subs)
}
if len(subs[0].Types) != 2 || len(subs[0].Components) != 0 {
t.Fatalf("settings = types %v components %v, want defaults", subs[0].Types, subs[0].Components)
}
var raw bson.M
if err := store.subscribers.FindOne(ctx, bson.M{"_id": "2"}).Decode(&raw); err != nil {
t.Fatalf("load healed subscriber: %v", err)
}
types, ok := storedStringSlice(raw["types"])
if !ok || len(normalizeTypes(types)) != 2 {
t.Fatalf("healed types = %v ok=%v, want defaults", raw["types"], ok)
}
components, ok := storedStringSlice(raw["components"])
if !ok || len(components) != 0 {
t.Fatalf("healed components = %v ok=%v, want empty", raw["components"], ok)
}
}
func TestGetSubscriberSelfHealsMalformedSettings(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
if _, err := store.subscribers.InsertOne(ctx, bson.M{
"_id": "3",
"chatID": int64(3),
"types": bson.A{"incident"},
"components": int32(42),
}); err != nil {
t.Fatalf("insert malformed subscriber settings: %v", err)
}
got, exists, err := store.GetSubscriber(ctx, NewSubscriber(3, nil))
if err != nil || !exists {
t.Fatalf("GetSubscriber exists=%v err=%v", exists, err)
}
if len(got.Types) != 1 || got.Types[0] != SubscriptionTypeIncident || len(got.Components) != 0 {
t.Fatalf("settings = types %v components %v, want incident and empty components", got.Types, got.Components)
}
}
func TestUpdateSubscriberSettingsMatchedVsMissing(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
sub := NewSubscriber(1, nil)
updated, err := store.UpdateSubscriberSettings(ctx, sub, []string{SubscriptionTypeComponent}, []string{"c-api"})
if err != nil {
t.Fatalf("UpdateSubscriberSettings: %v", err)
}
if updated {
t.Fatal("updated = true for missing subscriber, want false")
}
if err := store.AddSubscriber(ctx, sub); err != nil {
t.Fatalf("AddSubscriber: %v", err)
}
updated, err = store.UpdateSubscriberSettings(ctx, sub, []string{SubscriptionTypeComponent}, []string{"c-api"})
if err != nil {
t.Fatalf("UpdateSubscriberSettings: %v", err)
}
if !updated {
t.Fatal("updated = false for existing subscriber, want true")
}
}
func TestUpdateSubscriberTypesMatchedVsMissing(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
sub := NewSubscriber(1, nil)
updated, err := store.UpdateSubscriberTypes(ctx, sub, []string{SubscriptionTypeComponent})
if err != nil {
t.Fatalf("UpdateSubscriberTypes missing: %v", err)
}
if updated {
t.Fatal("updated = true for missing subscriber, want false")
}
if err := store.AddSubscriber(ctx, sub); err != nil {
t.Fatalf("AddSubscriber: %v", err)
}
updated, err = store.UpdateSubscriberTypes(ctx, sub, []string{" COMPONENT ", "invalid"})
if err != nil {
t.Fatalf("UpdateSubscriberTypes existing: %v", err)
}
if !updated {
t.Fatal("updated = false for existing subscriber, want true")
}
got, _, err := store.GetSubscriber(ctx, sub)
if err != nil {
t.Fatalf("GetSubscriber: %v", err)
}
if len(got.Types) != 1 || got.Types[0] != SubscriptionTypeComponent {
t.Fatalf("Types = %v, want normalized [component]", got.Types)
}
}
func TestComponentStatusRoundTrip(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
if err := store.SaveComponentStatus(ctx, "c1", "degraded_performance"); err != nil {
t.Fatalf("SaveComponentStatus: %v", err)
}
if err := store.SaveComponentStatus(ctx, "c1", "operational"); err != nil {
t.Fatalf("SaveComponentStatus update: %v", err)
}
statuses, err := store.ComponentStatuses(ctx)
if err != nil {
t.Fatalf("ComponentStatuses: %v", err)
}
if statuses["c1"] != "operational" {
t.Fatalf("status = %q, want operational", statuses["c1"])
}
}
func TestPendingComponentEventRoundTrip(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
event := PendingComponentEvent{
ComponentID: "c1",
ComponentName: "API",
Status: "degraded_performance",
UpdatedAt: "2026-01-01T00:00:00Z",
Position: 2,
PreviousStatus: "operational",
DeliveryKey: "component:c1:degraded_performance:2026-01-01T00:00:00Z",
}
if err := store.SavePendingComponentEvent(ctx, event); err != nil {
t.Fatalf("SavePendingComponentEvent: %v", err)
}
events, err := store.PendingComponentEvents(ctx)
if err != nil {
t.Fatalf("PendingComponentEvents: %v", err)
}
if got := events["c1"]; got != event {
t.Fatalf("event = %+v, want %+v", got, event)
}
if err := store.RemovePendingComponentEvent(ctx, "c1"); err != nil {
t.Fatalf("RemovePendingComponentEvent: %v", err)
}
events, err = store.PendingComponentEvents(ctx)
if err != nil {
t.Fatalf("PendingComponentEvents after remove: %v", err)
}
if len(events) != 0 {
t.Fatalf("events = %v, want empty after remove", events)
}
}
func TestIncidentUpdateVersionDedup(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
seen, err := store.HasIncidentUpdateVersion(ctx, "u1", "v1")
if err != nil {
t.Fatalf("HasIncidentUpdateVersion: %v", err)
}
if seen {
t.Fatal("unseen update reported as seen")
}
if err := store.MarkIncidentUpdateVersion(ctx, "u1", "v1"); err != nil {
t.Fatalf("MarkIncidentUpdateVersion: %v", err)
}
seen, err = store.HasIncidentUpdateVersion(ctx, "u1", "v1")
if err != nil || !seen {
t.Fatalf("same version seen=%v err=%v, want seen", seen, err)
}
// An edited update (new version) must notify again.
seen, err = store.HasIncidentUpdateVersion(ctx, "u1", "v2")
if err != nil {
t.Fatalf("HasIncidentUpdateVersion v2: %v", err)
}
if seen {
t.Fatal("edited update version reported as seen")
}
}
func TestDeliveryDedupAndClear(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
if err := store.MarkDelivered(ctx, "evt1", "sub-a"); err != nil {
t.Fatalf("MarkDelivered: %v", err)
}
if err := store.MarkDelivered(ctx, "evt1", "sub-b"); err != nil {
t.Fatalf("MarkDelivered: %v", err)
}
var marker struct {
ExpiresAt time.Time `bson:"expiresAt"`
}
if err := store.delivery.FindOne(ctx, bson.M{"_id": "evt1|sub-a"}).Decode(&marker); err != nil {
t.Fatalf("load delivery marker: %v", err)
}
if marker.ExpiresAt.Before(time.Now().Add(deliveryTTL-time.Minute)) || marker.ExpiresAt.After(time.Now().Add(deliveryTTL+time.Minute)) {
t.Fatalf("expiresAt = %s, want about %s from now", marker.ExpiresAt, deliveryTTL)
}
delivered, err := store.DeliveredSubscribers(ctx, "evt1")
if err != nil {
t.Fatalf("DeliveredSubscribers: %v", err)
}
if !delivered["sub-a"] || !delivered["sub-b"] || len(delivered) != 2 {
t.Fatalf("delivered = %v, want sub-a and sub-b", delivered)
}
if err := store.ClearDelivery(ctx, "evt1"); err != nil {
t.Fatalf("ClearDelivery: %v", err)
}
delivered, err = store.DeliveredSubscribers(ctx, "evt1")
if err != nil {
t.Fatalf("DeliveredSubscribers after clear: %v", err)
}
if len(delivered) != 0 {
t.Fatalf("delivered = %v, want empty after clear", delivered)
}
}
func TestDeliveryTTLIndexExists(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
assertDeliveryTTLIndex(t, ctx, store, int64(deliveryTTLIndexExpireAfterSeconds))
}
func TestEnsureIndexesReplacesLegacyDeliveryTTLIndex(t *testing.T) {
ctx := context.Background()
store := testMongoWithoutIndexes(t)
if _, err := store.delivery.Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "expiresAt", Value: 1}},
Options: options.Index().SetExpireAfterSeconds(604800),
}); err != nil {
t.Fatalf("create legacy TTL index: %v", err)
}
if err := store.EnsureIndexes(ctx); err != nil {
t.Fatalf("EnsureIndexes: %v", err)
}
assertDeliveryTTLIndex(t, ctx, store, int64(deliveryTTLIndexExpireAfterSeconds))
}
func assertDeliveryTTLIndex(t *testing.T, ctx context.Context, store *Store, want int64) {
t.Helper()
cursor, err := store.delivery.Indexes().List(ctx)
if err != nil {
t.Fatalf("list indexes: %v", err)
}
var indexes []bson.M
if err := cursor.All(ctx, &indexes); err != nil {
t.Fatalf("decode indexes: %v", err)
}
found := false
for _, idx := range indexes {
if !isExpiresAtIndex(idx["key"]) {
continue
}
if asInt(idx["expireAfterSeconds"]) == want {
found = true
}
}
if !found {
t.Fatalf("no expiresAt TTL index with expireAfterSeconds=%d in %v", want, indexes)
}
}
func asInt(v any) int64 {
switch n := v.(type) {
case int:
return int64(n)
case int32:
return int64(n)
case int64:
return n
case float64:
return int64(n)
default:
return -1
}
}
func TestTelegramOffsetRoundTrip(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
offset, err := store.TelegramOffset(ctx)
if err != nil {
t.Fatalf("TelegramOffset: %v", err)
}
if offset != 0 {
t.Fatalf("default offset = %d, want 0", offset)
}
if err := store.SaveTelegramOffset(ctx, 42); err != nil {
t.Fatalf("SaveTelegramOffset: %v", err)
}
offset, err = store.TelegramOffset(ctx)
if err != nil {
t.Fatalf("TelegramOffset: %v", err)
}
if offset != 42 {
t.Fatalf("offset = %d, want 42", offset)
}
}
func TestTelegramOffsetAcceptsStringValue(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
if _, err := store.meta.InsertOne(ctx, bson.M{"_id": metaTelegramOffsetID, "value": "42"}); err != nil {
t.Fatalf("insert string offset: %v", err)
}
offset, err := store.TelegramOffset(ctx)
if err != nil {
t.Fatalf("TelegramOffset: %v", err)
}
if offset != 42 {
t.Fatalf("offset = %d, want 42", offset)
}
}
func TestTelegramOffsetSelfHealsInvalidValue(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
if _, err := store.meta.InsertOne(ctx, bson.M{"_id": metaTelegramOffsetID, "value": "bad"}); err != nil {
t.Fatalf("insert invalid offset: %v", err)
}
offset, err := store.TelegramOffset(ctx)
if err != nil {
t.Fatalf("TelegramOffset: %v", err)
}
if offset != 0 {
t.Fatalf("offset = %d, want 0", offset)
}
count, err := store.meta.CountDocuments(ctx, bson.M{"_id": metaTelegramOffsetID})
if err != nil {
t.Fatalf("CountDocuments: %v", err)
}
if count != 0 {
t.Fatalf("invalid offset doc count = %d, want 0", count)
}
}
func TestInitializedFlag(t *testing.T) {
ctx := context.Background()
store := testMongo(t)
initialized, err := store.IsInitialized(ctx)
if err != nil {
t.Fatalf("IsInitialized: %v", err)
}
if initialized {
t.Fatal("fresh store reported initialized")
}
if err := store.SetInitialized(ctx); err != nil {
t.Fatalf("SetInitialized: %v", err)
}
initialized, err = store.IsInitialized(ctx)
if err != nil || !initialized {
t.Fatalf("IsInitialized after set = %v err=%v, want true", initialized, err)
}
}
+256
View File
@@ -0,0 +1,256 @@
package mongostore
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
type Subscriber struct {
ChatID int64
ThreadID *int
Types []string
Components []string
}
// subscriberDoc is the stored shape of a subscriber. Identity lives in _id (the
// subscriber key), mirroring the Redis design where the key held identity and a
// separate hash held settings; chatID/threadID are denormalized for inspection.
type subscriberDoc struct {
ID string `bson:"_id"`
ChatID int64 `bson:"chatID"`
ThreadID *int `bson:"threadID,omitempty"`
Types any `bson:"types"`
Components any `bson:"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)
}
// AddSubscriber upserts the subscriber. An existing subscriber keeps its
// settings on re-/start (only chatID/threadID are refreshed); a new subscriber
// is seeded with default types and no component filter.
func (s *Store) AddSubscriber(ctx context.Context, sub Subscriber) error {
// Identity lives in _id; chatID/threadID are denormalized for inspection.
// Omit threadID when absent so the stored shape matches subscriberDoc's
// omitempty decode (no explicit null).
set := bson.M{"chatID": sub.ChatID}
if sub.ThreadID != nil {
set["threadID"] = *sub.ThreadID
}
update := bson.M{
"$set": set,
"$setOnInsert": bson.M{
"types": DefaultSubscriptionTypes(),
"components": []string{},
},
}
_, err := s.subscribers.UpdateOne(ctx, bson.M{"_id": sub.Key()}, update, options.UpdateOne().SetUpsert(true))
return err
}
func (s *Store) RemoveSubscriber(ctx context.Context, sub Subscriber) error {
_, err := s.subscribers.DeleteOne(ctx, bson.M{"_id": sub.Key()})
return err
}
func (s *Store) ListSubscribers(ctx context.Context) ([]Subscriber, error) {
cursor, err := s.subscribers.Find(ctx, bson.M{})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var subscribers []Subscriber
for cursor.Next(ctx) {
var doc subscriberDoc
if err := cursor.Decode(&doc); err != nil {
return nil, err
}
sub, err := s.subscriberFromDoc(ctx, doc)
if err != nil {
return nil, err
}
subscribers = append(subscribers, sub)
}
if err := cursor.Err(); err != nil {
return nil, err
}
return subscribers, nil
}
func (s *Store) GetSubscriber(ctx context.Context, sub Subscriber) (Subscriber, bool, error) {
var doc subscriberDoc
if err := s.subscribers.FindOne(ctx, bson.M{"_id": sub.Key()}).Decode(&doc); err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
return Subscriber{}, false, nil
}
return Subscriber{}, false, err
}
resolved, err := s.subscriberFromDoc(ctx, doc)
if err != nil {
return Subscriber{}, false, err
}
return resolved, true, nil
}
func (s *Store) UpdateSubscriberTypes(ctx context.Context, sub Subscriber, types []string) (bool, error) {
return s.setSubscriberFields(ctx, sub.Key(), bson.M{"types": normalizeTypes(types)})
}
func (s *Store) UpdateSubscriberComponents(ctx context.Context, sub Subscriber, components []string) (bool, error) {
return s.setSubscriberFields(ctx, sub.Key(), bson.M{"components": normalizeComponents(components)})
}
func (s *Store) UpdateSubscriberSettings(ctx context.Context, sub Subscriber, types, components []string) (bool, error) {
return s.setSubscriberFields(ctx, sub.Key(), bson.M{
"types": normalizeTypes(types),
"components": normalizeComponents(components),
})
}
// setSubscriberFields applies a $set only when the subscriber exists.
// MatchedCount==0 means "not subscribed", replacing the Redis check-then-set
// Lua script.
func (s *Store) setSubscriberFields(ctx context.Context, key string, fields bson.M) (bool, error) {
result, err := s.subscribers.UpdateOne(ctx, bson.M{"_id": key}, bson.M{"$set": fields})
if err != nil {
return false, err
}
return result.MatchedCount > 0, nil
}
// subscriberFromDoc derives identity from the document _id (so a malformed key
// self-heals as it did under Redis) and normalizes the stored settings. Corrupt
// or missing settings fields are rewritten to defaults so one malformed
// document cannot block subscriber fan-out.
func (s *Store) subscriberFromDoc(ctx context.Context, doc subscriberDoc) (Subscriber, error) {
sub, err := ParseSubscriberKey(doc.ID)
if err != nil {
if _, removeErr := s.subscribers.DeleteOne(ctx, bson.M{"_id": doc.ID}); removeErr != nil {
return Subscriber{}, fmt.Errorf("remove malformed subscriber key %q: %w", doc.ID, removeErr)
}
return Subscriber{}, fmt.Errorf("malformed subscriber key %q: %w", doc.ID, err)
}
types, healTypes := normalizeStoredTypes(doc.Types)
components, healComponents := normalizeStoredComponents(doc.Components)
if healTypes || healComponents {
fields := bson.M{}
if healTypes {
fields["types"] = types
}
if healComponents {
fields["components"] = components
}
if _, err := s.subscribers.UpdateOne(ctx, bson.M{"_id": doc.ID}, bson.M{"$set": fields}); err != nil {
return Subscriber{}, fmt.Errorf("heal malformed subscriber settings %q: %w", doc.ID, err)
}
}
sub.Types = types
sub.Components = components
return sub, nil
}
func normalizeStoredTypes(value any) ([]string, bool) {
values, ok := storedStringSlice(value)
if !ok {
return DefaultSubscriptionTypes(), true
}
return normalizeTypes(values), false
}
func normalizeStoredComponents(value any) ([]string, bool) {
values, ok := storedStringSlice(value)
if !ok {
return []string{}, true
}
return normalizeComponents(values), false
}
func storedStringSlice(value any) ([]string, bool) {
switch value := value.(type) {
case nil:
return nil, false
case []string:
return append([]string(nil), value...), true
case bson.A:
return stringsFromAnySlice([]any(value))
case []any:
return stringsFromAnySlice(value)
default:
return nil, false
}
}
func stringsFromAnySlice(values []any) ([]string, bool) {
result := make([]string, 0, len(values))
for _, value := range values {
text, ok := value.(string)
if !ok {
return nil, false
}
result = append(result, text)
}
return result, true
}
@@ -1,4 +1,4 @@
package redisstore
package mongostore
import "strings"
+66
View File
@@ -0,0 +1,66 @@
package mongostore
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")
}
}
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")
}
}
func TestNormalizeTypesDedupesAndDefaults(t *testing.T) {
got := normalizeTypes([]string{"incident", "component", "incident"})
if len(got) != 2 || got[0] != SubscriptionTypeIncident || got[1] != SubscriptionTypeComponent {
t.Fatalf("normalizeTypes = %v", got)
}
if def := normalizeTypes(nil); len(def) != 2 {
t.Fatalf("normalizeTypes(nil) = %v, want defaults", def)
}
if def := normalizeTypes([]string{"bogus"}); len(def) != 2 {
t.Fatalf("normalizeTypes(bogus) = %v, want defaults", def)
}
}
func TestNormalizeComponentsTrimsAndDedupes(t *testing.T) {
got := normalizeComponents([]string{" api ", "API", ""})
if len(got) != 1 || got[0] != "api" {
t.Fatalf("normalizeComponents = %v, want [api]", got)
}
}
+11 -3
View File
@@ -4,7 +4,7 @@ import (
"context"
"fmt"
"github.com/tiennm99/openai-status-bot/internal/redisstore"
"github.com/tiennm99/openai-status-bot/internal/mongostore"
"github.com/tiennm99/openai-status-bot/internal/telegram"
)
@@ -44,7 +44,7 @@ func (e *deliveryError) addAll(other *deliveryError) {
e.count += other.count
}
func (r *Runner) notifySubscribers(ctx context.Context, event notificationEvent, subscribers []redisstore.Subscriber, removed, failed map[string]bool) (*deliveryError, error) {
func (r *Runner) notifySubscribers(ctx context.Context, event notificationEvent, subscribers []mongostore.Subscriber, removed, failed map[string]bool) (*deliveryError, error) {
deliveryFailures := &deliveryError{}
var delivered map[string]bool
if event.deliveryKey != "" {
@@ -56,7 +56,7 @@ func (r *Runner) notifySubscribers(ctx context.Context, event notificationEvent,
}
for _, subscriber := range subscribers {
subscriberKey := subscriber.Key()
if removed[subscriberKey] || failed[subscriberKey] {
if removed[subscriberKey] {
continue
}
if !subscriber.Accepts(event.eventType, event.componentID, event.componentName) {
@@ -65,6 +65,14 @@ func (r *Runner) notifySubscribers(ctx context.Context, event notificationEvent,
if delivered[subscriberKey] {
continue
}
if failed[subscriberKey] {
// This subscriber already hit a retryable failure earlier in the
// poll. Skip it to avoid hammering, but record this event as
// incomplete so its checkpoint is deferred and it retries next poll
// instead of being marked delivered to a subscriber that never got it.
deliveryFailures.add(fmt.Errorf("deferred %s after earlier failure", subscriberKey))
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 {
+47 -39
View File
@@ -8,15 +8,22 @@ import (
"strings"
openai "github.com/tiennm99/openai-status-bot/internal/openai"
"github.com/tiennm99/openai-status-bot/internal/redisstore"
"github.com/tiennm99/openai-status-bot/internal/mongostore"
)
// collectEvents returns the events to deliver this poll plus two checkpoint
// groups: `before` writes run unconditionally before delivery (pending
// markers), and `baseline` writes run unconditionally after delivery (seeding
// saves and unchanged-status re-saves). Each event carries its OWN
// after-delivery checkpoints in event.checkpoints, which run only once that
// event has fully delivered — so a delivery failure for one event no longer
// blocks checkpoints for unrelated events.
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)
componentEvents, before, componentBaseline, err := r.collectComponentEvents(ctx, summary, initialized)
if err != nil {
return nil, nil, nil, err
}
incidentEvents, incidentAfter, err := r.collectIncidentEvents(ctx, incidents, initialized)
incidentEvents, incidentBaseline, err := r.collectIncidentEvents(ctx, incidents, initialized)
if err != nil {
return nil, nil, nil, err
}
@@ -31,8 +38,8 @@ func (r *Runner) collectEvents(ctx context.Context, summary openai.Summary, inci
}
return events[i].sortTime < events[j].sortTime
})
after := append(componentAfter, incidentAfter...)
return events, componentBefore, after, nil
baseline := append(componentBaseline, incidentBaseline...)
return events, before, baseline, nil
}
func (r *Runner) collectComponentEvents(ctx context.Context, summary openai.Summary, initialized bool) ([]notificationEvent, []checkpoint, []checkpoint, error) {
@@ -49,20 +56,20 @@ func (r *Runner) collectComponentEvents(ctx context.Context, summary openai.Summ
events := make([]notificationEvent, 0)
before := make([]checkpoint, 0)
after := make([]checkpoint, 0, len(summary.Components)+len(pending))
baseline := make([]checkpoint, 0, len(summary.Components))
for _, pendingEvent := range pending {
pendingEvent := pendingEvent
component := pendingComponent(pendingEvent)
events = append(events, notificationEvent{
eventType: redisstore.SubscriptionTypeComponent,
eventType: mongostore.SubscriptionTypeComponent,
componentID: pendingEvent.ComponentID,
componentName: pendingEvent.ComponentName,
deliveryKey: pendingEvent.DeliveryKey,
sortTime: pendingEvent.UpdatedAt,
text: FormatComponentChange(component, pendingEvent.PreviousStatus, duplicates[component.Name]),
checkpoints: r.resolveComponentCheckpoints(pendingEvent.ComponentID, pendingEvent.Status, pendingEvent.DeliveryKey),
})
after = append(after, r.resolveComponentCheckpoints(pendingEvent.ComponentID, pendingEvent.Status, pendingEvent.DeliveryKey)...)
}
for _, component := range summary.Components {
@@ -75,14 +82,10 @@ func (r *Runner) collectComponentEvents(ctx context.Context, summary openai.Summ
}
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 {
if !initialized || (found && previousStatus == component.Status) {
// Seeding or no change: persist the current status unconditionally,
// no notification.
baseline = append(baseline, func(ctx context.Context) error {
return r.store.SaveComponentStatus(ctx, component.ID, component.Status)
})
continue
@@ -92,7 +95,7 @@ func (r *Runner) collectComponentEvents(ctx context.Context, summary openai.Summ
}
deliveryKey := fmt.Sprintf("component:%s:%s:%s", component.ID, component.Status, component.UpdatedAt)
pendingEvent := redisstore.PendingComponentEvent{
pendingEvent := mongostore.PendingComponentEvent{
ComponentID: component.ID,
ComponentName: component.Name,
Status: component.Status,
@@ -105,16 +108,16 @@ func (r *Runner) collectComponentEvents(ctx context.Context, summary openai.Summ
return r.store.SavePendingComponentEvent(ctx, pendingEvent)
})
events = append(events, notificationEvent{
eventType: redisstore.SubscriptionTypeComponent,
eventType: mongostore.SubscriptionTypeComponent,
componentID: component.ID,
componentName: component.Name,
deliveryKey: deliveryKey,
sortTime: component.UpdatedAt,
text: FormatComponentChange(component, previousStatus, duplicates[component.Name]),
checkpoints: r.resolveComponentCheckpoints(component.ID, component.Status, deliveryKey),
})
after = append(after, r.resolveComponentCheckpoints(component.ID, component.Status, deliveryKey)...)
}
return events, before, after, nil
return events, before, baseline, nil
}
// resolveComponentCheckpoints builds the post-delivery writes shared by pending
@@ -130,7 +133,7 @@ func (r *Runner) resolveComponentCheckpoints(componentID, status, deliveryKey st
func (r *Runner) collectIncidentEvents(ctx context.Context, response openai.IncidentsResponse, initialized bool) ([]notificationEvent, []checkpoint, error) {
events := make([]notificationEvent, 0)
checkpoints := make([]checkpoint, 0)
baseline := make([]checkpoint, 0)
for _, incident := range response.Incidents {
incident := incident
for _, update := range incident.IncidentUpdates {
@@ -143,29 +146,34 @@ func (r *Runner) collectIncidentEvents(ctx context.Context, response openai.Inci
if err != nil {
return nil, nil, err
}
if !seen {
checkpoints = append(checkpoints, func(ctx context.Context) error {
return r.store.MarkIncidentUpdateVersion(ctx, update.ID, version)
})
if seen {
continue
}
markVersion := func(ctx context.Context) error {
return r.store.MarkIncidentUpdateVersion(ctx, update.ID, version)
}
if !initialized {
// Seeding: record the version baseline without notifying.
baseline = append(baseline, markVersion)
continue
}
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)
})
}
events = append(events, notificationEvent{
eventType: mongostore.SubscriptionTypeIncident,
deliveryKey: deliveryKey,
sortTime: incidentUpdateSortTime(update),
text: FormatIncidentUpdate(incident, update),
checkpoints: []checkpoint{
markVersion,
func(ctx context.Context) error { return r.store.ClearDelivery(ctx, deliveryKey) },
},
})
}
}
return events, checkpoints, nil
return events, baseline, nil
}
func pendingComponent(event redisstore.PendingComponentEvent) openai.Component {
func pendingComponent(event mongostore.PendingComponentEvent) openai.Component {
return openai.Component{
ID: event.ComponentID,
Name: event.ComponentName,
@@ -175,7 +183,7 @@ func pendingComponent(event redisstore.PendingComponentEvent) openai.Component {
}
}
func componentsForDuplicateLabels(components []openai.Component, pending map[string]redisstore.PendingComponentEvent) []openai.Component {
func componentsForDuplicateLabels(components []openai.Component, pending map[string]mongostore.PendingComponentEvent) []openai.Component {
result := make([]openai.Component, 0, len(components)+len(pending))
seen := map[string]bool{}
add := func(component openai.Component) {
+41 -10
View File
@@ -6,7 +6,7 @@ import (
"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/mongostore"
)
type StatusClient interface {
@@ -20,19 +20,19 @@ type Store interface {
DeliveredSubscribers(ctx context.Context, eventKey string) (map[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)
ListSubscribers(ctx context.Context) ([]mongostore.Subscriber, 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)
PendingComponentEvents(ctx context.Context) (map[string]mongostore.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
RemoveSubscriber(ctx context.Context, sub mongostore.Subscriber) error
SavePendingComponentEvent(ctx context.Context, event mongostore.PendingComponentEvent) 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
SendMessage(ctx context.Context, sub mongostore.Subscriber, text string) error
}
type Runner struct {
@@ -50,6 +50,10 @@ type notificationEvent struct {
deliveryKey string
sortTime string
text string
// checkpoints are the post-delivery store writes for this event. They run
// only after the event has fully delivered to every accepting subscriber,
// so an unrelated event's delivery failure cannot block them.
checkpoints []checkpoint
}
type checkpoint func(ctx context.Context) error
@@ -95,7 +99,7 @@ func (r *Runner) CheckOnce(ctx context.Context) error {
return err
}
events, beforeDelivery, checkpoints, err := r.collectEvents(ctx, summary, incidents, initialized)
events, beforeDelivery, baseline, err := r.collectEvents(ctx, summary, incidents, initialized)
if err != nil {
return err
}
@@ -119,15 +123,33 @@ func (r *Runner) CheckOnce(ctx context.Context) error {
if err != nil {
return err
}
deliveryErr.addAll(failures)
if failures != nil {
// Event not fully delivered; defer its checkpoints so it is
// re-collected and retried on the next poll. Other events are
// unaffected.
deliveryErr.addAll(failures)
continue
}
if err := runCheckpoints(ctx, event.checkpoints); err != nil {
return err
}
}
if err := runCheckpoints(ctx, baseline); err != nil {
return err
}
if deliveryErr.count > 0 {
return deliveryErr
}
return nil
}
for _, save := range checkpoints {
if err := save(ctx); err != nil {
// Seeding, or nothing to deliver: run baseline writes and flush any event
// checkpoints (e.g. stale pending markers carried over from a prior run).
if err := runCheckpoints(ctx, baseline); err != nil {
return err
}
for _, event := range events {
if err := runCheckpoints(ctx, event.checkpoints); err != nil {
return err
}
}
@@ -140,6 +162,15 @@ func (r *Runner) CheckOnce(ctx context.Context) error {
return nil
}
func runCheckpoints(ctx context.Context, checkpoints []checkpoint) error {
for _, save := range checkpoints {
if err := save(ctx); err != nil {
return err
}
}
return nil
}
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)
+62 -29
View File
@@ -9,7 +9,7 @@ import (
"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/mongostore"
"github.com/tiennm99/openai-status-bot/internal/telegram"
)
@@ -30,14 +30,14 @@ type fakePollerStore struct {
initialized bool
componentStatuses map[string]string
incidentVersions map[string]string
subscribers []redisstore.Subscriber
subscribers []mongostore.Subscriber
delivered map[string]map[string]bool
hasDeliveredErr error
markDeliveredErr error
removeErr error
savedComponents map[string]string
markedVersions map[string]string
pendingComponents map[string]redisstore.PendingComponentEvent
pendingComponents map[string]mongostore.PendingComponentEvent
removed []string
}
@@ -48,7 +48,7 @@ func newFakePollerStore() *fakePollerStore {
delivered: map[string]map[string]bool{},
savedComponents: map[string]string{},
markedVersions: map[string]string{},
pendingComponents: map[string]redisstore.PendingComponentEvent{},
pendingComponents: map[string]mongostore.PendingComponentEvent{},
}
}
@@ -77,7 +77,7 @@ func (f *fakePollerStore) IsInitialized(context.Context) (bool, error) {
return f.initialized, nil
}
func (f *fakePollerStore) ListSubscribers(context.Context) ([]redisstore.Subscriber, error) {
func (f *fakePollerStore) ListSubscribers(context.Context) ([]mongostore.Subscriber, error) {
return f.subscribers, nil
}
@@ -98,15 +98,15 @@ func (f *fakePollerStore) MarkIncidentUpdateVersion(_ context.Context, updateID,
return nil
}
func (f *fakePollerStore) PendingComponentEvents(context.Context) (map[string]redisstore.PendingComponentEvent, error) {
result := make(map[string]redisstore.PendingComponentEvent, len(f.pendingComponents))
func (f *fakePollerStore) PendingComponentEvents(context.Context) (map[string]mongostore.PendingComponentEvent, error) {
result := make(map[string]mongostore.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 {
func (f *fakePollerStore) SavePendingComponentEvent(_ context.Context, event mongostore.PendingComponentEvent) error {
f.pendingComponents[event.ComponentID] = event
return nil
}
@@ -116,7 +116,7 @@ func (f *fakePollerStore) RemovePendingComponentEvent(_ context.Context, compone
return nil
}
func (f *fakePollerStore) RemoveSubscriber(_ context.Context, sub redisstore.Subscriber) error {
func (f *fakePollerStore) RemoveSubscriber(_ context.Context, sub mongostore.Subscriber) error {
if f.removeErr != nil {
return f.removeErr
}
@@ -142,7 +142,7 @@ type fakeNotifier struct {
messages []string
}
func (f *fakeNotifier) SendMessage(_ context.Context, sub redisstore.Subscriber, text string) error {
func (f *fakeNotifier) SendMessage(_ context.Context, sub mongostore.Subscriber, text string) error {
f.sends = append(f.sends, sub.Key())
f.messages = append(f.messages, text)
if queued := f.errorQueue[sub.Key()]; len(queued) > 0 {
@@ -160,7 +160,7 @@ func TestCheckOnceDoesNotCheckpointWhenSendFails(t *testing.T) {
store := newFakePollerStore()
store.initialized = true
store.componentStatuses["c1"] = "operational"
store.subscribers = []redisstore.Subscriber{redisstore.NewSubscriber(1, nil)}
store.subscribers = []mongostore.Subscriber{mongostore.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())
@@ -176,7 +176,7 @@ 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)}
store.subscribers = []mongostore.Subscriber{mongostore.NewSubscriber(1, nil), mongostore.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())
@@ -204,7 +204,7 @@ func TestCheckOnceContinuesFanOutAfterRetryableSendFailures(t *testing.T) {
store.initialized = true
store.componentStatuses["c1"] = "operational"
store.componentStatuses["c2"] = "operational"
store.subscribers = []redisstore.Subscriber{redisstore.NewSubscriber(1, nil), redisstore.NewSubscriber(2, nil)}
store.subscribers = []mongostore.Subscriber{mongostore.NewSubscriber(1, nil), mongostore.NewSubscriber(2, nil)}
notifier := &fakeNotifier{errors: map[string]error{"1": errors.New("rate limit")}}
runner := NewRunner(fakeStatusClient{summary: openai.Summary{Components: []openai.Component{
{ID: "c1", Name: "API", Status: "degraded_performance", UpdatedAt: "2026-01-01T00:00:00Z"},
@@ -227,15 +227,15 @@ func TestCheckOnceStopsOnDeliveryStateReadError(t *testing.T) {
store.initialized = true
store.componentStatuses["c1"] = "operational"
store.componentStatuses["c2"] = "operational"
store.subscribers = []redisstore.Subscriber{redisstore.NewSubscriber(1, nil)}
store.hasDeliveredErr = errors.New("redis read failed")
store.subscribers = []mongostore.Subscriber{mongostore.NewSubscriber(1, nil)}
store.hasDeliveredErr = errors.New("store read failed")
notifier := &fakeNotifier{}
runner := NewRunner(fakeStatusClient{summary: openai.Summary{Components: []openai.Component{
{ID: "c1", Name: "API", Status: "degraded_performance", UpdatedAt: "2026-01-01T00:00:00Z"},
{ID: "c2", Name: "ChatGPT", Status: "partial_outage", UpdatedAt: "2026-01-01T00:01:00Z"},
}}}, store, notifier, time.Minute, slog.Default())
if err := runner.CheckOnce(context.Background()); err == nil || !strings.Contains(err.Error(), "redis read failed") {
if err := runner.CheckOnce(context.Background()); err == nil || !strings.Contains(err.Error(), "store read failed") {
t.Fatalf("CheckOnce error = %v, want delivery state read error", err)
}
if len(notifier.sends) != 0 {
@@ -248,15 +248,15 @@ func TestCheckOnceStopsWhenTerminalSubscriberRemovalFails(t *testing.T) {
store.initialized = true
store.componentStatuses["c1"] = "operational"
store.componentStatuses["c2"] = "operational"
store.subscribers = []redisstore.Subscriber{redisstore.NewSubscriber(1, nil)}
store.removeErr = errors.New("redis remove failed")
store.subscribers = []mongostore.Subscriber{mongostore.NewSubscriber(1, nil)}
store.removeErr = errors.New("store remove failed")
notifier := &fakeNotifier{errors: map[string]error{"1": &telegram.APIError{StatusCode: 200, ErrorCode: 403, Description: "Forbidden: bot was blocked by the user"}}}
runner := NewRunner(fakeStatusClient{summary: openai.Summary{Components: []openai.Component{
{ID: "c1", Name: "API", Status: "degraded_performance", UpdatedAt: "2026-01-01T00:00:00Z"},
{ID: "c2", Name: "ChatGPT", Status: "partial_outage", UpdatedAt: "2026-01-01T00:01:00Z"},
}}}, store, notifier, time.Minute, slog.Default())
if err := runner.CheckOnce(context.Background()); err == nil || !strings.Contains(err.Error(), "redis remove failed") {
if err := runner.CheckOnce(context.Background()); err == nil || !strings.Contains(err.Error(), "store remove failed") {
t.Fatalf("CheckOnce error = %v, want subscriber remove error", err)
}
if got, want := notifier.sends, []string{"1"}; !equalStrings(got, want) {
@@ -269,7 +269,7 @@ func TestCheckOnceSkipsLaterEventsForSubscriberAfterRetryableFailure(t *testing.
store.initialized = true
store.componentStatuses["c1"] = "operational"
store.componentStatuses["c2"] = "operational"
store.subscribers = []redisstore.Subscriber{redisstore.NewSubscriber(1, nil), redisstore.NewSubscriber(2, nil)}
store.subscribers = []mongostore.Subscriber{mongostore.NewSubscriber(1, nil), mongostore.NewSubscriber(2, nil)}
notifier := &fakeNotifier{errorQueue: map[string][]error{"1": {errors.New("rate limit")}}}
runner := NewRunner(fakeStatusClient{summary: openai.Summary{Components: []openai.Component{
{ID: "c1", Name: "API", Status: "degraded_performance", UpdatedAt: "2026-01-01T00:00:00Z"},
@@ -288,7 +288,7 @@ 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)}
store.subscribers = []mongostore.Subscriber{mongostore.NewSubscriber(1, nil), mongostore.NewSubscriber(2, nil)}
notifier := &fakeNotifier{errors: map[string]error{"2": &telegram.APIError{StatusCode: 200, ErrorCode: 403, Description: "Forbidden: bot was blocked by the user"}}}
runner := NewRunner(fakeStatusClient{summary: summaryWithComponent("c1", "API", "degraded_performance")}, store, notifier, time.Minute, slog.Default())
@@ -308,7 +308,7 @@ func TestIncidentUpdateVersionEditSendsAgain(t *testing.T) {
store := newFakePollerStore()
store.initialized = true
store.incidentVersions["u1"] = IncidentUpdateVersion(update)
store.subscribers = []redisstore.Subscriber{redisstore.NewSubscriber(1, nil)}
store.subscribers = []mongostore.Subscriber{mongostore.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())
@@ -328,7 +328,7 @@ 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)}
store.subscribers = []mongostore.Subscriber{mongostore.NewSubscriber(1, nil), mongostore.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())
@@ -371,7 +371,7 @@ func TestPendingComponentLabelDoesNotCountCurrentComponentAsDuplicate(t *testing
store := newFakePollerStore()
store.initialized = true
store.componentStatuses["c1"] = "operational"
store.pendingComponents["c1"] = redisstore.PendingComponentEvent{
store.pendingComponents["c1"] = mongostore.PendingComponentEvent{
ComponentID: "c1",
ComponentName: "API",
Status: "degraded_performance",
@@ -379,7 +379,7 @@ func TestPendingComponentLabelDoesNotCountCurrentComponentAsDuplicate(t *testing
PreviousStatus: "operational",
DeliveryKey: "component:c1:degraded_performance:2026-01-01T00:00:00Z",
}
store.subscribers = []redisstore.Subscriber{redisstore.NewSubscriber(1, nil)}
store.subscribers = []mongostore.Subscriber{mongostore.NewSubscriber(1, nil)}
notifier := &fakeNotifier{}
runner := NewRunner(fakeStatusClient{summary: summaryWithComponent("c1", "API", "degraded_performance")}, store, notifier, time.Minute, slog.Default())
@@ -395,7 +395,7 @@ func TestPendingComponentLabelDoesNotCountCurrentComponentAsDuplicate(t *testing
}
func TestPendingComponentDuplicateLabelsIncludeCurrentRename(t *testing.T) {
pending := map[string]redisstore.PendingComponentEvent{
pending := map[string]mongostore.PendingComponentEvent{
"c1": {
ComponentID: "c1",
ComponentName: "Old API",
@@ -416,12 +416,45 @@ func TestPendingComponentDuplicateLabelsIncludeCurrentRename(t *testing.T) {
}
}
func TestCheckOnceCheckpointsDeliveredEventWhenSiblingEventFails(t *testing.T) {
// Regression: a fully-delivered event must checkpoint even when another
// event in the same poll fails to deliver. Previously any delivery failure
// aborted checkpoints for the whole batch, re-emitting delivered events.
store := newFakePollerStore()
store.initialized = true
store.componentStatuses["c1"] = "operational"
store.componentStatuses["c2"] = "operational"
store.subscribers = []mongostore.Subscriber{mongostore.NewSubscriber(1, nil)}
// c1 (sorts first) succeeds; c2 fails for the same subscriber.
notifier := &fakeNotifier{errorQueue: map[string][]error{"1": {nil, errors.New("rate limit")}}}
runner := NewRunner(fakeStatusClient{summary: openai.Summary{Components: []openai.Component{
{ID: "c1", Name: "API", Status: "degraded_performance", UpdatedAt: "2026-01-01T00:00:00Z"},
{ID: "c2", Name: "ChatGPT", Status: "partial_outage", UpdatedAt: "2026-01-01T00:01:00Z"},
}}}, store, notifier, time.Minute, slog.Default())
if err := runner.CheckOnce(context.Background()); err == nil {
t.Fatal("expected delivery error from c2")
}
if got := store.savedComponents["c1"]; got != "degraded_performance" {
t.Fatalf("c1 checkpoint = %q, want delivered event checkpointed", got)
}
if got, ok := store.savedComponents["c2"]; ok {
t.Fatalf("c2 checkpoint = %q, want failed event NOT checkpointed", got)
}
if _, ok := store.pendingComponents["c2"]; !ok {
t.Fatal("c2 pending marker should persist for retry")
}
if _, ok := store.pendingComponents["c1"]; ok {
t.Fatal("c1 pending marker should be cleared after delivery")
}
}
func TestCheckOnceContinuesWhenMarkDeliveredFailsAfterSend(t *testing.T) {
store := newFakePollerStore()
store.initialized = true
store.componentStatuses["c1"] = "operational"
store.subscribers = []redisstore.Subscriber{redisstore.NewSubscriber(1, nil)}
store.markDeliveredErr = errors.New("redis write failed")
store.subscribers = []mongostore.Subscriber{mongostore.NewSubscriber(1, nil)}
store.markDeliveredErr = errors.New("store write failed")
notifier := &fakeNotifier{}
runner := NewRunner(fakeStatusClient{summary: summaryWithComponent("c1", "API", "degraded_performance")}, store, notifier, time.Minute, slog.Default())
@@ -451,7 +484,7 @@ func TestCheckOnceDoesNotRemarkSeenIncidentVersion(t *testing.T) {
store := newFakePollerStore()
store.initialized = true
store.incidentVersions["u1"] = IncidentUpdateVersion(update)
store.subscribers = []redisstore.Subscriber{redisstore.NewSubscriber(1, nil)}
store.subscribers = []mongostore.Subscriber{mongostore.NewSubscriber(1, nil)}
notifier := &fakeNotifier{}
runner := NewRunner(fakeStatusClient{incidents: openai.IncidentsResponse{Incidents: []openai.Incident{{ID: "i1", Name: "incident", Impact: "minor", IncidentUpdates: []openai.IncidentUpdate{update}}}}}, store, notifier, time.Minute, slog.Default())
-171
View File
@@ -1,171 +0,0 @@
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 {
event, err := decodePendingComponentEvent(componentID, value)
if err != nil {
return nil, err
}
events[componentID] = event
}
return events, nil
}
func decodePendingComponentEvent(componentID, value string) (PendingComponentEvent, error) {
var event PendingComponentEvent
if err := json.Unmarshal([]byte(value), &event); err != nil {
return PendingComponentEvent{}, fmt.Errorf("decode pending component event %q: %w", componentID, err)
}
if event.ComponentID == "" {
event.ComponentID = componentID
}
return event, 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
}
legacySeen, err := s.HasIncidentUpdate(ctx, updateID)
if err != nil {
return false, err
}
if legacySeen {
if err := s.client.HSet(ctx, incidentVersionsKey, updateID, version).Err(); err != nil {
return false, err
}
return true, nil
}
return false, nil
}
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
}
// DeliveredSubscribers returns the set of subscriber keys already notified for
// eventKey. Callers fan a single event out to every subscriber, so one SMEMBERS
// plus in-memory lookups replaces one SISMEMBER per subscriber.
func (s *Store) DeliveredSubscribers(ctx context.Context, eventKey string) (map[string]bool, error) {
members, err := s.client.SMembers(ctx, deliveryStateKey(eventKey)).Result()
if err != nil {
return nil, err
}
delivered := make(map[string]bool, len(members))
for _, member := range members {
delivered[member] = true
}
return delivered, nil
}
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 {
if deleteErr := s.client.Del(ctx, telegramOffsetKey).Err(); deleteErr != nil {
return 0, fmt.Errorf("clear invalid telegram offset: %w", deleteErr)
}
return 0, nil
}
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[:])
}
-27
View File
@@ -1,27 +0,0 @@
package redisstore
import "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 (
SubscriptionTypeIncident = "incident"
SubscriptionTypeComponent = "component"
)
type Store struct {
client redis.UniversalClient
}
func New(client redis.UniversalClient) *Store {
return &Store{client: client}
}
-243
View File
@@ -1,243 +0,0 @@
package redisstore
import (
"context"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
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")
}
}
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")
}
}
func TestDecodePendingComponentEventBackfillsComponentID(t *testing.T) {
event, err := decodePendingComponentEvent("c1", `{"component_name":"API","status":"operational"}`)
if err != nil {
t.Fatalf("decodePendingComponentEvent returned error: %v", err)
}
if event.ComponentID != "c1" {
t.Fatalf("ComponentID = %q, want c1", event.ComponentID)
}
}
func TestDecodePendingComponentEventRejectsCorruptedPayload(t *testing.T) {
if _, err := decodePendingComponentEvent("c1", `{bad json`); err == nil {
t.Fatal("expected corrupted pending event error")
}
}
func TestDecodeSubscriberSettingsNormalizesValues(t *testing.T) {
settings, err := decodeSubscriberSettings("123", `{"types":["incident","component","incident"],"components":[" api ","API",""]}`)
if err != nil {
t.Fatalf("decodeSubscriberSettings returned error: %v", err)
}
if len(settings.Types) != 2 || settings.Types[0] != SubscriptionTypeIncident || settings.Types[1] != SubscriptionTypeComponent {
t.Fatalf("Types = %v", settings.Types)
}
if len(settings.Components) != 1 || settings.Components[0] != "api" {
t.Fatalf("Components = %v", settings.Components)
}
}
func TestDecodeSubscriberSettingsRejectsCorruptedPayload(t *testing.T) {
if _, err := decodeSubscriberSettings("123", `{bad json`); err == nil {
t.Fatal("expected corrupted subscriber settings error")
}
}
func TestListSubscribersRecoversCorruptSettingsWithoutBlockingOthers(t *testing.T) {
ctx := context.Background()
store, client := newTestStore(t)
good := NewSubscriber(1, nil)
corrupt := NewSubscriber(2, nil)
if err := client.SAdd(ctx, subscribersKey, good.Key(), corrupt.Key()).Err(); err != nil {
t.Fatalf("SAdd subscribers: %v", err)
}
if err := client.HSet(ctx, subscriberSettingsKey, good.Key(), `{"types":["incident"],"components":[]}`).Err(); err != nil {
t.Fatalf("HSet good settings: %v", err)
}
if err := client.HSet(ctx, subscriberSettingsKey, corrupt.Key(), `{bad json`).Err(); err != nil {
t.Fatalf("HSet corrupt settings: %v", err)
}
subscribers, err := store.ListSubscribers(ctx)
if err != nil {
t.Fatalf("ListSubscribers returned error: %v", err)
}
byKey := map[string]Subscriber{}
for _, subscriber := range subscribers {
byKey[subscriber.Key()] = subscriber
}
if len(byKey) != 2 {
t.Fatalf("subscribers = %v, want two subscribers", subscribers)
}
if got := byKey[good.Key()].Types; len(got) != 1 || got[0] != SubscriptionTypeIncident {
t.Fatalf("good Types = %v, want [incident]", got)
}
if got := byKey[corrupt.Key()].Types; len(got) != 2 {
t.Fatalf("corrupt Types = %v, want default types", got)
}
exists, err := client.HExists(ctx, subscriberSettingsKey, corrupt.Key()).Result()
if err != nil {
t.Fatalf("HExists corrupt settings: %v", err)
}
if exists {
t.Fatal("corrupt settings field still exists")
}
}
func TestUpdateSubscriberSettingsSavesTypesAndComponentsTogether(t *testing.T) {
ctx := context.Background()
store, _ := newTestStore(t)
sub := NewSubscriber(1, nil)
if err := store.AddSubscriber(ctx, sub); err != nil {
t.Fatalf("AddSubscriber returned error: %v", err)
}
updated, err := store.UpdateSubscriberSettings(ctx, sub, []string{SubscriptionTypeComponent}, []string{"c-api"})
if err != nil {
t.Fatalf("UpdateSubscriberSettings returned error: %v", err)
}
if !updated {
t.Fatal("UpdateSubscriberSettings updated = false, want true")
}
got, exists, err := store.GetSubscriber(ctx, sub)
if err != nil {
t.Fatalf("GetSubscriber returned error: %v", err)
}
if !exists {
t.Fatal("subscriber missing after update")
}
if len(got.Types) != 1 || got.Types[0] != SubscriptionTypeComponent {
t.Fatalf("Types = %v, want [component]", got.Types)
}
if len(got.Components) != 1 || got.Components[0] != "c-api" {
t.Fatalf("Components = %v, want [c-api]", got.Components)
}
}
func TestUpdateSubscriberSettingsReturnsFalseForMissingSubscriber(t *testing.T) {
ctx := context.Background()
store, client := newTestStore(t)
sub := NewSubscriber(1, nil)
updated, err := store.UpdateSubscriberSettings(ctx, sub, []string{SubscriptionTypeComponent}, []string{"c-api"})
if err != nil {
t.Fatalf("UpdateSubscriberSettings returned error: %v", err)
}
if updated {
t.Fatal("UpdateSubscriberSettings updated = true, want false")
}
exists, err := client.HExists(ctx, subscriberSettingsKey, sub.Key()).Result()
if err != nil {
t.Fatalf("HExists settings: %v", err)
}
if exists {
t.Fatal("settings were written for missing subscriber")
}
}
func TestHasIncidentUpdateVersionMigratesLegacySeenUpdate(t *testing.T) {
ctx := context.Background()
store, client := newTestStore(t)
if err := client.SAdd(ctx, incidentUpdatesKey, "u1").Err(); err != nil {
t.Fatalf("SAdd legacy incident update: %v", err)
}
seen, err := store.HasIncidentUpdateVersion(ctx, "u1", "v1")
if err != nil {
t.Fatalf("HasIncidentUpdateVersion returned error: %v", err)
}
if !seen {
t.Fatal("legacy incident update was not treated as seen")
}
stored, err := client.HGet(ctx, incidentVersionsKey, "u1").Result()
if err != nil {
t.Fatalf("HGet migrated incident version: %v", err)
}
if stored != "v1" {
t.Fatalf("stored version = %q, want v1", stored)
}
seen, err = store.HasIncidentUpdateVersion(ctx, "u1", "v2")
if err != nil {
t.Fatalf("HasIncidentUpdateVersion changed version returned error: %v", err)
}
if seen {
t.Fatal("changed incident update version should not be treated as seen")
}
}
func TestTelegramOffsetClearsInvalidStoredValue(t *testing.T) {
ctx := context.Background()
store, client := newTestStore(t)
if err := client.Set(ctx, telegramOffsetKey, "not-an-offset", 0).Err(); err != nil {
t.Fatalf("Set invalid telegram offset: %v", err)
}
offset, err := store.TelegramOffset(ctx)
if err != nil {
t.Fatalf("TelegramOffset returned error: %v", err)
}
if offset != 0 {
t.Fatalf("offset = %d, want 0", offset)
}
exists, err := client.Exists(ctx, telegramOffsetKey).Result()
if err != nil {
t.Fatalf("Exists telegram offset: %v", err)
}
if exists != 0 {
t.Fatal("invalid telegram offset key was not cleared")
}
}
func newTestStore(t *testing.T) (*Store, *redis.Client) {
t.Helper()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() {
_ = client.Close()
})
return New(client), client
}
-181
View File
@@ -1,181 +0,0 @@
package redisstore
import (
"context"
"fmt"
"strconv"
"strings"
)
type Subscriber struct {
ChatID int64
ThreadID *int
Types []string
Components []string
}
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
}
// Fetch every subscriber's settings in one HGETALL instead of one HGET per
// key. The settings hash mirrors the subscribers set, so its size is the
// subscriber count rather than unbounded history.
settingsByKey, err := s.client.HGetAll(ctx, subscriberSettingsKey).Result()
if err != nil {
return nil, err
}
subscribers := make([]Subscriber, 0, len(keys))
for _, key := range keys {
sub, err := ParseSubscriberKey(key)
if err != nil {
if removeErr := s.client.SRem(ctx, subscribersKey, key).Err(); removeErr != nil {
return nil, fmt.Errorf("remove malformed subscriber key %q: %w", key, removeErr)
}
return nil, fmt.Errorf("malformed subscriber key %q: %w", key, err)
}
value, present := settingsByKey[key]
settings, err := s.resolveSubscriberSettings(ctx, key, value, present)
if err != nil {
return nil, err
}
sub.Types = settings.Types
sub.Components = settings.Components
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 s.saveExistingSubscriberSettings(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 s.saveExistingSubscriberSettings(ctx, key, settings)
}
func (s *Store) UpdateSubscriberSettings(ctx context.Context, sub Subscriber, types, components []string) (bool, error) {
key := sub.Key()
return s.saveExistingSubscriberSettings(ctx, key, subscriberSettings{
Types: types,
Components: components,
})
}
func (s *Store) loadSubscriber(ctx context.Context, key string) (Subscriber, bool, error) {
sub, err := ParseSubscriberKey(key)
if err != nil {
if removeErr := s.client.SRem(ctx, subscribersKey, key).Err(); removeErr != nil {
return Subscriber{}, false, fmt.Errorf("remove malformed subscriber key %q: %w", key, removeErr)
}
return Subscriber{}, false, fmt.Errorf("malformed subscriber key %q: %w", key, err)
}
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
}
@@ -1,99 +0,0 @@
package redisstore
import (
"context"
"encoding/json"
"fmt"
"github.com/redis/go-redis/v9"
)
type subscriberSettings struct {
Types []string `json:"types"`
Components []string `json:"components"`
}
var updateSubscriberSettingsScript = redis.NewScript(`
if redis.call("SISMEMBER", KEYS[1], ARGV[1]) == 0 then
return 0
end
redis.call("HSET", KEYS[2], ARGV[1], ARGV[2])
return 1
`)
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 s.resolveSubscriberSettings(ctx, key, "", false)
}
if err != nil {
return subscriberSettings{}, err
}
return s.resolveSubscriberSettings(ctx, key, value, true)
}
// resolveSubscriberSettings turns a raw settings hash value into decoded
// settings. It lets callers that already hold the value (e.g. a batched
// HGETALL in ListSubscribers) reuse the same decode/default/self-heal logic as
// the single-key HGET path. Absent or corrupt values fall back to defaults,
// dropping the corrupt field so it heals on next write.
func (s *Store) resolveSubscriberSettings(ctx context.Context, key, value string, present bool) (subscriberSettings, error) {
if !present {
return defaultSubscriberSettings(), nil
}
settings, err := decodeSubscriberSettings(key, value)
if err == nil {
return settings, nil
}
if removeErr := s.client.HDel(ctx, subscriberSettingsKey, key).Err(); removeErr != nil {
return subscriberSettings{}, fmt.Errorf("clear corrupt subscriber settings %q: %w", key, removeErr)
}
return defaultSubscriberSettings(), nil
}
func decodeSubscriberSettings(key, value string) (subscriberSettings, error) {
var settings subscriberSettings
if err := json.Unmarshal([]byte(value), &settings); err != nil {
return subscriberSettings{}, fmt.Errorf("decode subscriber settings %q: %w", key, err)
}
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()
}
func (s *Store) saveExistingSubscriberSettings(ctx context.Context, key string, settings subscriberSettings) (bool, error) {
settings.Types = normalizeTypes(settings.Types)
settings.Components = normalizeComponents(settings.Components)
payload, err := json.Marshal(settings)
if err != nil {
return false, err
}
updated, err := updateSubscriberSettingsScript.Run(ctx, s.client, []string{subscribersKey, subscriberSettingsKey}, key, string(payload)).Int()
if err != nil {
return false, err
}
return updated == 1, nil
}
func defaultSubscriberSettings() subscriberSettings {
return subscriberSettings{Types: DefaultSubscriptionTypes(), Components: []string{}}
}
+76 -138
View File
@@ -1,22 +1,26 @@
package telegram
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/tiennm99/openai-status-bot/internal/redisstore"
tgbot "github.com/go-telegram/bot"
tgmodels "github.com/go-telegram/bot/models"
"github.com/tiennm99/openai-status-bot/internal/mongostore"
)
type Client struct {
baseURL string
httpClient *http.Client
requestTimeout time.Duration
type messageSender interface {
SendMessage(ctx context.Context, params *tgbot.SendMessageParams) (*tgmodels.Message, error)
}
type Sender struct {
bot messageSender
token string
timeout time.Duration
}
type APIError struct {
@@ -32,6 +36,71 @@ func (e *APIError) Error() string {
return fmt.Sprintf("telegram API HTTP %d: %s", e.StatusCode, e.Description)
}
func NewSender(bot messageSender, token string, timeout time.Duration) *Sender {
return &Sender{bot: bot, token: token, timeout: timeout}
}
func (s *Sender) SendMessage(ctx context.Context, sub mongostore.Subscriber, text string) error {
return s.SendText(ctx, sub.ChatID, sub.ThreadID, text)
}
func (s *Sender) SendText(ctx context.Context, chatID int64, threadID *int, text string) error {
if s == nil || s.bot == nil {
return errors.New("telegram sender is not configured")
}
disablePreview := true
params := &tgbot.SendMessageParams{
ChatID: chatID,
Text: text,
ParseMode: tgmodels.ParseModeHTML,
LinkPreviewOptions: &tgmodels.LinkPreviewOptions{IsDisabled: &disablePreview},
}
if threadID != nil {
params.MessageThreadID = *threadID
}
requestCtx := ctx
if s.timeout > 0 {
var cancel context.CancelFunc
requestCtx, cancel = context.WithTimeout(ctx, s.timeout)
defer cancel()
}
_, err := s.bot.SendMessage(requestCtx, params)
return s.mapSendError(err)
}
func (s *Sender) mapSendError(err error) error {
if err == nil {
return nil
}
description := s.redactErrorText(err)
switch {
case errors.Is(err, tgbot.ErrorForbidden):
return &APIError{StatusCode: http.StatusOK, ErrorCode: http.StatusForbidden, Description: description}
case errors.Is(err, tgbot.ErrorBadRequest):
return &APIError{StatusCode: http.StatusOK, ErrorCode: http.StatusBadRequest, Description: description}
default:
if description != err.Error() {
return errors.New(description)
}
return err
}
}
func (s *Sender) redactErrorText(err error) string {
if err == nil {
return ""
}
msg := err.Error()
if s == nil || s.token == "" || !strings.Contains(msg, s.token) {
return msg
}
return strings.ReplaceAll(msg, s.token, "<redacted>")
}
func IsTerminalSendError(err error) bool {
var apiErr *APIError
if !errors.As(err, &apiErr) {
@@ -62,134 +131,3 @@ func IsTerminalSendError(err error) bool {
}
return false
}
func NewClient(token string, timeout time.Duration) *Client {
return &Client{
baseURL: "https://api.telegram.org/bot" + token,
httpClient: &http.Client{},
requestTimeout: timeout,
}
}
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, c.requestTimeout)
}
func (c *Client) GetMe(ctx context.Context) (User, error) {
var user User
if err := c.postJSON(ctx, "/getMe", map[string]any{}, &user, c.requestTimeout); err != nil {
return User{}, err
}
return user, nil
}
func (c *Client) SetMyCommands(ctx context.Context, commands []BotCommand) error {
payload := map[string]any{"commands": commands}
var result json.RawMessage
return c.postJSON(ctx, "/setMyCommands", payload, &result, c.requestTimeout)
}
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, c.longPollRequestTimeout(timeoutSeconds)); 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,
"parse_mode": "HTML",
"disable_web_page_preview": true,
}
if threadID != nil {
payload["message_thread_id"] = *threadID
}
var result json.RawMessage
return c.postJSON(ctx, "/sendMessage", payload, &result, c.requestTimeout)
}
func (c *Client) longPollRequestTimeout(timeoutSeconds int) time.Duration {
telegramWait := time.Duration(timeoutSeconds) * time.Second
if telegramWait < 0 {
telegramWait = 0
}
if c.requestTimeout <= 0 {
return telegramWait
}
return telegramWait + c.requestTimeout
}
func (c *Client) postJSON(ctx context.Context, path string, payload any, target any, requestTimeout time.Duration) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
requestCtx := ctx
if requestTimeout > 0 {
var cancel context.CancelFunc
requestCtx, cancel = context.WithTimeout(ctx, requestTimeout)
defer cancel()
}
req, err := http.NewRequestWithContext(requestCtx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
httpClient := c.httpClient
if httpClient == nil {
httpClient = http.DefaultClient
}
res, err := 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"`
ErrorCode int `json:"error_code"`
}
if err := json.NewDecoder(res.Body).Decode(&envelope); err != nil {
if res.StatusCode < 200 || res.StatusCode >= 300 {
return &APIError{StatusCode: res.StatusCode, Description: res.Status}
}
return fmt.Errorf("decode telegram response: %w", err)
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
description := envelope.Description
if description == "" {
description = res.Status
}
return &APIError{StatusCode: res.StatusCode, ErrorCode: envelope.ErrorCode, Description: description}
}
if !envelope.OK {
return &APIError{StatusCode: res.StatusCode, ErrorCode: envelope.ErrorCode, Description: envelope.Description}
}
if target != nil {
if err := json.Unmarshal(envelope.Result, target); err != nil {
return fmt.Errorf("decode telegram result: %w", err)
}
}
return nil
}
+64 -119
View File
@@ -2,181 +2,126 @@ package telegram
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
tgbot "github.com/go-telegram/bot"
tgmodels "github.com/go-telegram/bot/models"
)
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()
type fakeFrameworkSender struct {
params *tgbot.SendMessageParams
err error
}
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 (f *fakeFrameworkSender) SendMessage(_ context.Context, params *tgbot.SendMessageParams) (*tgmodels.Message, error) {
f.params = params
return &tgmodels.Message{}, f.err
}
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()
framework := &fakeFrameworkSender{}
sender := NewSender(framework, "token", 0)
client := &Client{baseURL: server.URL, httpClient: server.Client()}
if err := client.SendText(context.Background(), 123, nil, "<b>hello</b>"); err != nil {
if err := sender.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 framework.params == nil {
t.Fatal("SendMessage params were not captured")
}
if payload["disable_web_page_preview"] != true {
t.Fatalf("disable_web_page_preview = %v", payload["disable_web_page_preview"])
if framework.params.ChatID != int64(123) {
t.Fatalf("ChatID = %v, want 123", framework.params.ChatID)
}
if framework.params.ParseMode != tgmodels.ParseModeHTML {
t.Fatalf("ParseMode = %v, want HTML", framework.params.ParseMode)
}
if framework.params.LinkPreviewOptions == nil || framework.params.LinkPreviewOptions.IsDisabled == nil || !*framework.params.LinkPreviewOptions.IsDisabled {
t.Fatalf("LinkPreviewOptions = %+v, want disabled preview", framework.params.LinkPreviewOptions)
}
}
func TestSetMyCommandsRegistersCommands(t *testing.T) {
var gotPath string
var payload struct {
Commands []BotCommand `json:"commands"`
}
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()
func TestSendTextPassesThreadID(t *testing.T) {
framework := &fakeFrameworkSender{}
sender := NewSender(framework, "token", 0)
threadID := 42
client := &Client{baseURL: server.URL, httpClient: server.Client()}
commands := []BotCommand{
{Command: "start", Description: "Subscribe"},
{Command: "status", Description: "Show status"},
if err := sender.SendText(context.Background(), 123, &threadID, "hello"); err != nil {
t.Fatalf("SendText returned error: %v", err)
}
if err := client.SetMyCommands(context.Background(), commands); err != nil {
t.Fatalf("SetMyCommands returned error: %v", err)
}
if gotPath != "/setMyCommands" {
t.Fatalf("path = %s", gotPath)
}
if len(payload.Commands) != len(commands) {
t.Fatalf("commands = %v, want %v", payload.Commands, commands)
}
for i := range commands {
if payload.Commands[i] != commands[i] {
t.Fatalf("commands[%d] = %+v, want %+v", i, payload.Commands[i], commands[i])
}
if framework.params.MessageThreadID != threadID {
t.Fatalf("MessageThreadID = %d, want %d", framework.params.MessageThreadID, threadID)
}
}
func TestIsTerminalSendError(t *testing.T) {
terminal := &APIError{StatusCode: 200, ErrorCode: 403, Description: "Forbidden: bot was blocked by the user"}
terminal := &APIError{StatusCode: http.StatusOK, ErrorCode: http.StatusForbidden, Description: "Forbidden: bot was blocked by the user"}
if !IsTerminalSendError(terminal) {
t.Fatal("403 blocked should be terminal")
}
chatNotFound := &APIError{StatusCode: 200, ErrorCode: 400, Description: "Bad Request: chat not found"}
chatNotFound := &APIError{StatusCode: http.StatusOK, ErrorCode: http.StatusBadRequest, Description: "Bad Request: chat not found"}
if !IsTerminalSendError(chatNotFound) {
t.Fatal("chat not found should be terminal")
}
parseError := &APIError{StatusCode: 200, ErrorCode: 400, Description: "Bad Request: can't parse entities"}
parseError := &APIError{StatusCode: http.StatusOK, ErrorCode: http.StatusBadRequest, Description: "Bad Request: can't parse entities"}
if IsTerminalSendError(parseError) {
t.Fatal("parse errors should not remove subscribers")
}
legacyForbidden := &APIError{StatusCode: 403, Description: "Forbidden"}
legacyForbidden := &APIError{StatusCode: http.StatusForbidden, Description: "Forbidden"}
if !IsTerminalSendError(legacyForbidden) {
t.Fatal("status-code-only 403 should remain terminal")
}
}
func TestSendTextCapturesTelegramErrorCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":false,"error_code":403,"description":"Forbidden: bot was kicked"}`))
}))
defer server.Close()
func TestSendTextMapsFrameworkForbiddenToTerminalAPIError(t *testing.T) {
framework := &fakeFrameworkSender{err: fmt.Errorf("%w, Forbidden: bot was kicked", tgbot.ErrorForbidden)}
sender := NewSender(framework, "token", 0)
client := &Client{baseURL: server.URL, httpClient: server.Client()}
err := client.SendText(context.Background(), 123, nil, "hello")
err := sender.SendText(context.Background(), 123, nil, "hello")
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("SendText error = %v, want APIError", err)
}
if apiErr.StatusCode != http.StatusOK || apiErr.ErrorCode != http.StatusForbidden {
t.Fatalf("APIError = %+v", apiErr)
if apiErr.ErrorCode != http.StatusForbidden {
t.Fatalf("ErrorCode = %d, want %d", apiErr.ErrorCode, http.StatusForbidden)
}
if !IsTerminalSendError(err) {
t.Fatal("telegram error_code 403 should be terminal")
t.Fatal("framework forbidden error should be terminal")
}
}
func TestSendTextPreservesNonJSONHTTPErrorStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "gateway", http.StatusBadGateway)
}))
defer server.Close()
func TestSendTextMapsFrameworkBadRequestWithoutRemovingSubscriber(t *testing.T) {
framework := &fakeFrameworkSender{err: fmt.Errorf("%w, Bad Request: can't parse entities", tgbot.ErrorBadRequest)}
sender := NewSender(framework, "token", 0)
client := &Client{baseURL: server.URL, httpClient: server.Client()}
err := client.SendText(context.Background(), 123, nil, "hello")
err := sender.SendText(context.Background(), 123, nil, "hello")
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("SendText error = %v, want APIError", err)
}
if apiErr.StatusCode != http.StatusBadGateway {
t.Fatalf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusBadGateway)
if apiErr.ErrorCode != http.StatusBadRequest {
t.Fatalf("ErrorCode = %d, want %d", apiErr.ErrorCode, http.StatusBadRequest)
}
if IsTerminalSendError(err) {
t.Fatal("parse errors should not remove subscribers")
}
}
func TestSendTextRejectsNonSuccessHTTPStatusEvenWhenEnvelopeOK(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"ok":true,"result":{}}`))
}))
defer server.Close()
func TestTransportErrorRedactsBotToken(t *testing.T) {
const token = "123456:super-secret-token"
framework := &fakeFrameworkSender{err: errors.New("Post https://api.telegram.org/bot" + token + "/sendMessage: dial tcp")}
sender := NewSender(framework, token, 0)
client := &Client{baseURL: server.URL, httpClient: server.Client()}
err := client.SendText(context.Background(), 123, nil, "hello")
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("SendText error = %v, want APIError", err)
err := sender.SendText(context.Background(), 123, nil, "hello")
if err == nil {
t.Fatal("expected transport error, got nil")
}
if apiErr.StatusCode != http.StatusTooManyRequests {
t.Fatalf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusTooManyRequests)
if strings.Contains(err.Error(), token) {
t.Fatalf("error leaked bot token: %v", err)
}
}
func TestNewClientUsesConfiguredTimeoutForOrdinaryRequests(t *testing.T) {
client := NewClient("token", 10*time.Second)
if client.requestTimeout != 10*time.Second {
t.Fatalf("requestTimeout = %s, want 10s", client.requestTimeout)
}
if client.httpClient.Timeout != 0 {
t.Fatalf("httpClient.Timeout = %s, want context-scoped timeout", client.httpClient.Timeout)
}
if got := client.longPollRequestTimeout(50); got != time.Minute {
t.Fatalf("longPollRequestTimeout = %s, want 1m", got)
if !strings.Contains(err.Error(), "<redacted>") {
t.Fatalf("error missing redaction placeholder: %v", err)
}
}
-31
View File
@@ -1,31 +0,0 @@
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"`
}
type User struct {
ID int64 `json:"id"`
IsBot bool `json:"is_bot"`
FirstName string `json:"first_name"`
Username string `json:"username"`
}
type BotCommand struct {
Command string `json:"command"`
Description string `json:"description"`
}
@@ -0,0 +1,65 @@
---
phase: 1
title: "Suitability & Decision"
status: complete
effort: "research-only (no code)"
---
# Phase 1: Suitability & Decision
## Overview
Decide whether MongoDB is the right datastore for this bot before writing code. This phase is the answer to "is it suitable for our case." No code; output is the recorded verdict below.
## Workload facts (from codebase)
- State is tiny: one set of subscribers (count = number of Telegram chats), per-subscriber settings, ~dozens of component statuses, a handful of pending component events, incident-update version markers, one `initialized` flag, one telegram offset, and short-lived per-event delivery markers.
- Access pattern: one poll/minute (default `POLL_INTERVAL=1m`) + occasional command handling. No hot path, no high QPS, no large values.
- Already abstracted behind `poller.Store` / `bot.Store` interfaces (`internal/poller/poller.go:17`, `internal/bot/bot.go:25`).
- Redis-specific mechanisms in use: a Lua script for atomic check-then-set (`internal/redisstore/subscriber_settings.go:16`), `EXPIRE` for 7-day delivery dedup TTL (`internal/redisstore/checkpoint.go:137`), and `TxPipeline` for multi-key atomicity (RemoveSubscriber, MarkDelivered, MarkIncidentUpdateVersion).
## Redis → MongoDB capability mapping
| Redis usage | MongoDB equivalent | Notes |
|---|---|---|
| `subscribers` SET + `subscriber-settings` HASH | single `subscribers` collection, one doc per subscriber (`_id` = subscriber key, `types`/`components` fields) | **Collapses two keys into one doc**; eliminates the SRem+HDel `TxPipeline` in RemoveSubscriber |
| Lua check-then-set on settings | `UpdateOne(filter{_id}, $set)`, read `MatchedCount` | `MatchedCount==0` ⇒ not subscribed; replaces the Lua script |
| `component-statuses` HASH | `component_statuses` collection, doc per component | upsert via `UpdateOne(SetUpsert)` |
| `pending-component-events` HASH (JSON) | `pending_component_events` collection, doc per component | native BSON fields, no manual JSON marshal needed |
| `incident-update-versions` HASH + legacy `incident-updates` SET | `incident_update_versions` collection, doc per updateID | **legacy SET + migration fallback (`checkpoint.go:89-108`) dropped** — fresh DB, no legacy data |
| `event-delivery:<sha>` SET + `EXPIRE 7d` | `delivery` collection, doc per (eventKey, subscriber) + **TTL index** on `expiresAt` | `MarkDelivered` becomes one upsert (no SAdd+Expire pair); sha256 key-hashing dropped (Mongo handles arbitrary string values) |
| `initialized` flag, `telegram-offset` | `meta` collection, doc per key | trivial FindOne/upsert |
All driver specifics confirmed against MongoDB Go driver **v2** (`go.mongodb.org/mongo-driver/v2`, latest v2.7.0, 2026): `ApplyURI` parses `mongodb+srv://` Atlas URIs; TTL via `IndexModel` + `SetExpireAfterSeconds`; `UpdateOne`/`MatchedCount` and `SetUpsert(true)` cover conditional set and upsert.
## Verdict
**Suitable: YES. Justified: YES** (given the stated motivation — team already runs MongoDB, so dropping Redis consolidates ops onto one datastore).
Functionally, MongoDB does everything Redis does here, and the document model **simplifies** the code: three multi-key `TxPipeline` sequences collapse into single-document operations, the Lua script disappears, and the legacy incident-dedup migration path is deleted (no data to migrate). Latency and TTL-sweep granularity (~60s) are irrelevant at one poll/minute with a 7-day dedup window.
### Trade-offs to accept (be honest)
1. **Testing regression (the real cost).** `miniredis` is pure-Go, in-memory, zero-binary, instant. MongoDB has **no equivalent** — every option runs a real `mongod`: `testcontainers-go` (needs Docker in CI, ~2-3s/suite) or `memongo` (downloads a ~100MB `mongod` binary, UNIX-only, primary repo stale — prefer the `tryvium-travels/memongo` fork). CI gains a Docker/binary dependency and slows down. This is a genuine downgrade in test ergonomics and the main argument *against* switching absent the ops motivation.
2. Heavier runtime dependency and image than the tiny `go-redis` client (acceptable; not a hot path).
3. Loss of Redis's trivial single-process local dev (`redis:7-alpine`); replaced by a **required Atlas connection** (no local DB — both dev and prod use Atlas, separated by database name).
### When NOT to switch
If the only driver were "exploring" with no infra reason, staying on Redis would be the KISS choice — Redis is the better technical fit for this exact workload. The switch is recommended **only because** MongoDB is already operated and consolidation has real ops value.
## Implementation Steps
1. Confirm the motivation still holds (already-run MongoDB / managed external host) before committing to Phases 2-5.
2. Accept the testing trade-off. **Decided (Session 1):** testcontainers-go, gated behind `//go:build integration` and run manually later — default `go test` stays Docker-free (Phase 5).
3. Proceed to Phase 2.
## Success Criteria
- [x] Verdict reviewed and motivation reconfirmed (already-run MongoDB / Atlas).
- [x] Test backend decided: testcontainers, gated + deferred (Session 1).
- [x] Testing trade-off accepted.
## Risk Assessment
- Risk: team underestimates CI impact of losing `miniredis`. Mitigation: Phase 5 makes the test backend explicit and a gating decision here.
- Risk: managed MongoDB connectivity/auth from the bot's deploy environment. Mitigation: validate `MONGODB_URI` reachability early (Phase 2 ping on startup, same as current Redis ping in `main.go:38`).
@@ -0,0 +1,59 @@
---
phase: 2
title: "Store Package & Connection"
status: complete
effort: "S"
---
# Phase 2: Store Package & Connection
## Overview
Create `internal/mongostore` with the connection plumbing, collection handles, index bootstrap, and the storage-agnostic domain types/logic moved over verbatim from `redisstore`. No consumer wiring yet (Phase 4); `redisstore` still exists in parallel until Phase 5.
## Requirements
- Functional: open a MongoDB client from a URI, ping on startup, expose a `Store` holding collection handles, ensure required indexes exist idempotently.
- Non-functional: keep exported type/constant names identical to `redisstore` so Phase 5 is a qualifier swap, not a rewrite of consumers.
## Architecture
Collections (database name from config, default `openai_status_bot`):
- `subscribers` — `{_id: "<chatID>"|"<chatID:threadID>", chatID, threadID, types[], components[]}`
- `component_statuses` — `{_id: componentID, status}`
- `pending_component_events` — `{_id: componentID, componentName, status, updatedAt, position, previousStatus, deliveryKey}`
- `incident_update_versions` — `{_id: updateID, version}`
- `delivery` — `{_id: "<eventKey>|<subscriber>", eventKey, subscriber, expiresAt}` (TTL index on `expiresAt`)
- `meta` — `{_id: "initialized"|"telegramOffset", value}`
Indexes ensured on startup:
- `delivery`: TTL index `{expiresAt:1}` with `ExpireAfterSeconds(604800)` (7 days). The compound key is encoded in `_id`, so no extra unique index needed; an additional non-unique `{eventKey:1}` index speeds `DeliveredSubscribers`/`ClearDelivery` lookups.
- All other collections use `_id` only — no extra indexes.
## Related Code Files
- Create: `internal/mongostore/store.go` — `Store` struct, `New(client *mongo.Client, dbName string) *Store`, collection handles, collection-name constants (replacing the `*Key` constants in `redisstore/store.go`), `SubscriptionType*` constants.
- Create: `internal/mongostore/indexes.go` — `EnsureIndexes(ctx) error` (TTL + eventKey index).
- Create: `internal/mongostore/subscriber.go` — move `Subscriber`, `NewSubscriber`, `ParseSubscriberKey`, `Key()`, `Accepts`, `DefaultSubscriptionTypes` **verbatim** (pure logic, storage-agnostic).
- Create: `internal/mongostore/subscriber_normalization.go` — move `normalizeTypes`, `normalizeComponents`, `containsFold` **verbatim**.
- Reference (driver, Phase 4 adds to go.mod): `go.mongodb.org/mongo-driver/v2/mongo`, `.../mongo/options`, `.../bson`.
## Implementation Steps
1. `go get go.mongodb.org/mongo-driver/v2@latest` (adds to go.mod; full dep cleanup in Phase 4).
2. Write `store.go`: define collection-name constants, `Store` struct holding `*mongo.Database` + per-collection `*mongo.Collection` handles, and `New(client, dbName)`.
3. Write `subscriber.go` and `subscriber_normalization.go` by copying the storage-agnostic code from the matching `redisstore` files unchanged except the package name.
4. Write `indexes.go` with `EnsureIndexes` creating the TTL index (`options.Index().SetExpireAfterSeconds(604800)`) and the `{eventKey:1}` index via `Indexes().CreateMany`. Idempotent — re-creating an existing index is a no-op/ignored error.
5. `go build ./internal/mongostore/...` to confirm the package compiles in isolation.
## Success Criteria
- [ ] `internal/mongostore` compiles standalone.
- [ ] Exported names match `redisstore` (`Subscriber`, `PendingComponentEvent` [added Phase 3], `NewSubscriber`, `DefaultSubscriptionTypes`, `SubscriptionTypeIncident/Component`).
- [ ] `EnsureIndexes` creates the TTL index with 604800s expiry.
## Risk Assessment
- Risk: driver v2 import path differs from v1 examples. Mitigation: pin `go.mongodb.org/mongo-driver/v2`; `mongo.Connect(options.Client().ApplyURI(uri))` (no separate `ctx` arg in v2 `Connect`).
- Risk: index creation race on multi-instance deploy. Mitigation: single-instance bot; `CreateMany` is idempotent regardless.
@@ -0,0 +1,67 @@
---
phase: 3
title: "Implement Store Methods"
status: complete
effort: "M"
---
# Phase 3: Implement Store Methods
## Overview
Implement every method required by `poller.Store` and `bot.Store` against MongoDB, matching the existing semantics exactly (return values, "exists" booleans, defaults, self-heal-on-corrupt behavior where applicable).
## Requirements
Satisfy the full interface surface (from `internal/poller/poller.go:17` and `internal/bot/bot.go:25`):
Subscribers/settings: `AddSubscriber`, `RemoveSubscriber`, `ListSubscribers`, `GetSubscriber`, `UpdateSubscriberTypes`, `UpdateSubscriberComponents`, `UpdateSubscriberSettings`.
Checkpoint/state: `ComponentStatuses`, `SaveComponentStatus`, `PendingComponentEvents`, `SavePendingComponentEvent`, `RemovePendingComponentEvent`, `HasIncidentUpdateVersion`, `MarkIncidentUpdateVersion`, `DeliveredSubscribers`, `MarkDelivered`, `ClearDelivery`, `IsInitialized`, `SetInitialized`, `TelegramOffset`, `SaveTelegramOffset`.
## Architecture / semantics mapping
- `PendingComponentEvent` struct moves to `mongostore` with BSON tags; stored as native fields (drop the manual `json.Marshal`/`Unmarshal` from `checkpoint.go:58-75`).
- **AddSubscriber**: `UpdateOne({_id:key}, {$set:{chatID,threadID}, $setOnInsert:{types:defaults, components:[]}}, upsert)`. Preserves existing settings on re-`/start` (matches current load-then-save behavior at `subscriber.go:73-83`).
- **RemoveSubscriber**: `DeleteOne({_id:key})` — single op (was SRem+HDel TxPipeline).
- **ListSubscribers**: `Find({})` + `cursor.All`. Decode straight into `Subscriber` (was SMEMBERS + HGETALL); keep the malformed-key self-heal: on decode/parse failure for a doc, `DeleteOne` it and continue or error (preserve current behavior at `subscriber.go:108-125`).
- **GetSubscriber**: `FindOne({_id:key})`; `ErrNoDocuments` ⇒ `(zero,false,nil)`.
- **UpdateSubscriberTypes/Components/Settings**: `UpdateOne({_id:key}, {$set:{...}})`; `MatchedCount==0` ⇒ `(false,nil)` (replaces the Lua check-then-set). Apply `normalizeTypes`/`normalizeComponents` before write, same as today.
- **ComponentStatuses**: `Find({})` → `map[string]string{_id: status}`.
- **SaveComponentStatus**: `UpdateOne({_id}, {$set:{status}}, upsert)`.
- **PendingComponentEvents**: `Find({})` → `map[string]PendingComponentEvent`.
- **SavePendingComponentEvent / RemovePendingComponentEvent**: upsert / `DeleteOne` by `_id=componentID`.
- **HasIncidentUpdateVersion**: `FindOne({_id:updateID})`; compare stored `version`. **Drop the legacy `incident-updates` SET fallback** (`checkpoint.go:97-106`) — no legacy data on a fresh DB.
- **MarkIncidentUpdateVersion**: `UpdateOne({_id:updateID}, {$set:{version}}, upsert)` — single op (was HSet+SAdd TxPipeline).
- **DeliveredSubscribers**: `Find({eventKey})` → `map[string]bool{subscriber:true}`.
- **MarkDelivered**: `UpdateOne({_id: eventKey+"|"+subscriber}, {$set:{eventKey,subscriber,expiresAt: now+7d}}, upsert)` — single op with TTL (was SAdd+Expire TxPipeline). `now` from `time.Now()`.
- **ClearDelivery**: `DeleteMany({eventKey})`. Drop sha256 key-hashing (`deliveryStateKey`, `checkpoint.go:168`).
- **IsInitialized / SetInitialized**: `meta` doc `_id:"initialized"` — `FindOne` exists / upsert `{value:true}`.
- **TelegramOffset / SaveTelegramOffset**: `meta` doc `_id:"telegramOffset"` — `FindOne` (default 0 on `ErrNoDocuments`, and clear-on-invalid like `checkpoint.go:155-160` is no longer needed since BSON stores an int natively) / upsert `{value:offset}`.
## Related Code Files
- Create: `internal/mongostore/subscriber.go` (extend Phase 2 file with the subscriber CRUD methods).
- Create: `internal/mongostore/subscriber_settings.go` — `subscriberSettings` decode/normalize helpers (drop the Lua script; keep normalization), or fold into subscriber.go if small (KISS).
- Create: `internal/mongostore/checkpoint.go` — `PendingComponentEvent` (BSON tags) + all checkpoint/delivery/meta/component methods.
- Reference: `internal/poller/poller.go:17`, `internal/bot/bot.go:25` (interface contracts to satisfy).
## Implementation Steps
1. Add `PendingComponentEvent` with BSON tags to `checkpoint.go`.
2. Implement subscriber CRUD + settings methods; verify against `bot.Store` signatures.
3. Implement checkpoint/delivery/meta/component methods; verify against `poller.Store` signatures.
4. Add a compile-time assertion file or inline `var _ poller.Store = (*Store)(nil)` / `var _ bot.Store = (*Store)(nil)` (only after Phase 5 imports align — or assert against locally redeclared interfaces during this phase) to catch signature drift.
5. `go build ./internal/mongostore/...`.
## Success Criteria
- [ ] All listed methods implemented with matching signatures and semantics.
- [ ] No `TxPipeline`/Lua equivalents needed — each former multi-key op is a single document op.
- [ ] Legacy incident-dedup fallback and sha256 delivery-key hashing removed.
- [ ] Package compiles.
## Risk Assessment
- Risk: `MatchedCount` vs `ModifiedCount` confusion in conditional updates. Mitigation: use `MatchedCount` for "did the subscriber exist" (a no-op `$set` to identical values still matches but may not modify).
- Risk: telegram offset stored as BSON int32 vs int64. Mitigation: store/read as `int64` explicitly in BSON.
- Risk: decode of self-healed malformed subscriber docs. Mitigation: replicate current delete-and-continue/error path; cover in Phase 5 tests.
@@ -0,0 +1,51 @@
---
phase: 4
title: "Config Wiring & Deployment"
status: complete
effort: "S"
---
# Phase 4: Config Wiring & Deployment
## Overview
Replace Redis configuration, connection bootstrap, and deployment artifacts with MongoDB equivalents. After this phase `main.go` builds the Mongo client; consumer type swap is Phase 5.
<!-- Updated: Validation Session 1 - Atlas for both envs; one cluster, two DBs selected by MONGODB_DATABASE; no local mongo service -->
## Requirements
- New env: `MONGODB_URI` (Atlas `mongodb+srv://...`, **required**, no localhost default — there is no local DB), `MONGODB_DATABASE` (default `openai_status_bot`; dev env sets a separate name e.g. `openai_status_bot_dev`). Remove `REDIS_URL`.
- `main.go` connects, pings, ensures indexes, constructs `mongostore.New(client, dbName)`.
- Deployment (validated): **Atlas for both prod and dev** — single cluster, two databases differentiated by `MONGODB_DATABASE`. **No local `mongo` service** in any compose file.
## Architecture
`config.Config`: replace `RedisOptions *redis.Options` with `MongoURI string` + `MongoDatabase string`. Drop `parseRedisURL`, `minRedisDB`/`maxRedisDB`. Validation: `MONGODB_URI` **required** (error if empty, like `TELEGRAM_BOT_TOKEN` at `config.go:35-38` — no localhost fallback since both envs use Atlas); `MONGODB_DATABASE` defaults to `openai_status_bot`. Keep validation minimal; the driver validates the URI on connect/ping.
## Related Code Files
- Modify: `internal/config/config.go` — swap Redis fields/parsing for Mongo fields; keep `getEnv` default pattern.
- Modify: `internal/config/config_test.go` — replace Redis URL cases with Mongo URI/DB cases.
- Modify: `cmd/openai-status-bot/main.go` — replace `redis.NewClient`+`Ping` (`main.go:37-42`) with `mongo.Connect(options.Client().ApplyURI(cfg.MongoURI))`, `client.Ping`, `defer client.Disconnect(ctx)`; call `store.EnsureIndexes(ctx)`; `store := mongostore.New(client, cfg.MongoDatabase)`. Update the connect-error log fields (drop redis-specific `Network/Addr/DB/TLS`).
- Modify: `.env.example` — `MONGODB_URI=mongodb+srv://<user>:<pass>@<cluster>/` (placeholder), `MONGODB_DATABASE=openai_status_bot`; remove `REDIS_URL`.
- Modify: `docker-compose.yml` — **remove the `redis` service and `redis-data` volume entirely** (no local DB). Becomes bot-only, connecting to Atlas via `.env`; this is the **development** path, so set `MONGODB_DATABASE` to the dev database (e.g. `openai_status_bot_dev`). No `depends_on`.
- Modify: `docker-compose.bot.yml` — bot-only, connects to Atlas via `.env` with the **production** `MONGODB_DATABASE` (`openai_status_bot`). With no local DB, this and `docker-compose.yml` differ only by database name; keep both for the dev/prod split. Optionally collapse to one file — but keeping both matches the existing two-file convention (YAGNI: don't restructure beyond the DB-name change).
- Modify: `README.md` — Configuration table (`MONGODB_URI`, `MONGODB_DATABASE` rows; drop `REDIS_URL` and the percent-encoding note, or rewrite the note for Atlas SRV URIs), feature bullet "Uses Redis for…" → MongoDB, Compose/Quick-Start description (Atlas, no local DB; dev vs prod database), intro line. Note that no local datastore runs in Compose — an Atlas URI is required to start.
## Implementation Steps
1. Edit `config.go` + `config_test.go`; `go test ./internal/config/`.
2. Edit `main.go` for Mongo client lifecycle + `EnsureIndexes`. (Will not fully build until Phase 5 swaps `redisstore`→`mongostore` in poller/bot — acceptable; Phases 4-5 land together.)
3. Update `.env.example`, both compose files, README.
## Success Criteria
- [ ] `config` package + tests pass with Mongo env vars; no Redis symbols remain in config.
- [ ] `main.go` opens/pings Mongo, ensures indexes, builds `mongostore`.
- [ ] Compose, `.env.example`, README reference only MongoDB.
## Risk Assessment
- Risk: managed URIs carry credentials/`+srv`. Mitigation: `ApplyURI` handles `mongodb+srv://`; never log the URI (log db name only); keep `.env` out of git (already in `.gitignore`).
- Risk: dropping `REDIS_URL` breaks existing deploys silently. Mitigation: README "Notes" + this plan flag the env rename; no compatibility shim (clean cutover, intended).
@@ -0,0 +1,69 @@
---
phase: 5
title: "Cutover & Tests"
status: complete
effort: "M"
---
# Phase 5: Cutover & Tests
## Overview
Swap all consumers from `redisstore` to `mongostore`, delete `redisstore`, purge the Redis dependencies, and re-establish the test suite on a MongoDB-backed harness (replacing `miniredis`). After this phase the project builds and tests green with zero Redis.
## Requirements
- All `redisstore.X` references → `mongostore.X` (type names identical; only the qualifier changes).
- `internal/redisstore` deleted; `go-redis` and `miniredis` removed from go.mod/go.sum.
- Tests pass against a real `mongod` test harness.
## Architecture: test backend
<!-- Updated: Validation Session 1 - testcontainers chosen but gated behind build tag; not run this phase; poller/bot use in-test fake -->
`miniredis` (pure-Go, in-memory, no binary) has **no MongoDB equivalent** — real-backend tests must run a real `mongod`.
**Decision (validated): `testcontainers-go/modules/mongodb`, but gated and deferred.** The `mongostore` integration suite is written behind a build tag (`//go:build integration`) so it is **NOT part of default `go test ./...`** in this phase. Default test runs must stay Docker-free. The user runs `go test -tags=integration ./internal/mongostore/...` later, when Docker is available.
- `internal/mongostore/store_test.go` → tag `//go:build integration`; a `testMongo(t)` helper starts a `mongo:7` container once, returns a `*mongostore.Store` against a unique per-test database, and `t.Cleanup` drops it.
- **Poller/bot tests use an in-test fake `Store`** (hand-written, satisfies the interface) — fast, Docker-free, runs in default `go test`. No real DB in those suites.
- So in this phase: `go test ./...` (no tag) passes with zero Docker — poller/bot/config/etc. on fakes, `mongostore` integration tests skipped by the build tag. CI stays green without Docker; real-backend validation is a deliberate manual/later step.
## Related Code Files
- Modify (qualifier swap `redisstore.` → `mongostore.`, update imports):
- `internal/poller/poller.go`, `internal/poller/event_collection.go`, `internal/poller/delivery.go`
- `internal/bot/bot.go`, `internal/bot/commands.go`, `internal/bot/helpers.go`, `internal/bot/subscription_format.go`
- `internal/telegram/client.go` (`Subscriber` param at `client.go:108`)
- Create: `internal/mongostore/store_test.go` — `//go:build integration` tag; port the 243-line `redisstore/store_test.go` to the testcontainers harness (same assertions: subscriber CRUD, settings normalization/self-heal, delivery dedup + TTL-index presence, incident version dedup, offset, initialized). Excluded from default `go test`.
- Modify: `internal/poller/poller_test.go`, `internal/bot/bot_test.go` — swap `redisstore` import; replace any `miniredis` usage with a lightweight **in-test fake** of the `Store` interface (Docker-free, fast). Do not point these at the real harness.
- Create (if not already present in those tests): a shared fake `Store` (e.g. an in-memory map-backed type in a test helper) satisfying both `poller.Store` and `bot.Store`.
- Delete: `internal/redisstore/` (all 6 files).
- Modify: `go.mod`/`go.sum` — remove `github.com/redis/go-redis/v9`, `github.com/alicebob/miniredis/v2` (+ transitive `gopher-lua`, `go-rendezvous`, `xxhash` if now unused); add `go.mongodb.org/mongo-driver/v2` and the chosen test dep. Run `go mod tidy`.
## Implementation Steps
1. Add compile-time interface assertions in `mongostore` (`var _ poller.Store`, `var _ bot.Store`) — now imports resolve both ways; fix any signature drift from Phase 3.
2. Swap qualifiers across poller/bot/telegram (grep `redisstore\.` → confirm each maps to an identical `mongostore` symbol).
3. `rm -rf internal/redisstore`; `go build ./...` and fix remaining references.
4. Build the in-test fake `Store`; swap poller/bot tests onto it. Port `store_test.go` to the testcontainers harness under `//go:build integration`.
5. `go mod tidy`; confirm Redis deps gone (`grep redis go.mod` empty).
6. `go test ./...` (no tag) — must pass **without Docker** (poller/bot on fakes, integration suite excluded). `go vet ./...`. Optionally `go build -tags=integration ./...` to confirm the gated suite compiles.
## Success Criteria
- [ ] `go build ./...` clean; no `redisstore` references; `internal/redisstore` deleted.
- [ ] `grep -ri redis` finds no code/config/doc references (outside this plan).
- [ ] `go test ./...` (no tag) green **without Docker**; gated `mongostore` suite compiles under `-tags=integration` (run later by the user).
- [ ] `go mod tidy` removed `go-redis` + `miniredis`; added mongo driver + testcontainers (test-only).
## Risk Assessment
- Risk: CI lacks Docker for testcontainers. Resolved by decision: integration suite is behind `//go:build integration` and excluded from default `go test`; user runs it manually later. Document the `-tags=integration` + Docker requirement in README.
- Risk: in-test fake `Store` drifts from real `mongostore` semantics (the fake passes but Mongo behaves differently). Mitigation: the gated integration suite is the real-behavior backstop; user must run it before trusting the cutover in production.
- Risk: TTL behavior not directly assertable (sweep ~60s). Mitigation: assert the TTL **index exists** with `ExpireAfterSeconds==604800` rather than waiting for expiry; trust MongoDB to enforce.
- Risk: hidden semantic drift between fake `Store` and real `mongostore` in poller/bot tests. Mitigation: keep at least the `mongostore` suite exercising real behavior end-to-end for the store contract.
## Post-cutover docs
Update the three docs that reference Redis: `docs/system-architecture.md`, `docs/setup-guide.md`, `docs/component-id-monitoring-report.md` (verify each via `grep -li redis docs/`). Add a README note that switching from a prior Redis deploy starts from empty state (no migration; subscribers re-`/start`).
@@ -0,0 +1,73 @@
---
title: "Migrate datastore from Redis to MongoDB"
description: ""
status: complete
priority: P2
branch: "main"
tags: []
blockedBy: []
blocks: []
created: "2026-06-26T04:22:22.185Z"
createdBy: "ck:plan"
source: skill
---
# Migrate datastore from Redis to MongoDB
## Overview
Replace Redis with MongoDB as the bot's datastore. No data migration: cutover is a clean reseed (first poll re-seeds state, subscribers re-issue `/start`). Motivation: team already operates MongoDB; goal is removing the Redis dependency to consolidate on one datastore. Hosting (validated): **managed Atlas for both environments** — one cluster with two databases (production + development) selected via `MONGODB_DATABASE`. **No local MongoDB service**; both compose files connect to Atlas, differing only by database name.
**Verdict (Phase 1, full reasoning there):** Suitable and justified. Every Redis op maps cleanly to MongoDB, and the document model actually *simplifies* several multi-key sequences. One real regression to accept: unit tests lose the pure-Go in-memory `miniredis`; MongoDB has no equivalent, so tests need a real `mongod` (testcontainers needs Docker, or `memongo` downloads a binary). CI gets slower and gains a Docker/binary dependency.
## Approach (straight replace, no dual backend)
`poller.Store` and `bot.Store` are already interfaces, and the only cross-package coupling is the domain types `redisstore.Subscriber`, `redisstore.PendingComponentEvent`, the `SubscriptionType*` constants, `NewSubscriber`, and `DefaultSubscriptionTypes` (~90 references, type *names* not bodies). Plan: create `internal/mongostore` exporting the **same type/constant names**, move the storage-agnostic domain logic verbatim, reimplement only the client-touching methods against MongoDB, swap consumer qualifiers `redisstore.` → `mongostore.`, then delete `internal/redisstore`. YAGNI: no pluggable-backend abstraction since only one backend is wanted.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Suitability & Decision](./phase-01-suitability-decision.md) | Done |
| 2 | [Store Package & Connection](./phase-02-store-package-connection.md) | Done |
| 3 | [Implement Store Methods](./phase-03-implement-store-methods.md) | Done |
| 4 | [Config Wiring & Deployment](./phase-04-config-wiring-deployment.md) | Done |
| 5 | [Cutover & Tests](./phase-05-cutover-tests.md) | Done |
## Acceptance Criteria
- [x] Bot builds and runs with no `go-redis` / `miniredis` dependency.
- [x] All store operations (subscribers, settings, component statuses, pending component events, incident-update versions, delivery dedup w/ 7-day expiry, initialized flag, telegram offset) work against MongoDB.
- [x] `poller.Store` and `bot.Store` interfaces satisfied by `mongostore`; existing poller/bot/telegram tests pass against the new backend.
- [x] Config, `.env.example`, compose files, and README reference `MONGODB_URI`/`MONGODB_DATABASE`; no Redis references remain.
- [x] TTL index on delivery collection enforces 7-day expiry; conditional "update-only-if-subscribed" preserved via `MatchedCount`.
## Dependencies
None (no cross-plan dependencies; no in-repo data migration).
## Validation Log
### Session 1 — 2026-06-26
**Verification Results**
- Claims checked: 7 anchors + driver facts (Full tier, 5 phases)
- Verified: 7 | Failed: 0 | Unverified: 0
- Evidence: `poller.go:17` & `bot.go:25` (Store interfaces), `subscriber_settings.go:16` (Lua script), `checkpoint.go:137` (`Expire` 7d), `telegram/client.go:108` (`redisstore.Subscriber` param), 92 `redisstore.` refs across `internal`+`cmd`, `.env` gitignored. Driver = `go.mongodb.org/mongo-driver/v2` (v2.7.0).
**Decisions confirmed**
1. **Test backend** → testcontainers-go chosen, but **gated/deferred**: integration suite behind a build tag (`//go:build integration`), NOT run in Phase 5. Default `go test ./...` must stay Docker-free; user runs the integration suite later. (Phase 5)
2. **Hosting** → Atlas for both envs; **one cluster, two databases** (prod + dev) selected by `MONGODB_DATABASE`. **No local `mongo` service** in any compose file. (Phase 4)
3. **Cutover** → clean break: remove `REDIS_URL`, no compat shim, state resets (subscribers re-`/start`, checkpoints reseed). (Phase 4/5)
4. **Poller/bot tests** → in-test fake `Store` (Docker-free); real-backend coverage stays in the gated `mongostore` suite. (Phase 5)
### Whole-Plan Consistency Sweep
- Re-read `plan.md` + all 5 phase files after propagation. Local-`mongo`-service references removed (Overview, Phase 4); test-gating note added (Phase 5). No stale "local mongo" / "docker-compose mongo service" terms remain. Verdict (Phase 1) unaffected by these deployment/test decisions. **0 unresolved contradictions.**
### Implementation — 2026-06-26 (all phases complete)
- New `internal/mongostore` (store/subscriber/subscriber_normalization/checkpoint/indexes) on driver `go.mongodb.org/mongo-driver/v2 v2.7.0`; `internal/redisstore` deleted; `go-redis`+`miniredis` removed via `go mod tidy`.
- Consumers (`poller`, `bot`, `telegram`) swapped by qualifier only (`redisstore.`→`mongostore.`); identical exported names. Interface conformance enforced at compile time via `main.go` wiring (no `var _` assertion — would cycle).
- `config` now requires `MONGODB_URI`, defaults `MONGODB_DATABASE=openai_status_bot`; `main.go` connects/pings/`EnsureIndexes`. `.env.example`, both compose files (bot-only, Atlas, dev/prod by DB name), README, and 3 docs updated; no Redis refs remain.
- Tests: poller/bot/config on Docker-free fakes; `mongostore` has pure-logic unit tests (default) + gated `//go:build integration` testcontainers suite (run via `go test -tags=integration ./internal/mongostore/...`). `go build`/`go test`/`go vet ./...` all green without Docker.
- `code-reviewer`: DONE, 0 Critical/High/Medium; one cosmetic note (subscriber `threadID` null-vs-omit) applied.
- Deferred: user runs the gated integration suite with Docker before trusting the cutover in production.
@@ -0,0 +1,83 @@
---
phase: 1
title: "Dependency and architecture setup"
status: completed
priority: P1
dependencies: []
---
# Phase 1: Dependency and architecture setup
## Overview
Introduce `go-telegram/bot` and define the local architecture boundaries before moving command behavior. This phase should compile toward the new dependency but may keep old runtime code until later phases finish.
## Requirements
- Functional: add `github.com/go-telegram/bot@v1.21.0`; identify startup, command, notification, and error integration points.
- Non-functional: keep dependency footprint small; no webhook mode; no debug logging of Telegram requests.
## Architecture
Keep these boundaries:
- `cmd/openai-status-bot/main.go` owns process wiring and framework lifecycle.
- `internal/bot` owns command behavior and handler registration.
- `internal/telegram` owns notification sending and framework error translation for poller use.
- `internal/poller` should not import `github.com/go-telegram/bot` directly.
Preferred naming:
- Import framework package as `tgbot` inside project package `internal/bot` to avoid `bot.Bot` naming conflicts.
- Import models as `tgmodels`.
- If refactoring internal command runtime, prefer `type App struct` over another `type Bot struct`.
## Related Code Files
| Action | File | Notes |
|---|---|---|
| Modify | `go.mod` / `go.sum` | Add `github.com/go-telegram/bot@v1.21.0` |
| Modify | `cmd/openai-status-bot/main.go` | Prepare framework construction and lifecycle wiring |
| Modify | `internal/bot/bot.go` | Plan rename/split from polling loop to app handler container |
| Modify | `internal/telegram/client.go` | Prepare for deletion or shrink to sender/error adapter |
| Reference | `plans/reports/260626-1602-go-telegram-framework-research.md` | Prior library comparison |
## Implementation Steps
1. Run `go get github.com/go-telegram/bot@v1.21.0`.
2. Confirm `go mod tidy` does not introduce unexpected third-party dependencies.
3. Add local package aliases in planned files: `tgbot "github.com/go-telegram/bot"` and `tgmodels "github.com/go-telegram/bot/models"`.
4. Define the target startup sequence in `main.go` before code movement:
- load config/logger/context/health/Mongo;
- build status client/store;
- load stored Telegram offset if retained;
- create framework bot with options;
- delete webhook, set commands, resolve username;
- create `internal/bot.App`;
- register handlers;
- create `internal/telegram.Sender` for poller notifications;
- start status poller goroutine;
- mark readiness;
- call `tg.Start(ctx)`.
5. Decide initial file boundaries:
- `internal/bot/app.go` for app struct/registration;
- `internal/bot/message_context.go` or helper functions for model conversion;
- `internal/telegram/sender.go` for poller notification sending;
- `internal/telegram/errors.go` for terminal error classification.
## Success Criteria
- [x] `go.mod` contains `github.com/go-telegram/bot v1.21.0`.
- [x] Plan-confirmed architecture avoids importing framework types into `internal/poller`.
- [x] Startup sequence is documented in code comments only where non-obvious.
- [x] No source file grows past 200 lines without a modularization check.
## Risk Assessment
- Risk: package name collision between project `internal/bot` and framework `bot`.
Mitigation: always alias framework import as `tgbot`.
- Risk: framework debug/error logging leaks token-bearing URLs.
Mitigation: do not enable `WithDebug`; route framework errors through a redacting slog handler.
- Risk: migration becomes a broad rewrite.
Mitigation: keep OpenAI status formatting, Mongo store, and poller event logic unchanged.
@@ -0,0 +1,98 @@
---
phase: 2
title: "Command handler migration"
status: completed
priority: P1
dependencies: [1]
---
# Phase 2: Command handler migration
## Overview
Move command processing from the custom `GetUpdates` loop to `go-telegram/bot` handlers while preserving command behavior and testability.
## Requirements
- Functional: every existing command keeps output and storage behavior.
- Functional: commands with another bot username remain ignored.
- Functional: topic-specific subscriptions still use `message_thread_id`.
- Non-functional: command logic remains unit-testable without live Telegram HTTP calls.
## Architecture
Recommended shape:
```go
type App struct {
sender ReplySender
statusClient StatusClient
store Store
logger *slog.Logger
username string
}
func (a *App) RegisterHandlers(tg *tgbot.Bot)
func (a *App) HandleUpdate(ctx context.Context, tg *tgbot.Bot, update *tgmodels.Update)
```
Use one framework default handler or `RegisterHandlerMatchFunc` for message commands, then reuse `normalizeCommand` for consistent `/cmd@BotName` behavior. Direct framework `MatchTypeCommandStartOnly` alone does not normalize own-bot suffixes; if using per-command registration, explicitly test `/start@<username>`.
Use a small local message context instead of threading framework models everywhere:
```go
type MessageContext struct {
ChatID int64
ThreadID *int
Text string
}
```
Existing command methods can then migrate from `telegram.Message` to `MessageContext`.
## Related Code Files
| Action | File | Notes |
|---|---|---|
| Modify | `internal/bot/bot.go` | Remove manual `Run` loop; introduce app handler entrypoint |
| Modify | `internal/bot/helpers.go` | Change reply helpers from custom message type to local context |
| Modify | `internal/bot/commands.go` | Change command methods from custom message type to local context |
| Modify | `internal/bot/menu_commands.go` | Return `[]tgmodels.BotCommand` or build menu in `main.go` |
| Modify | `internal/bot/bot_test.go` | Replace fake `GetUpdates` tests with handler/dispatch tests |
| Delete or shrink | `internal/telegram/types.go` | Custom `Update`, `Message`, `Chat`, `User`, `BotCommand` should disappear if unused |
## Implementation Steps
1. Create `App` and `MessageContext` types.
2. Add helper conversion:
- ignore nil update/message;
- ignore empty/non-command text;
- map `message.Chat.ID`;
- map `message.MessageThreadID == 0` to `nil`, otherwise copy int pointer.
3. Move `handleMessage` into `App.HandleUpdate` or split into:
- `handleUpdate(ctx, update)`;
- `handleCommand(ctx, msgCtx)`.
4. Keep `normalizeCommand(text, username)` unchanged unless tests require framework model-specific adjustments.
5. Convert `reply`, `subscribe`, `unsubscribe`, `replyStatus`, `replySubscribe`, `replyHistory`, `replyUptime`, and `replyInfo` to accept `MessageContext`.
6. Register handlers in `main.go` after username is known:
- safest path: `WithDefaultHandler(app.HandleUpdate)` or `RegisterHandlerMatchFunc(commandMessage, app.HandleUpdate)`;
- use `WithNotAsyncHandlers` and `WithWorkers(1)` to preserve simple sequential command behavior.
7. Remove `TelegramClient.GetUpdates` dependency from `internal/bot`.
## Success Criteria
- [x] `internal/bot` no longer calls `GetUpdates`.
- [x] `/start@OtherBot` remains ignored.
- [x] `/start@OpenAIStatusBot` works when username is configured.
- [x] Topic replies send to the original `message_thread_id`.
- [x] Existing command tests still assert core behavior, but they use framework update models or local `MessageContext`.
## Risk Assessment
- Risk: losing own-bot command suffix behavior.
Mitigation: keep `normalizeCommand`; add tests for own-bot and other-bot suffixes.
- Risk: framework async handler default introduces races.
Mitigation: configure `WithNotAsyncHandlers()` and `WithWorkers(1)`.
- Risk: tests become HTTP-heavy.
Mitigation: keep command logic behind local sender interface; unit-test without framework network calls.
@@ -0,0 +1,83 @@
---
phase: 3
title: "Notification sender migration"
status: completed
priority: P1
dependencies: [1]
---
# Phase 3: Notification sender migration
## Overview
Replace custom notification sends with a `go-telegram/bot` backed sender while preserving poller retry isolation and terminal subscriber cleanup.
## Requirements
- Functional: `poller.Runner` can still call `SendMessage(ctx, subscriber, text) error`.
- Functional: HTML parse mode and disabled link previews remain.
- Functional: `message_thread_id` remains for topic subscribers.
- Functional: terminal send errors still remove unreachable subscribers.
- Non-functional: `internal/poller` must stay independent from framework types.
## Architecture
Keep a local adapter package:
```go
package telegram
type Sender struct {
bot *tgbot.Bot
}
func (s *Sender) SendMessage(ctx context.Context, sub mongostore.Subscriber, text string) error
func IsTerminalSendError(err error) bool
```
Map framework/API errors into a local `APIError` shape if needed. Preserve the existing semantic contract used by `internal/poller/delivery.go`: 403 is terminal; selected 400 descriptions are terminal; parse/entity errors are retryable.
## Related Code Files
| Action | File | Notes |
|---|---|---|
| Modify/create | `internal/telegram/sender.go` | Framework-backed poller sender |
| Modify/create | `internal/telegram/errors.go` | API error mapping and terminal classification |
| Modify | `internal/poller/delivery.go` | Should require little or no change |
| Modify | `cmd/openai-status-bot/main.go` | Pass sender adapter to `poller.NewRunner` |
| Modify | `internal/poller/poller_test.go` | Keep fake notifier tests working |
| Modify/delete | `internal/telegram/client_test.go` | Replace HTTP-client tests with sender/error tests |
## Implementation Steps
1. Create `telegram.Sender` around `*tgbot.Bot`.
2. Implement send params:
- `ChatID: subscriber.ChatID`;
- `Text: text`;
- `ParseMode: models.ParseModeHTML`;
- `LinkPreviewOptions: &models.LinkPreviewOptions{IsDisabled: true}`;
- `MessageThreadID: *subscriber.ThreadID` when non-nil.
3. Preserve a local `APIError` type if framework errors do not expose the exact fields poller needs.
4. Implement error redaction for logs:
- no debug request logging;
- framework errors passed through `redactToken(err, token)` before logging where token may appear.
5. Keep `telegram.IsTerminalSendError` signature unchanged so `poller` does not care about framework internals.
6. Update `main.go` to instantiate one framework bot and one notification sender from it.
## Success Criteria
- [x] Poller notification tests still prove per-subscriber retry behavior.
- [x] Terminal 403 and terminal 400 messages still remove unreachable subscribers.
- [x] Non-terminal 400 parse errors remain retryable.
- [x] Topic notification sends include `message_thread_id`.
- [x] `internal/poller` imports no `github.com/go-telegram/bot` package.
## Risk Assessment
- Risk: framework error type does not include HTTP status and Telegram `error_code` in the same shape.
Mitigation: inspect returned errors and map by `errors.As` or message fallback with tests.
- Risk: link preview field changed from deprecated `disable_web_page_preview` to `link_preview_options`.
Mitigation: test the framework params object, not raw JSON field, and verify Telegram docs support disabled previews.
- Risk: bot token appears in transport errors.
Mitigation: carry the token into adapter only for redaction; never log raw framework debug output.
@@ -0,0 +1,77 @@
---
phase: 4
title: "Offset policy and cleanup"
status: completed
priority: P2
dependencies: [2, 3]
---
# Phase 4: Offset policy and cleanup
## Overview
Adapt Mongo `telegramOffset` to the framework polling model and remove custom client leftovers after command and notification paths are migrated.
## Requirements
- Functional: restarts should not intentionally replay old command updates when a stored offset exists.
- Functional: the app must tolerate missing/zero offset on fresh databases.
- Non-functional: document the weaker offset guarantee introduced by framework-owned polling.
## Architecture
`go-telegram/bot` stores `lastUpdateID` internally and calls `getUpdates` with `lastUpdateID + 1`. Current Mongo `telegramOffset` stores the next update ID to fetch. Therefore:
- if stored offset > 0, configure `WithInitialOffset(storedOffset - 1)`;
- if stored offset == 0, omit `WithInitialOffset`;
- after handling an update, optionally call `SaveTelegramOffset(update.ID + 1)` as a restart seed;
- do not rely on Mongo offset as a strict Telegram confirmation boundary.
Use low-buffer sequential options to reduce the gap between framework receiving updates and app handling them:
```go
tgbot.WithWorkers(1)
tgbot.WithUpdatesChannelCap(1)
tgbot.WithNotAsyncHandlers()
```
## Related Code Files
| Action | File | Notes |
|---|---|---|
| Modify | `cmd/openai-status-bot/main.go` | Load offset and configure `WithInitialOffset` |
| Modify | `internal/bot/app.go` or `internal/bot/bot.go` | Save restart seed after handling update |
| Modify | `internal/mongostore/checkpoint.go` | Keep offset methods unless a later decision removes them |
| Modify | `docs/system-architecture.md` | Update runtime/failure behavior |
| Delete | `internal/telegram/client.go` | Remove custom `postJSON`, `GetUpdates`, `DeleteWebhook`, `SetMyCommands`, `GetMe` once unused |
| Delete | `internal/telegram/types.go` | Remove custom Telegram API model types once unused |
## Implementation Steps
1. Load `store.TelegramOffset(ctx)` in `main.go` before creating framework bot.
2. Convert stored next-offset to framework initial last-update:
- `initialLastUpdateID := offset - 1` when offset > 0;
- no option when offset <= 0.
3. Add `SaveTelegramOffset(update.ID + 1)` after command handler completion.
4. Log offset-save failures as warnings, same as current code.
5. Remove old `Bot.Run` manual polling path and any unused fake `GetUpdates` interfaces.
6. Remove `internal/telegram/client.go` and `types.go` only after sender/error replacements compile.
7. Re-run `rg "GetUpdates|telegram.Update|telegram.Message|telegram.BotCommand|telegram.User" internal cmd` and remove stale references.
## Success Criteria
- [x] Fresh DB starts without offset errors.
- [x] Existing DB with `telegramOffset=N` starts framework polling from update `N`.
- [x] Command handler saves `update.ID + 1` as restart seed after handling.
- [x] No custom HTTP Telegram API client remains.
- [x] Architecture docs describe framework-owned polling and restart-seed offset semantics.
## Risk Assessment
- Risk: exact post-handler Telegram confirmation is no longer guaranteed.
Mitigation: document trade-off; use sequential/low-buffer framework options; keep offset as restart seed.
- Risk: off-by-one offset skip/replay.
Mitigation: add tests for offset 0, offset 1, and offset N conversion to `WithInitialOffset`.
- Risk: unused Mongo offset state becomes misleading.
Mitigation: rename docs/comments to "restart seed"; consider later migration removing `telegramOffset` if not useful.
@@ -0,0 +1,88 @@
---
phase: 5
title: "Test and documentation update"
status: completed
priority: P1
dependencies: [4]
---
# Phase 5: Test and documentation update
## Overview
Lock the migration down with focused tests, full test execution, and documentation updates for the new runtime model.
## Requirements
- Functional: existing bot commands and notification behavior remain equivalent.
- Non-functional: tests should protect the new framework boundary without requiring a live Telegram token.
## Architecture
Test the app at three levels:
1. Command unit tests for `internal/bot` using local message contexts or framework `models.Update` with fake sender/store/status clients.
2. Telegram sender/error tests for `internal/telegram` using framework params, fake API caller where possible, or local error mapping tests.
3. Startup/compile verification through package tests and `go test ./...`.
Do not add live Telegram network tests.
## Related Code Files
| Action | File | Notes |
|---|---|---|
| Modify | `internal/bot/bot_test.go` | Replace manual polling tests with handler dispatch tests |
| Modify | `internal/telegram/client_test.go` | Convert to sender/error/param tests or replace with new test files |
| Modify | `internal/poller/poller_test.go` | Keep fake notifier behavior; update imports if error type moves |
| Modify | `README.md` | Update dependency/runtime note only if user-visible setup changes |
| Modify | `docs/system-architecture.md` | Required: framework polling, restart-seed offset |
| Modify | `docs/setup-guide.md` | Update only if commands/setup text references custom long polling details |
| Modify | `plans/reports/260626-1602-go-telegram-framework-research.md` | Optional: add note that user chose framework-style migration |
## Implementation Steps
1. Add command dispatch tests:
- `/start` subscribes chat;
- `/start` in topic subscribes `chatID:threadID`;
- `/start@OtherBot` ignored;
- `/start@OpenAIStatusBot` accepted;
- unknown command replies with help hint;
- non-command text ignored.
2. Add reply/send tests:
- HTML parse mode set;
- link preview disabled;
- topic reply includes `MessageThreadID`;
- send errors are logged but do not panic command handlers.
3. Add terminal error tests:
- 403 terminal;
- 400 chat/thread missing terminal;
- 400 parse entities non-terminal.
4. Add offset conversion tests:
- no stored offset => no initial offset option;
- stored offset 1 => initial last update 0;
- stored offset N => initial last update N-1.
5. Run focused tests:
- `go test ./internal/bot ./internal/telegram ./internal/poller`
6. Run broad tests:
- `go test ./...`
7. Run integration tests only when Docker is available and needed:
- `go test -tags=integration ./internal/mongostore/...`
8. Update docs after behavior is verified.
## Success Criteria
- [x] Focused bot/telegram/poller tests pass.
- [x] `go test ./...` passes.
- [x] Docs mention `go-telegram/bot` runtime and offset restart-seed semantics.
- [x] README remains accurate for local and Docker startup.
- [x] No stale custom Telegram client symbols remain under `internal` or `cmd`.
## Risk Assessment
- Risk: tests overfit framework internals.
Mitigation: assert project-visible behavior and adapter params, not private framework state.
- Risk: docs over-explain internals to end users.
Mitigation: README stays user-focused; detailed offset semantics go in `docs/system-architecture.md`.
- Risk: integration tests fail due Docker absence.
Mitigation: keep Mongo integration tests behind existing build tag and report if not run.
@@ -0,0 +1,103 @@
---
title: "Switch Telegram layer to go-telegram/bot"
description: "Replace the custom Telegram HTTP client and command polling loop with the standard go-telegram/bot framework while preserving subscriptions, topic replies, notification delivery, and startup behavior."
status: completed
priority: P2
branch: "develop"
tags: [refactor, backend, telegram]
blockedBy: []
blocks: []
created: "2026-06-26"
createdBy: "ck:plan"
source: skill
---
# Switch Telegram layer to go-telegram/bot
## Overview
Migrate the Telegram integration from the custom `internal/telegram` HTTP client to `github.com/go-telegram/bot` (`v1.21.0`, Bot API 10.0, zero third-party deps). This is a framework-style refactor: `go-telegram/bot` owns long polling and update dispatch; app code owns command behavior, subscriber state, and notification delivery.
The accepted trade-off: Mongo `telegramOffset` stops being a strict post-handler confirmation checkpoint. It remains a restart seed when present, and command handlers can still save the latest handled update ID for replay reduction. This is acceptable because the user prioritized a good standard library and accepted larger changes.
## Scope Challenge
- Existing code: command parsing/replies are in `internal/bot`; delivery fan-out and terminal subscriber cleanup are in `internal/poller`; Telegram HTTP calls/errors are isolated in `internal/telegram`.
- Minimum changes: add `go-telegram/bot`, refactor startup wiring, convert command handlers to framework update handling, replace notification sender, update tests/docs.
- Complexity: expected >8 files touched. Justified because the current custom client, command loop, model types, tests, and architecture docs all encode Telegram transport assumptions.
- Selected mode: HOLD SCOPE. No webhook migration, no interactive keyboards, no callback queries, no feature expansion.
## Architecture Decision
Use `go-telegram/bot` as the runtime and handler framework. Keep project-specific behavior behind local packages:
```text
cmd/openai-status-bot/main.go
-> creates *tgbot.Bot with options
-> deleteWebhook, setMyCommands, getMe
-> internal/bot.App registers command handlers
-> internal/telegram.Sender sends poller notifications
internal/bot
-> owns command dispatch and subscription business logic
-> consumes go-telegram/bot models at the edge
internal/telegram
-> owns poller notification sender and terminal-error classification
-> wraps framework errors so poller logic stays stable
internal/poller
-> keeps Notifier interface unchanged
```
Runtime options should favor predictable command handling:
- `bot.WithAllowedUpdates(bot.AllowedUpdates{"message"})`
- `bot.WithInitialOffset(storedOffset-1)` only when stored offset > 0
- `bot.WithWorkers(1)`
- `bot.WithUpdatesChannelCap(1)`
- `bot.WithNotAsyncHandlers()`
- custom `WithErrorsHandler` / no debug logging with token redaction
## Cross-Plan Dependencies
None. The prior Redis-to-Mongo plan is complete and does not block this work.
## Phases
| Phase | Name | Status |
|-------|------|--------|
| 1 | [Dependency and architecture setup](./phase-01-dependency-and-architecture-setup.md) | Completed |
| 2 | [Command handler migration](./phase-02-command-handler-migration.md) | Completed |
| 3 | [Notification sender migration](./phase-03-notification-sender-migration.md) | Completed |
| 4 | [Offset policy and cleanup](./phase-04-offset-policy-and-cleanup.md) | Completed |
| 5 | [Test and documentation update](./phase-05-test-and-documentation-update.md) | Completed |
## Acceptance Criteria
- [x] Bot builds with `github.com/go-telegram/bot@v1.21.0`.
- [x] Custom Telegram `getUpdates` loop is removed from app code.
- [x] `/start`, `/stop`, `/status`, `/components`, `/subscribe`, `/history`, `/uptime`, `/info`, `/help`, and unknown-command behavior remain equivalent.
- [x] Supergroup topic support via `message_thread_id` remains for replies and notifications.
- [x] Poller delivery retry behavior and terminal subscriber cleanup remain equivalent.
- [x] Startup still deletes webhook with `drop_pending_updates=false`, registers commands, starts health endpoint, starts OpenAI poller, then starts Telegram long polling.
- [x] Tests cover framework handler dispatch, topic replies, notification sender params, terminal errors, offset seed behavior, and command menu registration.
- [x] `go test ./...` passes; integration tests remain gated behind `-tags=integration`.
## Not In Scope
- Webhook mode.
- New bot commands or UI features.
- Inline keyboards, callback queries, payments, web apps.
- Replacing MongoDB or changing subscription schema beyond optional offset cleanup.
## Research Inputs
- `plans/reports/260626-1602-go-telegram-framework-research.md`
- `github.com/go-telegram/bot@v1.21.0` module metadata: released 2026-05-22, Go directive 1.18.
- Local source confirmed support for `WithInitialOffset`, `WithAllowedUpdates`, `WithNotAsyncHandlers`, `SetMyCommands`, `DeleteWebhook`, `SendMessageParams.MessageThreadID`, and `LinkPreviewOptions`.
## Unresolved Questions
None.
@@ -0,0 +1,286 @@
---
type: research-report
topic: go-telegram-framework-selection
conducted_at: 2026-06-26 16:02 Asia/Saigon
status: complete
---
# Research Report: Go Telegram Framework For OpenAI Status Bot
## Executive Summary
Recommendation: do not replace the current custom Telegram client unless we need faster Bot API feature coverage. Current code uses only five API calls and already has behavior this project cares about: explicit offset persistence after command handling, token redaction, typed API errors, request timeout control, `message_thread_id`, and narrow test fakes.
If we still apply a framework, pick `github.com/mymmrac/telego` behind this repo's existing `internal/telegram` interface. It exposes manual `GetUpdates(ctx, params)`, current Bot API types, `message_thread_id`, `setMyCommands`, `deleteWebhook`, and `sendMessage`. Avoid its `UpdatesViaLongPolling` helper because it advances offset internally before this app can persist offset after handling.
Do not pick `go-telegram-bot-api/v5` for this repo: latest module tag is from 2021 and its tagged source does not expose `message_thread_id`, so it regresses supergroup topic support. `go-telegram/bot` is current and zero-dependency, but its normal polling model owns offset progression internally, which mismatches this app's Mongo-backed offset contract. `telebot.v3` is handler-framework oriented and would force more rewrite than value. `gotgbot/v2` is capable but still release-candidate at latest.
## Research Methodology
- Sources consulted: 13 primary/local sources.
- Date range: 2021-12-13 to 2026-06-14 module releases, checked on 2026-06-26.
- Search terms: `Go Telegram Bot API framework`, `telego GetUpdates`, `go-telegram bot MessageThreadID`, `telegram-bot-api v5 message_thread_id`, `telebot.v3 polling`.
- Source types: Telegram official Bot API docs, pkg.go.dev, Go module metadata via `go list`, downloaded module READMEs/source, current repo source.
- Docs-seeker: checked; context7 had no docs for `go-telegram bot Telegram Bot API Go`, fallback used module sources.
## Table Of Contents
- [Project Fit](#project-fit)
- [Key Findings](#key-findings)
- [Comparative Analysis](#comparative-analysis)
- [Recommendation](#recommendation)
- [Implementation Notes](#implementation-notes)
- [Resources](#resources)
- [Unresolved Questions](#unresolved-questions)
## Project Fit
Current Telegram surface:
- `deleteWebhook(drop_pending_updates=false)`
- `getMe`
- `setMyCommands`
- `getUpdates(offset, timeout, allowed_updates=["message"])`
- `sendMessage(chat_id, text, parse_mode=HTML, disable preview, optional message_thread_id)`
Important local constraints:
- `internal/bot.Bot.Run` loads Telegram offset from MongoDB.
- It handles each update, then saves `update_id + 1`.
- This creates a conservative delivery contract: do not confirm future offset before app state is handled.
- Existing client redacts bot token from transport errors.
- `IsTerminalSendError` maps 403 and selected 400 descriptions to subscriber cleanup.
## Key Findings
### 1. Technology Overview
Telegram Bot API works fine with a thin HTTP wrapper. A framework adds value when the bot needs rich handlers, callback routing, media upload helpers, payments, inline mode, web apps, or faster coverage of new Bot API fields.
This project is not there yet. It is a polling status-notification bot with simple text commands. Most complexity is OpenAI-status dedupe, Mongo state, and delivery semantics, not Telegram routing.
### 2. Current State And Trends
Module metadata checked with `go list -m -json <module>@latest`:
| Module | Latest | Release time | Go directive | Fit |
|---|---:|---|---:|---|
| `github.com/mymmrac/telego` | `v1.10.0` | 2026-06-14 | `1.25.7` | Best external fit |
| `github.com/go-telegram/bot` | `v1.21.0` | 2026-05-22 | `1.18` | Good library, poorer offset fit |
| `github.com/PaulSonOfLars/gotgbot/v2` | `v2.0.0-rc.35` | 2026-05-25 | `1.24` | Current but pre-release |
| `gopkg.in/telebot.v3` | `v3.3.8` stable, `v3.4.2-beta` exists | 2024-08-06 stable | `1.16` | Framework-heavy |
| `github.com/go-telegram-bot-api/telegram-bot-api/v5` | `v5.5.1` | 2021-12-13 | `1.16` | Not suitable for topics |
`go-telegram/bot` README says it supports Bot API 10.0 and is zero-dependency. Good signal. But its exported API is handler/poller oriented.
`telego` is generated/current and exposes manual `GetUpdates`. Bad signal: dependency cost is materially larger (`fasthttp`, custom JSON libs, Sonic, etc.) and its module says Go `1.25.7`, while this repo says Go `1.25.0`.
### 3. Best Practices
- Keep this repo's `internal/telegram` boundary. Do not let a framework leak into `internal/bot` or `internal/poller`.
- Preserve explicit offset persistence. Use manual `GetUpdates`, not framework-owned long polling.
- Preserve token redaction tests.
- Preserve terminal send error classification. If using telego, map `errors.As(err, *telegoapi.Error)` to existing `APIError` or update `IsTerminalSendError`.
- Keep `allowed_updates=["message"]`.
- Keep `deleteWebhook(drop_pending_updates=false)` on startup.
- Use context deadlines per request. For long polling, deadline should be `timeoutSeconds + HTTP_TIMEOUT`, not just `HTTP_TIMEOUT`.
### 4. Security Considerations
- Bot token must never enter logs. Current client explicitly redacts URL-bearing transport errors.
- Framework default loggers can log request failures. Disable or replace framework logger.
- Do not enable debug logging around Telegram requests in production.
- Keep command parsing defensive. Do not trust message text, chat type, or thread ID.
- Avoid webhook mode unless deploying with HTTPS, secret token validation, and replay-safe handler.
### 5. Performance Insights
- Traffic is tiny. Performance should not drive selection.
- `telego` and `gotgbot` are more complete than needed.
- Long polling dominates latency. Any library using `getUpdates` is good enough.
- Dependency count matters more than raw throughput for this app.
## Comparative Analysis
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| Keep custom client | Exact behavior, no new deps, easy fakes, offset contract already right | Manual maintenance for new Bot API fields | Best default |
| `mymmrac/telego` | Current, manual `GetUpdates`, topics, typed methods, error type | Heavier deps, Go `1.25.7`, migration still needed | Best if adopting |
| `go-telegram/bot` | Current, zero deps, Bot API 10.0, nice handlers | Polling owns offset, handler model duplicates app command dispatch | Good only if accepting offset model change |
| `gotgbot/v2` | Generated, current, zero third-party deps, manual methods | Latest is RC, generated API can be verbose | Watch, not first choice |
| `telebot.v3` | Mature handler framework, topic support | Framework rewrite, stable tag older, unnecessary features | Not for this bot |
| `telegram-bot-api/v5` | Simple wrapper, classic package | Latest tag old, no topic field in tagged source | Reject |
## Recommendation
### Decision
Keep the custom client now. If user explicitly wants a framework migration, use `telego` only inside `internal/telegram`.
### Why
The app's actual Telegram need is tiny. Migrating only pays off if maintaining Bot API models becomes painful. The current code is closer to the desired operational semantics than most frameworks.
### Acceptance Criteria For A Telego Migration
- `go test ./...` passes.
- `internal/bot` and `internal/poller` interfaces do not import `telego`.
- Existing Telegram client tests still cover:
- `deleteWebhook` keeps pending updates.
- `message_thread_id` included for topic subscriptions.
- token redaction.
- terminal send errors.
- long poll request timeout is `telegram timeout + HTTP_TIMEOUT`.
- Offset saved only after `handleMessage` completes.
## Implementation Notes
### Quick Start
```bash
go get github.com/mymmrac/telego@v1.10.0
```
Then rewrite only `internal/telegram/client.go` and `internal/telegram/types.go` conversion helpers.
### Adapter Sketch
```go
package telegram
import (
"context"
"errors"
"net/http"
"time"
"github.com/mymmrac/telego"
"github.com/mymmrac/telego/telegoapi"
"github.com/mymmrac/telego/telegoutil"
)
type Client struct {
bot *telego.Bot
requestTimeout time.Duration
}
func NewClient(token string, timeout time.Duration) (*Client, error) {
b, err := telego.NewBot(
token,
telego.WithHTTPClient(&http.Client{}),
telego.WithDefaultLogger(false, false),
)
if err != nil {
return nil, err
}
return &Client{bot: b, requestTimeout: timeout}, nil
}
func (c *Client) GetUpdates(ctx context.Context, offset int64, timeoutSeconds int) ([]Update, error) {
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second+c.requestTimeout)
defer cancel()
updates, err := c.bot.GetUpdates(reqCtx, &telego.GetUpdatesParams{
Offset: int(offset),
Timeout: timeoutSeconds,
AllowedUpdates: []string{telego.MessageUpdates},
})
if err != nil {
return nil, mapTelegoError(err)
}
return convertUpdates(updates), nil
}
func (c *Client) SendText(ctx context.Context, chatID int64, threadID *int, text string) error {
reqCtx, cancel := context.WithTimeout(ctx, c.requestTimeout)
defer cancel()
params := &telego.SendMessageParams{
ChatID: telegoutil.ID(chatID),
Text: text,
ParseMode: telego.ModeHTML,
LinkPreviewOptions: &telego.LinkPreviewOptions{IsDisabled: true},
}
if threadID != nil {
params.MessageThreadID = *threadID
}
_, err := c.bot.SendMessage(reqCtx, params)
return mapTelegoError(err)
}
func mapTelegoError(err error) error {
var apiErr *telegoapi.Error
if errors.As(err, &apiErr) {
return &APIError{ErrorCode: apiErr.ErrorCode, Description: apiErr.Description}
}
return err
}
```
This is intentionally a sketch. Validate exact conversion code in tests.
### Common Pitfalls
- Do not use `telego.UpdatesViaLongPolling`; it mutates offset internally before this repo persists offset.
- Do not set `http.Client.Timeout` to `HTTP_TIMEOUT` globally; long polling needs longer.
- Do not leak telego types outside `internal/telegram`.
- Do not drop topic support while converting `MessageThreadID`.
- Do not remove token-redaction coverage without replacing it.
## Resources
### Official Documentation
- Telegram Bot API: https://core.telegram.org/bots/api
- Go package docs, telego: https://pkg.go.dev/github.com/mymmrac/telego
- Go package docs, go-telegram/bot: https://pkg.go.dev/github.com/go-telegram/bot
- Go package docs, gotgbot/v2: https://pkg.go.dev/github.com/PaulSonOfLars/gotgbot/v2
- Go package docs, telebot.v3: https://pkg.go.dev/gopkg.in/telebot.v3
- Go package docs, telegram-bot-api/v5: https://pkg.go.dev/github.com/go-telegram-bot-api/telegram-bot-api/v5
### Repository References
- telego: https://github.com/mymmrac/telego
- go-telegram/bot: https://github.com/go-telegram/bot
- gotgbot: https://github.com/PaulSonOfLars/gotgbot
- telebot: https://github.com/tucnak/telebot
- telegram-bot-api: https://github.com/go-telegram-bot-api/telegram-bot-api
### Local Evidence
- `internal/telegram/client.go`: current custom API wrapper.
- `internal/bot/bot.go`: offset persistence after update handling.
- `README.md`: topic support and long-polling startup behavior.
- `go list -m -json <module>@latest`: current module versions above.
## Appendix A: Glossary
- Bot API: Telegram HTTPS API for bot accounts.
- MTProto: Telegram client protocol. Not needed here.
- Long polling: `getUpdates` request waits for new updates.
- Offset: Telegram update checkpoint. Higher offset confirms older updates.
- Topic: Supergroup forum thread, sent via `message_thread_id`.
## Appendix B: Version Compatibility Matrix
| Project constraint | Custom | telego | go-telegram/bot | gotgbot/v2 | telebot.v3 | telegram-bot-api/v5 |
|---|---:|---:|---:|---:|---:|---:|
| Go 1.25 project | yes | maybe needs 1.25.7 | yes | yes | yes | yes |
| Manual `GetUpdates` | yes | yes | no public manual method found | yes | not primary path | yes |
| Topic send support | yes | yes | yes | yes | yes | no in latest tag |
| Low dependency footprint | yes | no | yes | yes | medium | yes |
| Minimal migration | yes | medium | high | medium | high | medium |
## Next Steps
1. Keep current client unless a concrete Telegram API gap appears.
2. If migrating, create a small telego adapter under `internal/telegram`.
3. Run focused tests first: `go test ./internal/telegram ./internal/bot ./internal/poller`.
4. Then run `go test ./...`.
5. If Go toolchain upgrade appears due telego `go 1.25.7`, decide whether that is acceptable before merge.
## Unresolved Questions
- Is the user goal to reduce maintenance, or to add upcoming Telegram Bot API features?
- Is Go toolchain auto-upgrade to 1.25.7 acceptable if telego requires it?
@@ -0,0 +1,31 @@
## Plan Complete: Switch Telegram layer to go-telegram/bot
### Summary
- **Duration:** 2026-06-26 plan -> 2026-06-26 complete
- **Phases:** 5/5 completed
- **Status:** completed
- **Branch:** develop
- **Tests:** `go test ./...` pass
### Achievements
- Replaced custom Telegram `getUpdates` loop with `github.com/go-telegram/bot@v1.21.0` runtime.
- Kept command behavior in `internal/bot`; added framework update handler and `MessageContext` edge type.
- Replaced custom Telegram HTTP client with `internal/telegram.Sender` adapter for replies and poller notifications.
- Preserved topic replies via `message_thread_id`, HTML parse mode, disabled link preview, terminal subscriber cleanup.
- Preserved startup flow: health endpoint, Mongo setup, offset seed, delete webhook, set commands, poller, Telegram long polling.
- Updated `docs/system-architecture.md` for framework runtime and restart offset semantics.
### Validation
| Check | Result |
|-------|--------|
| Full tests | pass: `go test ./...` |
| Stale custom DTO search | pass: no `telegram.Update`, `telegram.Message`, `GetUpdates`, `NewClient` in app Telegram layer |
| Plan sync | pass: all phases + acceptance criteria checked |
| Docs impact | major: architecture doc updated |
### Known Limitations
- No live Telegram token/network test added; framework integration covered by compile and adapter tests.
- Webhook mode, keyboards, callbacks remain out of scope.
### Unresolved Questions
None.