From 11dd7660c0ba03b5d88ffaf46100624f32ea7d06 Mon Sep 17 00:00:00 2001 From: Astra Date: Wed, 9 Sep 2026 16:26:33 +0100 Subject: [PATCH] botapi: getChatMemberCount, getChatMember, and a fuller getChat - getChatMemberCount: channel/supergroup participant count (numeric chat_id). - getChatMember: resolves a member via GetParticipant, projected to a Bot API ChatMember (creator/administrator/restricted/member/left/kicked with the matching rights); a user simply not in an accessible chat returns "left". - getChat now uses the full channel view and adds permissions (from the default restrictions), slow_mode_delay, linked_chat_id and pinned_message. Channel-only methods reject user chat_ids; private chats the bot cannot access return CHAT_NOT_FOUND. --- internal/botapi/projection.go | 118 +++++++++++++++++++++++++++++++++ internal/botapi/server.go | 57 ++++++++++++++++ internal/botapi/server_test.go | 68 ++++++++++++++++++- internal/domain/botapi_chat.go | 14 ++++ internal/rpc/botapi_gateway.go | 94 ++++++++++++++++++++++---- 5 files changed, 336 insertions(+), 15 deletions(-) diff --git a/internal/botapi/projection.go b/internal/botapi/projection.go index 57b180ae..ddfc39f6 100644 --- a/internal/botapi/projection.go +++ b/internal/botapi/projection.go @@ -392,9 +392,127 @@ func apiChatFull(chat domain.BotAPIChat) map[string]any { if chat.Fake { out["is_fake"] = true } + if chat.SlowModeDelay > 0 { + out["slow_mode_delay"] = chat.SlowModeDelay + } + if chat.LinkedChatID != 0 { + out["linked_chat_id"] = -1000000000000 - chat.LinkedChatID + } + if chat.Permissions != nil { + out["permissions"] = apiChatPermissions(*chat.Permissions) + } + if chat.PinnedMessage != nil { + out["pinned_message"] = apiMessage(*chat.PinnedMessage, chat.PinnedMessageUsers) + } return out } +// apiChatPermissions projects a channel's default restrictions as a Bot API +// ChatPermissions object (a right is granted when the matching restriction is +// off). +func apiChatPermissions(b domain.ChannelBannedRights) map[string]any { + text := !b.SendMessages && !b.SendPlain + return map[string]any{ + "can_send_messages": text, + "can_send_audios": !b.SendMedia && !b.SendAudios, + "can_send_documents": !b.SendMedia && !b.SendDocs, + "can_send_photos": !b.SendMedia && !b.SendPhotos, + "can_send_videos": !b.SendMedia && !b.SendVideos, + "can_send_video_notes": !b.SendMedia && !b.SendRoundvideos, + "can_send_voice_notes": !b.SendMedia && !b.SendVoices, + "can_send_polls": !b.SendPolls, + "can_send_other_messages": !b.SendStickers && !b.SendGifs && !b.SendGames && !b.SendInline, + "can_add_web_page_previews": !b.EmbedLinks, + "can_change_info": !b.ChangeInfo, + "can_invite_users": !b.InviteUsers, + "can_pin_messages": !b.PinMessages, + "can_manage_topics": !b.ManageTopics, + } +} + +// apiChatMember projects a resolved member as a Bot API ChatMember object. +func apiChatMember(m domain.BotAPIChatMember) map[string]any { + out := map[string]any{ + "status": botAPIMemberStatus(m.Member), + "user": apiUser(userOrPlaceholder(m.User, m.Member.UserID)), + } + switch out["status"] { + case "creator": + if m.Member.AdminRights.Anonymous { + out["is_anonymous"] = true + } + if m.Member.Rank != "" { + out["custom_title"] = m.Member.Rank + } + case "administrator": + a := m.Member.AdminRights + out["can_be_edited"] = false + out["is_anonymous"] = a.Anonymous + out["can_manage_chat"] = a.ManageChat + out["can_delete_messages"] = a.DeleteMessages + out["can_manage_video_chats"] = a.ManageCall + out["can_restrict_members"] = a.BanUsers + out["can_promote_members"] = a.AddAdmins + out["can_change_info"] = a.ChangeInfo + out["can_invite_users"] = a.InviteUsers + out["can_post_messages"] = a.PostMessages + out["can_edit_messages"] = a.EditMessages + out["can_pin_messages"] = a.PinMessages + out["can_manage_topics"] = a.ManageTopics + out["can_post_stories"] = a.PostStories + out["can_edit_stories"] = a.EditStories + out["can_delete_stories"] = a.DeleteStories + if m.Member.Rank != "" { + out["custom_title"] = m.Member.Rank + } + case "restricted": + b := m.Member.BannedRights + out["is_member"] = m.Member.Status == domain.ChannelMemberActive + for k, v := range apiChatPermissions(b) { + out[k] = v + } + if b.UntilDate > 0 { + out["until_date"] = b.UntilDate + } + case "kicked": + if m.Member.BannedRights.UntilDate > 0 { + out["until_date"] = m.Member.BannedRights.UntilDate + } + } + return out +} + +func userOrPlaceholder(u domain.User, id int64) domain.User { + if u.ID != 0 { + return u + } + return domain.User{ID: id} +} + +func botAPIMemberStatus(m domain.ChannelMember) string { + switch { + case m.Role == domain.ChannelRoleCreator: + return "creator" + case m.Status == domain.ChannelMemberKicked, m.Status == domain.ChannelMemberBanned, m.BannedRights.ViewMessages: + return "kicked" + case m.Role == domain.ChannelRoleAdmin: + return "administrator" + case m.Status == domain.ChannelMemberLeft: + return "left" + case botAPIMemberRestricted(m.BannedRights): + return "restricted" + default: + return "member" + } +} + +func botAPIMemberRestricted(b domain.ChannelBannedRights) bool { + return b.SendMessages || b.SendMedia || b.SendStickers || b.SendGifs || b.SendGames || + b.SendInline || b.EmbedLinks || b.SendPolls || b.ChangeInfo || b.InviteUsers || + b.PinMessages || b.ManageTopics || b.SendPhotos || b.SendVideos || b.SendRoundvideos || + b.SendAudios || b.SendVoices || b.SendDocs || b.SendPlain || b.SendReactions +} + 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 9ef3aac9..19a40719 100644 --- a/internal/botapi/server.go +++ b/internal/botapi/server.go @@ -42,6 +42,8 @@ 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) + BotAPIChatMemberCount(ctx context.Context, botID, chatID int64) (int, error) + BotAPIChatMember(ctx context.Context, botID, chatID, userID int64) (domain.BotAPIChatMember, 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) @@ -200,6 +202,10 @@ func (h *handler) handle(w http.ResponseWriter, r *http.Request) { h.getMe(w, r, botID) case "getchat": h.getChat(w, r, botID) + case "getchatmembercount", "getchatmemberscount": + h.getChatMemberCount(w, r, botID) + case "getchatmember": + h.getChatMember(w, r, botID) case "setmycommands": h.setMyCommands(w, r, botID) case "deletemycommands": @@ -337,6 +343,57 @@ func (h *handler) getChat(w http.ResponseWriter, r *http.Request, botID int64) { writeAPIOK(w, apiChatFull(chat)) } +func (h *handler) getChatMemberCount(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 + } + chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64) + if err != nil || chatID == 0 { + writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID") + return + } + count, err := h.gateway.BotAPIChatMemberCount(r.Context(), botID, chatID) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + writeAPIOK(w, count) +} + +func (h *handler) getChatMember(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 + } + chatID, err := strconv.ParseInt(strings.TrimSpace(values["chat_id"]), 10, 64) + if err != nil || chatID == 0 { + writeAPIError(w, http.StatusBadRequest, "CHAT_ID_INVALID") + return + } + userID, err := strconv.ParseInt(strings.TrimSpace(values["user_id"]), 10, 64) + if err != nil || userID <= 0 { + writeAPIError(w, http.StatusBadRequest, "USER_ID_INVALID") + return + } + member, err := h.gateway.BotAPIChatMember(r.Context(), botID, chatID, userID) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + writeAPIOK(w, apiChatMember(member)) +} + func (h *handler) getUpdates(w http.ResponseWriter, r *http.Request, botID int64) { if h.gateway == nil { writeAPIError(w, http.StatusNotImplemented, "METHOD_NOT_FOUND") diff --git a/internal/botapi/server_test.go b/internal/botapi/server_test.go index 90cbf002..60600c0a 100644 --- a/internal/botapi/server_test.go +++ b/internal/botapi/server_test.go @@ -216,6 +216,57 @@ func TestGetChatUsesGateway(t *testing.T) { } } +func TestGetChatMemberCountAndMember(t *testing.T) { + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + gateway := &fakeBotAPIGateway{ + memberCount: 7, + member: domain.BotAPIChatMember{ + User: domain.User{ID: 500, FirstName: "Ann"}, + Member: domain.ChannelMember{ + UserID: 500, Role: domain.ChannelRoleAdmin, Status: domain.ChannelMemberActive, + AdminRights: domain.ChannelAdminRights{BanUsers: true, PinMessages: true}, + Rank: "mod", + }, + }, + } + h := (&handler{bots: bots, gateway: gateway}).routes() + + rec := performBotAPIRequest(t, h, bots.profile, "getChatMemberCount", `{"chat_id":-1000000000042}`) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"result":7`) { + t.Fatalf("getChatMemberCount status=%d body=%s", rec.Code, rec.Body.String()) + } + + rec = performBotAPIRequest(t, h, bots.profile, "getChatMember", `{"chat_id":-1000000000042,"user_id":500}`) + if rec.Code != http.StatusOK { + t.Fatalf("getChatMember status=%d body=%s", rec.Code, rec.Body.String()) + } + var resp struct { + OK bool `json:"ok"` + Result struct { + Status string `json:"status"` + CustomTitle string `json:"custom_title"` + CanRestrict bool `json:"can_restrict_members"` + CanPromote bool `json:"can_promote_members"` + User struct { + ID int64 `json:"id"` + } `json:"user"` + } `json:"result"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if !resp.OK || resp.Result.Status != "administrator" || resp.Result.User.ID != 500 || + resp.Result.CustomTitle != "mod" || !resp.Result.CanRestrict || resp.Result.CanPromote { + t.Fatalf("getChatMember result = %s", rec.Body.String()) + } + + // user_id is required. + rec = performBotAPIRequest(t, h, bots.profile, "getChatMember", `{"chat_id":-1000000000042}`) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "USER_ID_INVALID") { + t.Fatalf("missing user_id 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() @@ -1497,9 +1548,12 @@ func (f *fakeWebAppService) SavePreparedInlineMessageFromBotAPI(_ context.Contex type fakeBotAPIGateway struct { self domain.User - chat domain.BotAPIChat - chatErr error - chatChatID int64 + chat domain.BotAPIChat + chatErr error + chatChatID int64 + memberCount int + member domain.BotAPIChatMember + memberErr error updates []domain.UpdateEvent updateBotID int64 @@ -1566,6 +1620,14 @@ func (f *fakeBotAPIGateway) BotAPIChat(_ context.Context, _ int64, chatID int64) return f.chat, f.chatErr } +func (f *fakeBotAPIGateway) BotAPIChatMemberCount(context.Context, int64, int64) (int, error) { + return f.memberCount, f.memberErr +} + +func (f *fakeBotAPIGateway) BotAPIChatMember(context.Context, int64, int64, int64) (domain.BotAPIChatMember, error) { + return f.member, f.memberErr +} + 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 index 019019ac..5b2172d9 100644 --- a/internal/domain/botapi_chat.go +++ b/internal/domain/botapi_chat.go @@ -15,4 +15,18 @@ type BotAPIChat struct { Verified bool Scam bool Fake bool + + // Channel/supergroup only, from the full view. + SlowModeDelay int + LinkedChatID int64 // domain channel id; the projection applies the Bot API encoding + Permissions *ChannelBannedRights // default restrictions; nil for a user chat + PinnedMessage *Message + PinnedMessageUsers []User +} + +// BotAPIChatMember is a resolved chat member for the Bot API getChatMember +// method. +type BotAPIChatMember struct { + User User + Member ChannelMember } diff --git a/internal/rpc/botapi_gateway.go b/internal/rpc/botapi_gateway.go index e89c1b5a..8bca5446 100644 --- a/internal/rpc/botapi_gateway.go +++ b/internal/rpc/botapi_gateway.go @@ -70,7 +70,7 @@ func (r *Router) BotAPIChat(ctx context.Context, botID, chatID int64) (domain.Bo if r.deps.Channels == nil { return domain.BotAPIChat{}, errors.New("CHAT_NOT_FOUND") } - view, err := r.deps.Channels.ResolveChannel(ctx, botID, peer.ID) + view, err := r.deps.Channels.GetChannel(ctx, botID, peer.ID) if err != nil { return domain.BotAPIChat{}, botAPIChatErr(err) } @@ -79,21 +79,91 @@ func (r *Router) BotAPIChat(ctx context.Context, botID, chatID int64) (domain.Bo 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 + out := 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, + SlowModeDelay: ch.SlowmodeSeconds, + LinkedChatID: ch.LinkedChatID, + } + if typ == "supergroup" { + perms := ch.DefaultBannedRights + out.Permissions = &perms + } + if ch.PinnedMessageID > 0 { + if hist, msgErr := r.deps.Channels.GetMessages(ctx, botID, peer.ID, []int{ch.PinnedMessageID}); msgErr == nil && len(hist.Messages) > 0 { + pinned := botAPIMessageFromChannel(botID, hist.Messages[0]) + out.PinnedMessage = &pinned + out.PinnedMessageUsers = hist.Users + } + } + return out, nil } return domain.BotAPIChat{}, errors.New("CHAT_ID_INVALID") } +// BotAPIChatMemberCount resolves getChatMemberCount. Channels/supergroups only. +func (r *Router) BotAPIChatMemberCount(ctx context.Context, botID, chatID int64) (int, error) { + if r == nil || botID == 0 { + return 0, errors.New("BOT_INVALID") + } + peer, ok := botAPIPeerFromChatID(chatID) + if !ok || peer.Type != domain.PeerTypeChannel { + return 0, errors.New("CHAT_ID_INVALID") + } + if r.deps.Channels == nil { + return 0, errors.New("CHAT_NOT_FOUND") + } + view, err := r.deps.Channels.ResolveChannel(ctx, botID, peer.ID) + if err != nil { + return 0, botAPIChatErr(err) + } + return view.Channel.ParticipantsCount, nil +} + +// BotAPIChatMember resolves getChatMember. Channels/supergroups only. +func (r *Router) BotAPIChatMember(ctx context.Context, botID, chatID, userID int64) (domain.BotAPIChatMember, error) { + if r == nil || botID == 0 { + return domain.BotAPIChatMember{}, errors.New("BOT_INVALID") + } + if userID <= 0 { + return domain.BotAPIChatMember{}, errors.New("USER_ID_INVALID") + } + peer, ok := botAPIPeerFromChatID(chatID) + if !ok || peer.Type != domain.PeerTypeChannel { + return domain.BotAPIChatMember{}, errors.New("CHAT_ID_INVALID") + } + if r.deps.Channels == nil { + return domain.BotAPIChatMember{}, errors.New("CHAT_NOT_FOUND") + } + member, err := r.deps.Channels.GetParticipant(ctx, botID, peer.ID, userID) + switch { + case err == nil: + case errors.Is(err, domain.ErrUserNotParticipant): + // Bot API returns a "left" member for a user who is simply not in the + // chat, as long as the chat itself is accessible. + member = domain.ChannelMember{ChannelID: peer.ID, UserID: userID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberLeft} + default: + return domain.BotAPIChatMember{}, botAPIChatErr(err) + } + out := domain.BotAPIChatMember{Member: member} + if r.deps.Users != nil { + if u, found, uErr := r.deps.Users.ByID(ctx, botID, userID); uErr == nil && found { + out.User = u + } + } + if out.User.ID == 0 { + out.User = domain.User{ID: userID} + } + return out, nil +} + func botAPIChatErr(err error) error { switch { case errors.Is(err, domain.ErrChannelInvalid),