mirror of
https://github.com/tiennm99/openai-status-bot.git
synced 2026-10-11 03:13:41 +00:00
refactor(telegram): use go-telegram-bot runtime
This commit is contained in:
1 parent
b3ab6317fe
commit
70ecf12498
21 files changed
+1242
-470
No files matched your search
@@ -4,11 +4,15 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
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"
|
||||
@@ -70,31 +74,92 @@ func main() {
|
||||
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)
|
||||
|
||||
ready.Store(true)
|
||||
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)
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+19
-13
@@ -2,24 +2,25 @@
|
||||
|
||||
## 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.
|
||||
|
||||
MongoDB 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 MongoDB.
|
||||
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 MongoDB 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.
|
||||
|
||||
## MongoDB Collections
|
||||
|
||||
@@ -30,21 +31,26 @@ MongoDB stores subscribers, subscription settings, polling checkpoints, delivery
|
||||
| `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 last processed Telegram update offset |
|
||||
| `meta` | `initialized` or `telegramOffset` | value | Baseline seed marker and next Telegram update offset to use on restart |
|
||||
|
||||
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. 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.
|
||||
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.
|
||||
- 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.
|
||||
@@ -3,6 +3,7 @@ module github.com/tiennm99/openai-status-bot
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
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
|
||||
)
|
||||
|
||||
@@ -41,6 +41,8 @@ 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=
|
||||
|
||||
+62
-61
@@ -2,18 +2,16 @@ package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
openai "github.com/tiennm99/openai-status-bot/internal/openai"
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
tgmodels "github.com/go-telegram/bot/models"
|
||||
"github.com/tiennm99/openai-status-bot/internal/mongostore"
|
||||
"github.com/tiennm99/openai-status-bot/internal/telegram"
|
||||
openai "github.com/tiennm99/openai-status-bot/internal/openai"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -27,72 +25,75 @@ type Store interface {
|
||||
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 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 := mongostore.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 := mongostore.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.")
|
||||
|
||||
+59
-44
@@ -2,32 +2,27 @@ package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
openai "github.com/tiennm99/openai-status-bot/internal/openai"
|
||||
tgmodels "github.com/go-telegram/bot/models"
|
||||
"github.com/tiennm99/openai-status-bot/internal/mongostore"
|
||||
"github.com/tiennm99/openai-status-bot/internal/telegram"
|
||||
openai "github.com/tiennm99/openai-status-bot/internal/openai"
|
||||
)
|
||||
|
||||
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,11 +39,11 @@ func (f fakeBotStatusClient) FetchIncidents(context.Context) (openai.IncidentsRe
|
||||
}
|
||||
|
||||
type fakeBotStore struct {
|
||||
sub mongostore.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 mongostore.Subscriber) error {
|
||||
@@ -73,8 +68,10 @@ func (f *fakeBotStore) RemoveSubscriber(context.Context, mongostore.Subscriber)
|
||||
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, _ mongostore.Subscriber, types []string) (bool, error) {
|
||||
if !f.subscribed {
|
||||
@@ -107,7 +104,7 @@ func TestSubscribeComponentStoresComponentID(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -5,13 +5,12 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tiennm99/openai-status-bot/internal/poller"
|
||||
"github.com/tiennm99/openai-status-bot/internal/mongostore"
|
||||
"github.com/tiennm99/openai-status-bot/internal/telegram"
|
||||
"github.com/tiennm99/openai-status-bot/internal/poller"
|
||||
)
|
||||
|
||||
func (b *Bot) replySubscribe(ctx context.Context, message telegram.Message, fields []string) {
|
||||
sub := mongostore.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)
|
||||
@@ -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 mongostore.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
|
||||
@@ -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 := mongostore.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)
|
||||
|
||||
@@ -5,12 +5,11 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tiennm99/openai-status-bot/internal/poller"
|
||||
"github.com/tiennm99/openai-status-bot/internal/mongostore"
|
||||
"github.com/tiennm99/openai-status-bot/internal/telegram"
|
||||
"github.com/tiennm99/openai-status-bot/internal/poller"
|
||||
)
|
||||
|
||||
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,9 +44,9 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
+75
-156
@@ -1,23 +1,26 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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
|
||||
token 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 {
|
||||
@@ -33,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) {
|
||||
@@ -63,152 +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,
|
||||
token: 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 mongostore.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)
|
||||
}
|
||||
|
||||
// redactToken returns an error whose message has the bot token replaced with a
|
||||
// placeholder. It leaves the error untouched when no token is set or the token
|
||||
// does not appear in the message, so typed errors are preserved where possible.
|
||||
func (c *Client) redactToken(err error) error {
|
||||
if err == nil || c.token == "" {
|
||||
return err
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, c.token) {
|
||||
return err
|
||||
}
|
||||
return errors.New(strings.ReplaceAll(msg, c.token, "<redacted>"))
|
||||
}
|
||||
|
||||
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 {
|
||||
// Transport errors are *url.Error and embed the full request URL,
|
||||
// which contains the bot token. Strip it before the error propagates
|
||||
// to logs.
|
||||
return c.redactToken(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
|
||||
}
|
||||
@@ -2,184 +2,119 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
}
|
||||
if apiErr.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusTooManyRequests)
|
||||
if IsTerminalSendError(err) {
|
||||
t.Fatal("parse errors should not remove subscribers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportErrorRedactsBotToken(t *testing.T) {
|
||||
const token = "123456:super-secret-token"
|
||||
// Port 0 is reserved and never listens, forcing a transport-level dial
|
||||
// error whose *url.Error message embeds the token-bearing URL.
|
||||
client := &Client{
|
||||
baseURL: "http://127.0.0.1:0/bot" + token,
|
||||
token: token,
|
||||
httpClient: &http.Client{},
|
||||
requestTimeout: time.Second,
|
||||
}
|
||||
err := client.SendText(context.Background(), 123, nil, "hello")
|
||||
framework := &fakeFrameworkSender{err: errors.New("Post https://api.telegram.org/bot" + token + "/sendMessage: dial tcp")}
|
||||
sender := NewSender(framework, token, 0)
|
||||
|
||||
err := sender.SendText(context.Background(), 123, nil, "hello")
|
||||
if err == nil {
|
||||
t.Fatal("expected transport error, got nil")
|
||||
}
|
||||
@@ -190,16 +125,3 @@ func TestTransportErrorRedactsBotToken(t *testing.T) {
|
||||
t.Fatalf("error missing redaction placeholder: %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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
+83
@@ -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.
|
||||
Reference in new issue
Block a user