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.
This commit is contained in:
Astra 2026-09-09 16:14:49 +01:00
parent 78267ee0d3
commit d022521d67
5 changed files with 221 additions and 0 deletions

View file

@ -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