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.
This commit is contained in:
Astra 2026-09-09 16:26:33 +01:00
parent f5a0e770c7
commit 22846e340f
5 changed files with 336 additions and 15 deletions

View file

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

View file

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

View file

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