From d022521d679cf801761985e4cbf20d702df8759f Mon Sep 17 00:00:00 2001 From: Astra Date: Wed, 9 Sep 2026 16:14:49 +0100 Subject: [PATCH] botapi: implement getChat Adds the getChat method to the HTTP Bot API gateway. Numeric chat_id only (no @username). Resolution goes through the shared peer resolvers: - user: ByID; unknown -> CHAT_NOT_FOUND - channel/supergroup: ResolveChannel, so a public one resolves even when the bot is not a member (projected as a preview); a private one the bot cannot access -> CHAT_NOT_FOUND, a banned bot likewise The Chat projection returns id (bot-api encoded), type ("channel" for a broadcast, "supergroup" for a megagroup, "private" for a user), title, username, first/last name, description, is_forum, and the scam/fake/verified flags. --- internal/botapi/projection.go | 39 ++++++++++++++++++ internal/botapi/server.go | 28 +++++++++++++ internal/botapi/server_test.go | 64 ++++++++++++++++++++++++++++++ internal/domain/botapi_chat.go | 18 +++++++++ internal/rpc/botapi_gateway.go | 72 ++++++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+) create mode 100644 internal/domain/botapi_chat.go diff --git a/internal/botapi/projection.go b/internal/botapi/projection.go index 6c4c10ca..57b180ae 100644 --- a/internal/botapi/projection.go +++ b/internal/botapi/projection.go @@ -356,6 +356,45 @@ func apiMediaUsesCaption(media map[string]any) bool { return false } +// apiChatFull projects a getChat result. The bot-api chat-id encoding is applied +// here: users keep their positive id, channels/supergroups become +// -1000000000000 - channelID. +func apiChatFull(chat domain.BotAPIChat) map[string]any { + id := chat.Peer.ID + if chat.Peer.Type == domain.PeerTypeChannel { + id = -1000000000000 - chat.Peer.ID + } + out := map[string]any{"id": id, "type": chat.Type} + if chat.Title != "" { + out["title"] = chat.Title + } + if chat.Username != "" { + out["username"] = chat.Username + } + if chat.FirstName != "" { + out["first_name"] = chat.FirstName + } + if chat.LastName != "" { + out["last_name"] = chat.LastName + } + if chat.Description != "" { + out["description"] = chat.Description + } + if chat.IsForum { + out["is_forum"] = true + } + if chat.Verified { + out["is_verified"] = true + } + if chat.Scam { + out["is_scam"] = true + } + if chat.Fake { + out["is_fake"] = true + } + return out +} + func apiChat(peer domain.Peer, users map[int64]domain.User) map[string]any { switch peer.Type { case domain.PeerTypeUser: diff --git a/internal/botapi/server.go b/internal/botapi/server.go index 7bfcef76..9ef3aac9 100644 --- a/internal/botapi/server.go +++ b/internal/botapi/server.go @@ -41,6 +41,7 @@ type WebAppService interface { type GatewayService interface { BotAPISelf(ctx context.Context, botID int64) (domain.User, error) + BotAPIChat(ctx context.Context, botID, chatID int64) (domain.BotAPIChat, error) BotAPIUpdates(ctx context.Context, botID int64, offset int64) ([]domain.UpdateEvent, error) BotAPISendMessage(ctx context.Context, botID, chatID int64, text string, entities []domain.MessageEntity, replyMarkup *domain.MessageReplyMarkup, disableWebPagePreview, silent bool, replyToMessageID int) (domain.Message, error) BotAPISendRichMessage(ctx context.Context, botID, chatID int64, rich domain.BotAPIRichMessageInput, replyMarkup *domain.MessageReplyMarkup, silent, noForwards bool, replyToMessageID int, effectID int64) (domain.Message, error) @@ -197,6 +198,8 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) { switch strings.ToLower(method) { case "getme": h.getMe(w, r, botID) + case "getchat": + h.getChat(w, r, botID) case "setmycommands": h.setMyCommands(w, r, botID) case "deletemycommands": @@ -310,6 +313,30 @@ func (h *handler) getMe(w http.ResponseWriter, r *http.Request, botID int64) { writeAPIOK(w, apiUser(u)) } +func (h *handler) getChat(w http.ResponseWriter, r *http.Request, botID int64) { + if h.gateway == nil { + writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND") + return + } + values, err := requestValues(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + // Numeric chat_id only - no @username resolution. + chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64) + if err != nil || chatID == 0 { + writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID") + return + } + chat, err := h.gateway.BotAPIChat(r.Context(), botID, chatID) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + writeAPIOK(w, apiChatFull(chat)) +} + func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64) { if h.gateway == nil { writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND") @@ -1457,6 +1484,7 @@ func apiErrorDescription(err error) string { "BUTTON_URL_INVALID", "BOT_INVALID", "CHAT_ID_INVALID", + "CHAT_NOT_FOUND", "ENTITY_INVALID", "ENTITIES_TOO_LONG", "ENTITY_BOUNDS_INVALID", diff --git a/internal/botapi/server_test.go b/internal/botapi/server_test.go index b4b36fdc..90cbf002 100644 --- a/internal/botapi/server_test.go +++ b/internal/botapi/server_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "mime/multipart" "net/http" @@ -161,6 +162,60 @@ func TestGetMeUsesGateway(t *testing.T) { } } +func TestGetChatUsesGateway(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{ + chat: domain.BotAPIChat{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 42}, + Type: "supergroup", + Title: "Test", + Username: "test1", + Description: "a test group", + IsForum: true, + }, + } + h := (&handler{bots: bots, gateway: gateway}).routes() + + rec := performBotAPIRequest(t, h, bots.profile, "getChat", `{"chat_id":-1000000000042}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if gateway.chatChatID != -1000000000042 { + t.Fatalf("gateway chat_id = %d, want -1000000000042", gateway.chatChatID) + } + var resp struct { + OK bool `json:"ok"` + Result struct { + ID int64 `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Username string `json:"username"` + Description string `json:"description"` + IsForum bool `json:"is_forum"` + } `json:"result"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if !resp.OK || resp.Result.ID != -1000000000042 || resp.Result.Type != "supergroup" || + resp.Result.Username != "test1" || resp.Result.Description != "a test group" || !resp.Result.IsForum { + t.Fatalf("response = %s", rec.Body.String()) + } + + // A private chat the bot cannot see comes back as chat not found. + gateway.chatErr = errors.New("CHAT_NOT_FOUND") + rec = performBotAPIRequest(t, h, bots.profile, "getChat", `{"chat_id":-1000000000099}`) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "CHAT_NOT_FOUND") { + t.Fatalf("not-found response status=%d body=%s", rec.Code, rec.Body.String()) + } + + // @username is rejected before it reaches the gateway. + rec = performBotAPIRequest(t, h, bots.profile, "getChat", `{"chat_id":"@test1"}`) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "CHAT_ID_INVALID") { + t.Fatalf("username response status=%d body=%s", rec.Code, rec.Body.String()) + } +} + func TestBotCommandsPreserveEphemeralFlag(t *testing.T) { bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} h := (&handler{bots: bots}).routes() @@ -1442,6 +1497,10 @@ func (f *fakeWebAppService) SavePreparedInlineMessageFromBotAPI(_ context.Contex type fakeBotAPIGateway struct { self domain.User + chat domain.BotAPIChat + chatErr error + chatChatID int64 + updates []domain.UpdateEvent updateBotID int64 updateOffset int64 @@ -1502,6 +1561,11 @@ func (f *fakeBotAPIGateway) BotAPISelf(context.Context, int64) (domain.User, err return f.self, nil } +func (f *fakeBotAPIGateway) BotAPIChat(_ context.Context, _ int64, chatID int64) (domain.BotAPIChat, error) { + f.chatChatID = chatID + return f.chat, f.chatErr +} + func (f *fakeBotAPIGateway) BotAPIUpdates(_ context.Context, botID int64, offset int64) ([]domain.UpdateEvent, error) { f.updateBotID = botID f.updateOffset = offset diff --git a/internal/domain/botapi_chat.go b/internal/domain/botapi_chat.go new file mode 100644 index 00000000..019019ac --- /dev/null +++ b/internal/domain/botapi_chat.go @@ -0,0 +1,18 @@ +package domain + +// BotAPIChat is a peer resolved for the Bot API getChat method. The bot need +// not be a member: a public channel or supergroup resolves (projected as a +// preview), while a private chat the bot has no access to resolves to an error. +type BotAPIChat struct { + Peer Peer // domain peer; the Bot API chat-id encoding is applied by the projection + Type string // "private" | "group" | "supergroup" | "channel" + Title string + Username string + FirstName string + LastName string + Description string // channel/supergroup "about" + IsForum bool + Verified bool + Scam bool + Fake bool +} diff --git a/internal/rpc/botapi_gateway.go b/internal/rpc/botapi_gateway.go index c6a02a56..e89c1b5a 100644 --- a/internal/rpc/botapi_gateway.go +++ b/internal/rpc/botapi_gateway.go @@ -33,6 +33,78 @@ func (r *Router) BotAPISelf(ctx context.Context, botID int64) (domain.User, erro return u, nil } +// BotAPIChat resolves a chat for the Bot API getChat method. chat_id is numeric +// only (no @username). Public channels/supergroups resolve even when the bot is +// not a member; a private chat the bot cannot access is CHAT_NOT_FOUND. +func (r *Router) BotAPIChat(ctx context.Context, botID, chatID int64) (domain.BotAPIChat, error) { + if r == nil || botID == 0 { + return domain.BotAPIChat{}, errors.New("BOT_INVALID") + } + peer, ok := botAPIPeerFromChatID(chatID) + if !ok { + return domain.BotAPIChat{}, errors.New("CHAT_ID_INVALID") + } + switch peer.Type { + case domain.PeerTypeUser: + if r.deps.Users == nil { + return domain.BotAPIChat{}, errors.New("CHAT_NOT_FOUND") + } + u, found, err := r.deps.Users.ByID(ctx, botID, peer.ID) + if err != nil { + return domain.BotAPIChat{}, err + } + if !found { + return domain.BotAPIChat{}, errors.New("CHAT_NOT_FOUND") + } + return domain.BotAPIChat{ + Peer: peer, + Type: "private", + FirstName: u.FirstName, + LastName: u.LastName, + Username: u.Username, + Verified: u.Verified, + Scam: u.Scam, + Fake: u.Fake, + }, nil + case domain.PeerTypeChannel: + if r.deps.Channels == nil { + return domain.BotAPIChat{}, errors.New("CHAT_NOT_FOUND") + } + view, err := r.deps.Channels.ResolveChannel(ctx, botID, peer.ID) + if err != nil { + return domain.BotAPIChat{}, botAPIChatErr(err) + } + ch := view.Channel + typ := "supergroup" + if ch.Broadcast && !ch.Megagroup { + typ = "channel" + } + return domain.BotAPIChat{ + Peer: peer, + Type: typ, + Title: ch.Title, + Username: ch.Username, + Description: ch.About, + IsForum: ch.Forum, + Verified: ch.Verified, + Scam: ch.Scam, + Fake: ch.Fake, + }, nil + } + return domain.BotAPIChat{}, errors.New("CHAT_ID_INVALID") +} + +func botAPIChatErr(err error) error { + switch { + case errors.Is(err, domain.ErrChannelInvalid), + errors.Is(err, domain.ErrChannelPrivate), + errors.Is(err, domain.ErrChannelUserBanned): + return errors.New("CHAT_NOT_FOUND") + default: + return channelInvalidErr(err) + } +} + // BotAPIUpdates returns durable update_id based events projected for the HTTP // Bot API. New deployments use the dedicated Bot API queue; the legacy // user_update_events fallback is kept for tests that have not wired the queue.