feat(bot): register telegram command menu

This commit is contained in:
tiennm99 committed 2026-06-22 10:15:50 +07:00
1 parent aa425ec98d
commit 956b2a98b4
8 files changed
+98 -1

No files matched your search

+1
View File
@@ -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
+3
View File
@@ -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 {
+1 -1
View File
@@ -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
+17
View File
@@ -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"},
}
}
+30
View File
@@ -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)
}
}
}
+6
View File
@@ -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,
+35
View File
@@ -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) {
+5
View File
@@ -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"`
}