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:
parent
7aab677ce7
commit
f5a0e770c7
5 changed files with 221 additions and 0 deletions
|
|
@ -356,6 +356,45 @@ func apiMediaUsesCaption(media map[string]any) bool {
|
||||||
return false
|
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 {
|
func apiChat(peer domain.Peer, users map[int64]domain.User) map[string]any {
|
||||||
switch peer.Type {
|
switch peer.Type {
|
||||||
case domain.PeerTypeUser:
|
case domain.PeerTypeUser:
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ type WebAppService interface {
|
||||||
|
|
||||||
type GatewayService interface {
|
type GatewayService interface {
|
||||||
BotAPISelf(ctx context.Context, botID int64) (domain.User, error)
|
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)
|
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)
|
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)
|
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) {
|
switch strings.ToLower(method) {
|
||||||
case "getme":
|
case "getme":
|
||||||
h.getMe(w, r, botID)
|
h.getMe(w, r, botID)
|
||||||
|
case "getchat":
|
||||||
|
h.getChat(w, r, botID)
|
||||||
case "setmycommands":
|
case "setmycommands":
|
||||||
h.setMyCommands(w, r, botID)
|
h.setMyCommands(w, r, botID)
|
||||||
case "deletemycommands":
|
case "deletemycommands":
|
||||||
|
|
@ -310,6 +313,30 @@ func (h *handler) getMe(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||||
writeAPIOK(w, apiUser(u))
|
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) {
|
func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64) {
|
||||||
if h.gateway == nil {
|
if h.gateway == nil {
|
||||||
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
|
writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND")
|
||||||
|
|
@ -1457,6 +1484,7 @@ func apiErrorDescription(err error) string {
|
||||||
"BUTTON_URL_INVALID",
|
"BUTTON_URL_INVALID",
|
||||||
"BOT_INVALID",
|
"BOT_INVALID",
|
||||||
"CHAT_ID_INVALID",
|
"CHAT_ID_INVALID",
|
||||||
|
"CHAT_NOT_FOUND",
|
||||||
"ENTITY_INVALID",
|
"ENTITY_INVALID",
|
||||||
"ENTITIES_TOO_LONG",
|
"ENTITIES_TOO_LONG",
|
||||||
"ENTITY_BOUNDS_INVALID",
|
"ENTITY_BOUNDS_INVALID",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"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) {
|
func TestBotCommandsPreserveEphemeralFlag(t *testing.T) {
|
||||||
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}}
|
||||||
h := (&handler{bots: bots}).routes()
|
h := (&handler{bots: bots}).routes()
|
||||||
|
|
@ -1442,6 +1497,10 @@ func (f *fakeWebAppService) SavePreparedInlineMessageFromBotAPI(_ context.Contex
|
||||||
type fakeBotAPIGateway struct {
|
type fakeBotAPIGateway struct {
|
||||||
self domain.User
|
self domain.User
|
||||||
|
|
||||||
|
chat domain.BotAPIChat
|
||||||
|
chatErr error
|
||||||
|
chatChatID int64
|
||||||
|
|
||||||
updates []domain.UpdateEvent
|
updates []domain.UpdateEvent
|
||||||
updateBotID int64
|
updateBotID int64
|
||||||
updateOffset int64
|
updateOffset int64
|
||||||
|
|
@ -1502,6 +1561,11 @@ func (f *fakeBotAPIGateway) BotAPISelf(context.Context, int64) (domain.User, err
|
||||||
return f.self, nil
|
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) {
|
func (f *fakeBotAPIGateway) BotAPIUpdates(_ context.Context, botID int64, offset int64) ([]domain.UpdateEvent, error) {
|
||||||
f.updateBotID = botID
|
f.updateBotID = botID
|
||||||
f.updateOffset = offset
|
f.updateOffset = offset
|
||||||
|
|
|
||||||
18
internal/domain/botapi_chat.go
Normal file
18
internal/domain/botapi_chat.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,78 @@ func (r *Router) BotAPISelf(ctx context.Context, botID int64) (domain.User, erro
|
||||||
return u, nil
|
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
|
// BotAPIUpdates returns durable update_id based events projected for the HTTP
|
||||||
// Bot API. New deployments use the dedicated Bot API queue; the legacy
|
// 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.
|
// user_update_events fallback is kept for tests that have not wired the queue.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue