Compare commits

..

2 commits

Author SHA1 Message Date
d2ffaa92bf Merge branch 'feat/botapi-chat-members' 2026-09-09 16:26:33 +01:00
11dd7660c0 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.
2026-09-09 16:26:33 +01:00
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

View file

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

View file

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