Skip to content

Commit ea865f3

Browse files
ernadoclaude
andcommitted
feat: add reactions, join requests and sender-chat ban
SetMessageReaction (messages.sendReaction), ApproveChatJoinRequest / DeclineChatJoinRequest (messages.hideChatJoinRequest), and BanChatSenderChat / UnbanChatSenderChat (channels.editBanned). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0751859 commit ea865f3

4 files changed

Lines changed: 311 additions & 0 deletions

File tree

chat_join.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
6+
"github.com/gotd/td/tg"
7+
)
8+
9+
// hideJoinRequest approves or declines a pending join request for a chat.
10+
func (b *Bot) hideJoinRequest(ctx context.Context, chat ChatID, userID int64, approved bool) error {
11+
peer, err := b.resolveInputPeer(ctx, chat)
12+
if err != nil {
13+
return err
14+
}
15+
16+
user, err := b.resolveInputUser(ctx, userID)
17+
if err != nil {
18+
return err
19+
}
20+
21+
if _, err := b.raw.MessagesHideChatJoinRequest(ctx, &tg.MessagesHideChatJoinRequestRequest{
22+
Approved: approved,
23+
Peer: peer,
24+
UserID: user,
25+
}); err != nil {
26+
return asAPIError(err)
27+
}
28+
29+
return nil
30+
}
31+
32+
// ApproveChatJoinRequest approves a chat join request. The bot must be an
33+
// administrator with the can_invite_users right.
34+
func (b *Bot) ApproveChatJoinRequest(ctx context.Context, chat ChatID, userID int64) error {
35+
return b.hideJoinRequest(ctx, chat, userID, true)
36+
}
37+
38+
// DeclineChatJoinRequest declines a chat join request. The bot must be an
39+
// administrator with the can_invite_users right.
40+
func (b *Bot) DeclineChatJoinRequest(ctx context.Context, chat ChatID, userID int64) error {
41+
return b.hideJoinRequest(ctx, chat, userID, false)
42+
}

chat_sender.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
6+
"github.com/gotd/td/tg"
7+
)
8+
9+
// BanChatSenderChat bans a channel chat from posting in a supergroup or channel
10+
// on behalf of any of its sender chats. The bot must be an administrator with
11+
// the appropriate rights. The ban is permanent until lifted with
12+
// UnbanChatSenderChat.
13+
func (b *Bot) BanChatSenderChat(ctx context.Context, chat ChatID, senderChatID int64) error {
14+
return b.editSenderChatBan(ctx, chat, senderChatID, tg.ChatBannedRights{ViewMessages: true})
15+
}
16+
17+
// UnbanChatSenderChat lifts a previously set sender-chat ban in a supergroup or
18+
// channel.
19+
func (b *Bot) UnbanChatSenderChat(ctx context.Context, chat ChatID, senderChatID int64) error {
20+
return b.editSenderChatBan(ctx, chat, senderChatID, tg.ChatBannedRights{})
21+
}
22+
23+
// editSenderChatban sets the banned rights of a sender chat (channel or user
24+
// peer) within a supergroup or channel. Empty rights clear the ban.
25+
func (b *Bot) editSenderChatBan(ctx context.Context, chat ChatID, senderChatID int64, rights tg.ChatBannedRights) error {
26+
channel, err := b.resolveChannel(ctx, chat)
27+
if err != nil {
28+
return err
29+
}
30+
31+
sender, err := b.resolveInputPeer(ctx, ID(senderChatID))
32+
if err != nil {
33+
return err
34+
}
35+
36+
if _, err := b.raw.ChannelsEditBanned(ctx, &tg.ChannelsEditBannedRequest{
37+
Channel: channel,
38+
Participant: sender,
39+
BannedRights: rights,
40+
}); err != nil {
41+
return asAPIError(err)
42+
}
43+
44+
return nil
45+
}

