diff --git a/README.md b/README.md index 7ac9bf0..1813cfe 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Telegram bot that watches [OpenAI Status](https://status.openai.com/) every minu - Uses Redis for subscribers, delivery state, component checkpoints, and seen incident update versions - Supports Telegram supergroup topics via `message_thread_id` - Clears an existing Telegram webhook before long polling, for migration from webhook deployments +- Registers the Telegram command menu on startup - Includes Docker Compose for local Redis + bot runtime ## Bot Commands diff --git a/cmd/openai-status-bot/main.go b/cmd/openai-status-bot/main.go index 83d1755..14442c7 100644 --- a/cmd/openai-status-bot/main.go +++ b/cmd/openai-status-bot/main.go @@ -45,6 +45,9 @@ func main() { logger.Error("delete telegram webhook", "error", err) os.Exit(1) } + if err := telegramClient.SetMyCommands(ctx, bot.MenuCommands()); err != nil { + logger.Warn("set telegram bot commands", "error", err) + } botUsername := "" if me, err := telegramClient.GetMe(ctx); err != nil { diff --git a/docs/system-architecture.md b/docs/system-architecture.md index bbe5e5d..0c5adf6 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -38,7 +38,7 @@ Subscriber set members are `chatID` or `chatID:threadID`. Missing settings defau ## Runtime -The service uses Telegram `getUpdates`, so it does not need a public webhook URL. On startup it calls `deleteWebhook` before long polling, which allows migration from a webhook-based deployment using the same bot token. Redis is configured with `REDIS_URL`. Docker Compose starts Redis and the bot. +The service uses Telegram `getUpdates`, so it does not need a public webhook URL. On startup it calls `deleteWebhook` before long polling, registers the Telegram command menu, and then starts polling. Redis is configured with `REDIS_URL`. Docker Compose starts Redis and the bot. ## Failure Behavior diff --git a/internal/bot/menu_commands.go b/internal/bot/menu_commands.go new file mode 100644 index 0000000..e47192b --- /dev/null +++ b/internal/bot/menu_commands.go @@ -0,0 +1,17 @@ +package bot + +import "github.com/tiennm99/openai-status-bot/internal/telegram" + +func MenuCommands() []telegram.BotCommand { + return []telegram.BotCommand{ + {Command: "start", Description: "Subscribe this chat or topic"}, + {Command: "stop", Description: "Unsubscribe this chat or topic"}, + {Command: "status", Description: "Show current OpenAI status"}, + {Command: "components", Description: "Show all component statuses"}, + {Command: "subscribe", Description: "Set notification preferences"}, + {Command: "history", Description: "Show recent incidents"}, + {Command: "uptime", Description: "Show component health overview"}, + {Command: "info", Description: "Show chat and subscription details"}, + {Command: "help", Description: "Show command help"}, + } +} diff --git a/internal/bot/menu_commands_test.go b/internal/bot/menu_commands_test.go new file mode 100644 index 0000000..976a6f8 --- /dev/null +++ b/internal/bot/menu_commands_test.go @@ -0,0 +1,30 @@ +package bot + +import "testing" + +func TestMenuCommandsMatchSupportedCommands(t *testing.T) { + commands := MenuCommands() + got := make([]string, 0, len(commands)) + for _, command := range commands { + if command.Command == "" { + t.Fatal("command is empty") + } + if command.Command[0] == '/' { + t.Fatalf("command %q must not include leading slash", command.Command) + } + if command.Description == "" { + t.Fatalf("description for %q is empty", command.Command) + } + got = append(got, command.Command) + } + + want := []string{"start", "stop", "status", "components", "subscribe", "history", "uptime", "info", "help"} + if len(got) != len(want) { + t.Fatalf("commands = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("commands = %v, want %v", got, want) + } + } +} diff --git a/internal/telegram/client.go b/internal/telegram/client.go index af0667f..a4e0a87 100644 --- a/internal/telegram/client.go +++ b/internal/telegram/client.go @@ -85,6 +85,12 @@ func (c *Client) GetMe(ctx context.Context) (User, error) { 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, diff --git a/internal/telegram/client_test.go b/internal/telegram/client_test.go index e6acaed..4ab9280 100644 --- a/internal/telegram/client_test.go +++ b/internal/telegram/client_test.go @@ -59,6 +59,41 @@ func TestSendTextUsesHTMLAndDisablesPreview(t *testing.T) { } } +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() + + client := &Client{baseURL: server.URL, httpClient: server.Client()} + commands := []BotCommand{ + {Command: "start", Description: "Subscribe"}, + {Command: "status", Description: "Show status"}, + } + 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]) + } + } +} + func TestIsTerminalSendError(t *testing.T) { terminal := &APIError{StatusCode: 200, ErrorCode: 403, Description: "Forbidden: bot was blocked by the user"} if !IsTerminalSendError(terminal) { diff --git a/internal/telegram/types.go b/internal/telegram/types.go index 85160b1..bac376c 100644 --- a/internal/telegram/types.go +++ b/internal/telegram/types.go @@ -24,3 +24,8 @@ type User struct { FirstName string `json:"first_name"` Username string `json:"username"` } + +type BotCommand struct { + Command string `json:"command"` + Description string `json:"description"` +}