reactions.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"strconv"
6+
7+
"github.com/gotd/td/tg"
8+
)
9+
10+
// reactionToTg converts a Bot API reaction type to the MTProto representation.
11+
//
12+
// The switch over the sealed ReactionType union is exhaustive (gochecksumtype).
13+
func reactionToTg(r ReactionType) (tg.ReactionClass, error) {
14+
switch v := r.(type) {
15+
case ReactionTypeEmoji:
16+
return &tg.ReactionEmoji{Emoticon: v.Emoji}, nil
17+
case ReactionTypeCustomEmoji:
18+
id, err := strconv.ParseInt(v.CustomEmojiID, 10, 64)
19+
if err != nil {
20+
return nil, &Error{Code: 400, Description: "Bad Request: invalid custom_emoji_id"}
21+
}
22+
23+
return &tg.ReactionCustomEmoji{DocumentID: id}, nil
24+
case ReactionTypePaid:
25+
return &tg.ReactionPaid{}, nil
26+
default:
27+
return nil, &Error{Code: 400, Description: "Bad Request: invalid reaction type"}
28+
}
29+
}
30+
31+
// ReactionOption configures a SetMessageReaction call.
32+
type ReactionOption func(*reactionConfig)
33+
34+
type reactionConfig struct {
35+
big bool
36+
}
37+
38+
// WithBigReaction shows the reaction with a big, animated effect.
39+
func WithBigReaction() ReactionOption {
40+
return func(c *reactionConfig) { c.big = true }
41+
}
42+
43+
// SetMessageReaction sets the bot's reactions on a message. Passing an empty
44+
// reactions slice removes the bot's reaction. As a non-premium user a bot can
45+
// set at most one reaction per message.
46+
func (b *Bot) SetMessageReaction(ctx context.Context, chat ChatID, messageID int, reactions []ReactionType, opts ...ReactionOption) error {
47+
var cfg reactionConfig
48+
49+
for _, o := range opts {
50+
o(&cfg)
51+
}
52+
53+
peer, err := b.resolveInputPeer(ctx, chat)
54+
if err != nil {
55+
return err
56+
}
57+
58+
tgReactions := make([]tg.ReactionClass, 0, len(reactions))
59+
60+
for _, r := range reactions {
61+
converted, err := reactionToTg(r)
62+
if err != nil {
63+
return err
64+
}
65+
66+
tgReactions = append(tgReactions, converted)
67+
}
68+
69+
if _, err := b.raw.MessagesSendReaction(ctx, &tg.MessagesSendReactionRequest{
70+
Big: cfg.big,
71+
Peer: peer,
72+
MsgID: messageID,
73+
Reaction: tgReactions,
74+
}); err != nil {
75+
return asAPIError(err)
76+
}
77+
78+
return nil
79+
}

reactions_test.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package botapi
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/gotd/td/constant"
8+
"github.com/gotd/td/tg"
9+
)
10+
11+
func TestSetMessageReaction(t *testing.T) {
12+
inv := newMockInvoker()
13+
inv.reply(tg.MessagesSendReactionRequestTypeID, okUpdates())
14+
15+
b := newMockBot(inv)
16+
17+
reactions := []ReactionType{Emoji("👍")}
18+
if err := b.SetMessageReaction(context.Background(), userRef(10, 1), 42, reactions, WithBigReaction()); err != nil {
19+
t.Fatalf("SetMessageReaction: %v", err)
20+
}
21+
22+
var req tg.MessagesSendReactionRequest
23+
24+
inv.decode(t, tg.MessagesSendReactionRequestTypeID, &req)
25+
26+
if req.MsgID != 42 || !req.Big {
27+
t.Fatalf("req = %#v", req)
28+
}
29+
30+
if len(req.Reaction) != 1 {
31+
t.Fatalf("reactions = %#v", req.Reaction)
32+
}
33+
34+
emoji, ok := req.Reaction[0].(*tg.ReactionEmoji)
35+
if !ok || emoji.Emoticon != "👍" {
36+
t.Fatalf("reaction = %#v", req.Reaction[0])
37+
}
38+
}
39+
40+
func TestSetMessageReactionEmptyClears(t *testing.T) {
41+
inv := newMockInvoker()
42+
inv.reply(tg.MessagesSendReactionRequestTypeID, okUpdates())
43+
44+
if err := newMockBot(inv).SetMessageReaction(context.Background(), userRef(10, 1), 1, nil); err != nil {
45+
t.Fatalf("SetMessageReaction: %v", err)
46+
}
47+
48+
var req tg.MessagesSendReactionRequest
49+
50+
inv.decode(t, tg.MessagesSendReactionRequestTypeID, &req)
51+
52+
if len(req.Reaction) != 0 {
53+
t.Fatalf("reactions = %#v, want empty", req.Reaction)
54+
}
55+
}
56+
57+
func TestReactionToTgCustomEmoji(t *testing.T) {
58+
got, err := reactionToTg(CustomEmoji("12345"))
59+
if err != nil {
60+
t.Fatalf("reactionToTg: %v", err)
61+
}
62+
63+
custom, ok := got.(*tg.ReactionCustomEmoji)
64+
if !ok || custom.DocumentID != 12345 {
65+
t.Fatalf("reaction = %#v", got)
66+
}
67+
68+
if _, err := reactionToTg(CustomEmoji("notanumber")); err == nil {
69+
t.Fatal("expected error for invalid custom_emoji_id")
70+
}
71+
}
72+
73+
func TestChatJoinRequests(t *testing.T) {
74+
for _, c := range []struct {
75+
name string
76+
call func(*Bot) error
77+
approved bool
78+
}{
79+
{"approve", func(b *Bot) error {
80+
return b.ApproveChatJoinRequest(context.Background(), tdlibChannel(50), 99)
81+
}, true},
82+
{"decline", func(b *Bot) error {
83+
return b.DeclineChatJoinRequest(context.Background(), tdlibChannel(50), 99)
84+
}, false},
85+
} {
86+
t.Run(c.name, func(t *testing.T) {
87+
inv := newMockInvoker()
88+
inv.reply(tg.MessagesHideChatJoinRequestRequestTypeID, okUpdates())
89+
90+
if err := c.call(newMockBot(inv)); err != nil {
91+
t.Fatalf("call: %v", err)
92+
}
93+
94+
var req tg.MessagesHideChatJoinRequestRequest
95+
96+
inv.decode(t, tg.MessagesHideChatJoinRequestRequestTypeID, &req)
97+
98+
if req.Approved != c.approved {
99+
t.Fatalf("approved = %v, want %v", req.Approved, c.approved)
100+
}
101+
})
102+
}
103+
}
104+
105+
func TestBanUnbanChatSenderChat(t *testing.T) {
106+
var p constant.TDLibPeerID
107+
108+
p.Channel(60)
109+
110+
senderID := int64(p)
111+
112+
for _, c := range []struct {
113+
name string
114+
call func(*Bot) error
115+
view bool
116+
}{
117+
{"ban", func(b *Bot) error {
118+
return b.BanChatSenderChat(context.Background(), tdlibChannel(50), senderID)
119+
}, true},
120+
{"unban", func(b *Bot) error {
121+
return b.UnbanChatSenderChat(context.Background(), tdlibChannel(50), senderID)
122+
}, false},
123+
} {
124+
t.Run(c.name, func(t *testing.T) {
125+
inv := newMockInvoker()
126+
inv.reply(tg.ChannelsEditBannedRequestTypeID, okUpdates())
127+
128+
if err := c.call(newMockBot(inv)); err != nil {
129+
t.Fatalf("call: %v", err)
130+
}
131+
132+
var req tg.ChannelsEditBannedRequest
133+
134+
inv.decode(t, tg.ChannelsEditBannedRequestTypeID, &req)
135+
136+
if _, ok := req.Participant.(*tg.InputPeerChannel); !ok {
137+
t.Fatalf("participant = %#v, want channel", req.Participant)
138+
}
139+
140+
if req.BannedRights.ViewMessages != c.view {
141+
t.Fatalf("ViewMessages = %v, want %v", req.BannedRights.ViewMessages, c.view)
142+
}
143+
})
144+
}
145+
}

0 commit comments

Comments
 (0)