feat(telegram): promo bot + channel-chat moderation gate
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
Add a second standalone promo bot to the bot container (answers /start with a localized message + a URL button into the main bot's Mini App) and gate write access in a channel's linked discussion chat: grant on join when the Telegram user is registered and neither admin-suspended nor holding a new chat_muted role, and revoke/grant on the matching moderation change for a member currently in the chat. Eligibility (registered AND NOT suspended AND NOT chat_muted; the game suspension dominates) is resolved once in the backend and reached two ways: the bot's join-time unary ResolveChatEligibility over the existing mTLS bot-link, and a backend chat_access_changed event -> gateway -> ChatGate command (idempotent; a temporary-block-expiry sweeper may over-emit). The bot guards the block/unblock path with getChatMember, since bots cannot list members. A web_app button cannot open another bot's Mini App (it signs initData with the sending bot's token), so the promo button is a t.me ?startapp URL reusing the UI's VITE_TELEGRAM_LINK. The bot must be a chat admin with the restrict-members right and chat_member in its allowed updates. No schema change: chat_muted reuses the data-driven account_roles table.
This commit is contained in:
@@ -8,6 +8,7 @@ package bot
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
@@ -30,8 +31,19 @@ type Config struct {
|
||||
// Telegram Bot API flood limits; 0 disables the limiter. The burst equals the
|
||||
// per-second rate.
|
||||
SendRatePerSecond int
|
||||
// ChatID is the moderated discussion chat the bot gates write access in; 0
|
||||
// disables chat gating (and the chat_member long-poll subscription). Gating needs
|
||||
// the bot to be an administrator there with the restrict-members right.
|
||||
ChatID int64
|
||||
}
|
||||
|
||||
// EligibilityResolver answers whether the Telegram user identified by externalID
|
||||
// (the decimal user id) may write in the moderated chat: registered and neither
|
||||
// admin-suspended nor chat-muted. The bot calls it when a user joins the chat. It is
|
||||
// late-bound (SetEligibilityResolver) because it is backed by the bot-link client,
|
||||
// which is built after the bot.
|
||||
type EligibilityResolver func(ctx context.Context, externalID string) (eligible bool, err error)
|
||||
|
||||
// Bot wraps a Telegram Bot API client and the Mini App launch URL.
|
||||
type Bot struct {
|
||||
api *tgbot.Bot
|
||||
@@ -40,6 +52,11 @@ type Bot struct {
|
||||
// limiter throttles outbound sends to stay under the Bot API flood limits; nil
|
||||
// disables throttling.
|
||||
limiter *rate.Limiter
|
||||
// chatID is the moderated discussion chat (0 disables gating).
|
||||
chatID int64
|
||||
// eligibility resolves a joining user's chat write eligibility; nil leaves a
|
||||
// joiner muted (fail-closed) until it is wired.
|
||||
eligibility EligibilityResolver
|
||||
}
|
||||
|
||||
// New builds the bot wrapper, registering the /start handler and a default handler
|
||||
@@ -49,15 +66,25 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
t := &Bot{miniAppURL: cfg.MiniAppURL, log: log}
|
||||
t := &Bot{miniAppURL: cfg.MiniAppURL, log: log, chatID: cfg.ChatID}
|
||||
if cfg.SendRatePerSecond > 0 {
|
||||
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
|
||||
}
|
||||
|
||||
opts := []tgbot.Option{
|
||||
tgbot.WithDefaultHandler(t.handleStart),
|
||||
tgbot.WithDefaultHandler(t.handleUpdate),
|
||||
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
|
||||
}
|
||||
if cfg.ChatID != 0 {
|
||||
// chat_member updates are off by default; subscribe explicitly (alongside
|
||||
// messages) so the bot sees joins in the moderated chat. The bot must also be an
|
||||
// administrator there for Telegram to deliver them.
|
||||
opts = append(opts, tgbot.WithAllowedUpdates(tgbot.AllowedUpdates{
|
||||
models.AllowedUpdateMessage,
|
||||
models.AllowedUpdateMyChatMember,
|
||||
models.AllowedUpdateChatMember,
|
||||
}))
|
||||
}
|
||||
if cfg.TestEnv {
|
||||
// Route to the Bot API test environment (.../bot<token>/test/METHOD).
|
||||
opts = append(opts, tgbot.UseTestEnvironment())
|
||||
@@ -176,3 +203,130 @@ func startPayload(text string) string {
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(text, cmd))
|
||||
}
|
||||
|
||||
// handleUpdate is the default-handler dispatcher: a chat-member change in the
|
||||
// moderated chat drives the write-access gate; anything else is treated as a message
|
||||
// and gets the Mini App launch reply.
|
||||
func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.Update) {
|
||||
if update.ChatMember != nil {
|
||||
t.handleChatMember(ctx, update.ChatMember)
|
||||
return
|
||||
}
|
||||
t.handleStart(ctx, api, update)
|
||||
}
|
||||
|
||||
// SetEligibilityResolver wires the chat-eligibility resolver after construction (the
|
||||
// bot-link client backing it is built after the bot).
|
||||
func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) {
|
||||
t.eligibility = resolve
|
||||
}
|
||||
|
||||
// handleChatMember grants write access to a user who joins the moderated chat when
|
||||
// they are registered and not blocked. A non-eligible joiner is left muted (the chat
|
||||
// defaults to no-send), and a resolve failure fails closed (also left muted). Other
|
||||
// status changes (leaves, restrictions, admin edits) are ignored.
|
||||
func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated) {
|
||||
if t.chatID == 0 || cm.Chat.ID != t.chatID {
|
||||
return
|
||||
}
|
||||
// Only a transition into plain membership is a join to evaluate.
|
||||
if cm.NewChatMember.Type != models.ChatMemberTypeMember || cm.OldChatMember.Type == models.ChatMemberTypeMember {
|
||||
return
|
||||
}
|
||||
user := chatMemberUser(cm.NewChatMember)
|
||||
if user == nil || user.IsBot {
|
||||
return
|
||||
}
|
||||
if t.eligibility == nil {
|
||||
return // resolver not wired: leave muted (fail closed)
|
||||
}
|
||||
eligible, err := t.eligibility(ctx, strconv.FormatInt(user.ID, 10))
|
||||
if err != nil {
|
||||
t.log.Warn("chat join eligibility failed", zap.Int64("user_id", user.ID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if !eligible {
|
||||
return // not registered or blocked: leave muted
|
||||
}
|
||||
if err := t.setChatWrite(ctx, user.ID, true); err != nil {
|
||||
t.log.Warn("grant chat write failed", zap.Int64("user_id", user.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyChatGate applies a chat-gate command (an admin block/unblock or chat_muted
|
||||
// change relayed by the gateway): it sets the user's write access, but only when they
|
||||
// are currently in the chat. Bots cannot list members, so it probes the single user
|
||||
// with getChatMember and is a no-op when they are absent (left/kicked) or an
|
||||
// administrator (who cannot be restricted). It reports whether a restriction was
|
||||
// applied.
|
||||
func (t *Bot) ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error) {
|
||||
if t.chatID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
member, err := t.api.GetChatMember(ctx, &tgbot.GetChatMemberParams{ChatID: t.chatID, UserID: userID})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch member.Type {
|
||||
case models.ChatMemberTypeMember, models.ChatMemberTypeRestricted:
|
||||
if err := t.setChatWrite(ctx, userID, allow); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil // absent, or an admin/owner who cannot be restricted
|
||||
}
|
||||
}
|
||||
|
||||
// setChatWrite restricts the user in the moderated chat to either the full send
|
||||
// permission set (allow) or none (mute); the non-send permissions stay at their
|
||||
// default-deny either way.
|
||||
func (t *Bot) setChatWrite(ctx context.Context, userID int64, allow bool) error {
|
||||
perms := models.ChatPermissions{}
|
||||
if allow {
|
||||
perms = chatWritePerms()
|
||||
}
|
||||
_, err := t.api.RestrictChatMember(ctx, &tgbot.RestrictChatMemberParams{
|
||||
ChatID: t.chatID,
|
||||
UserID: userID,
|
||||
Permissions: &perms,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// chatWritePerms grants a member the ability to send every kind of message; the
|
||||
// non-send permissions stay denied.
|
||||
func chatWritePerms() models.ChatPermissions {
|
||||
return models.ChatPermissions{
|
||||
CanSendMessages: true,
|
||||
CanSendAudios: true,
|
||||
CanSendDocuments: true,
|
||||
CanSendPhotos: true,
|
||||
CanSendVideos: true,
|
||||
CanSendVideoNotes: true,
|
||||
CanSendVoiceNotes: true,
|
||||
CanSendPolls: true,
|
||||
CanSendOtherMessages: true,
|
||||
CanAddWebPagePreviews: true,
|
||||
}
|
||||
}
|
||||
|
||||
// chatMemberUser returns the user a ChatMember refers to across the union variants,
|
||||
// or nil for an unrecognised type.
|
||||
func chatMemberUser(m models.ChatMember) *models.User {
|
||||
switch m.Type {
|
||||
case models.ChatMemberTypeOwner:
|
||||
return m.Owner.User
|
||||
case models.ChatMemberTypeAdministrator:
|
||||
return &m.Administrator.User
|
||||
case models.ChatMemberTypeMember:
|
||||
return m.Member.User
|
||||
case models.ChatMemberTypeRestricted:
|
||||
return m.Restricted.User
|
||||
case models.ChatMemberTypeLeft:
|
||||
return m.Left.User
|
||||
case models.ChatMemberTypeBanned:
|
||||
return m.Banned.User
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const testChatID = 555
|
||||
|
||||
// chatAPI is a fake Bot API for the chat-gating tests: it answers getMe, returns a
|
||||
// scripted getChatMember status, and records restrictChatMember calls.
|
||||
type chatAPI struct {
|
||||
memberStatus string // the status getChatMember reports (default "left")
|
||||
restricts []restrictCall
|
||||
}
|
||||
|
||||
type restrictCall struct {
|
||||
userID string
|
||||
canSend bool // can_send_messages in the applied permissions
|
||||
}
|
||||
|
||||
func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/getMe"):
|
||||
io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"t","username":"tb"}}`)
|
||||
case strings.HasSuffix(r.URL.Path, "/getChatMember"):
|
||||
status := a.memberStatus
|
||||
if status == "" {
|
||||
status = "left"
|
||||
}
|
||||
io.WriteString(w, `{"ok":true,"result":{"status":"`+status+`","user":{"id":`+r.FormValue("user_id")+`,"is_bot":false,"first_name":"u"}}}`)
|
||||
case strings.HasSuffix(r.URL.Path, "/restrictChatMember"):
|
||||
var perms struct {
|
||||
CanSendMessages bool `json:"can_send_messages"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(r.FormValue("permissions")), &perms)
|
||||
a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages})
|
||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
||||
default:
|
||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
||||
}
|
||||
}
|
||||
|
||||
// newChatBot builds a gating bot (ChatID set) over the fake API, without an
|
||||
// eligibility resolver — each test wires the one it needs.
|
||||
func newChatBot(t *testing.T, api *chatAPI) *Bot {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(api)
|
||||
t.Cleanup(srv.Close)
|
||||
b, err := New(Config{Token: "123:ABC", APIBaseURL: srv.URL, MiniAppURL: "https://example.com/", ChatID: testChatID}, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("new bot: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// chatMemberUpdate builds a status transition oldType -> member for userID in chatID.
|
||||
func chatMemberUpdate(chatID, userID int64, oldType models.ChatMemberType) *models.ChatMemberUpdated {
|
||||
return &models.ChatMemberUpdated{
|
||||
Chat: models.Chat{ID: chatID},
|
||||
OldChatMember: models.ChatMember{Type: oldType, Left: &models.ChatMemberLeft{User: &models.User{ID: userID}}},
|
||||
NewChatMember: models.ChatMember{Type: models.ChatMemberTypeMember, Member: &models.ChatMemberMember{User: &models.User{ID: userID}}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleChatMemberGrantsEligibleJoin(t *testing.T) {
|
||||
api := &chatAPI{}
|
||||
b := newChatBot(t, api)
|
||||
b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return true, nil })
|
||||
|
||||
b.handleChatMember(context.Background(), chatMemberUpdate(testChatID, 777, models.ChatMemberTypeLeft))
|
||||
|
||||
if len(api.restricts) != 1 || api.restricts[0].userID != "777" || !api.restricts[0].canSend {
|
||||
t.Fatalf("restricts = %+v, want one grant (can_send=true) for 777", api.restricts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleChatMemberSkipsIneligibleJoin(t *testing.T) {
|
||||
api := &chatAPI{}
|
||||
b := newChatBot(t, api)
|
||||
b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return false, nil })
|
||||
|
||||
b.handleChatMember(context.Background(), chatMemberUpdate(testChatID, 777, models.ChatMemberTypeLeft))
|
||||
|
||||
if len(api.restricts) != 0 {
|
||||
t.Fatalf("an ineligible joiner was restricted: %+v (want left muted, no call)", api.restricts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleChatMemberFailsClosedOnResolveError(t *testing.T) {
|
||||
api := &chatAPI{}
|
||||
b := newChatBot(t, api)
|
||||
b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return true, context.DeadlineExceeded })
|
||||
|
||||
b.handleChatMember(context.Background(), chatMemberUpdate(testChatID, 777, models.ChatMemberTypeLeft))
|
||||
|
||||
if len(api.restricts) != 0 {
|
||||
t.Fatalf("a resolve error still granted write: %+v (want fail-closed)", api.restricts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleChatMemberIgnoresOtherChatAndNonJoins(t *testing.T) {
|
||||
api := &chatAPI{}
|
||||
b := newChatBot(t, api)
|
||||
b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return true, nil })
|
||||
ctx := context.Background()
|
||||
|
||||
// A different chat is ignored.
|
||||
b.handleChatMember(ctx, chatMemberUpdate(999, 777, models.ChatMemberTypeLeft))
|
||||
// A non-join transition (already a member) is ignored.
|
||||
b.handleChatMember(ctx, chatMemberUpdate(testChatID, 777, models.ChatMemberTypeMember))
|
||||
|
||||
if len(api.restricts) != 0 {
|
||||
t.Fatalf("restricts = %+v, want none for a foreign chat / non-join", api.restricts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyChatGatePresentMember(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
allow bool
|
||||
}{{"grant", true}, {"mute", false}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
api := &chatAPI{memberStatus: "member"}
|
||||
b := newChatBot(t, api)
|
||||
applied, err := b.ApplyChatGate(context.Background(), 777, tc.allow)
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if !applied {
|
||||
t.Fatal("applied = false, want true for a present member")
|
||||
}
|
||||
if len(api.restricts) != 1 || api.restricts[0].canSend != tc.allow {
|
||||
t.Fatalf("restricts = %+v, want one with can_send=%v", api.restricts, tc.allow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyChatGateSkipsAbsentAndAdmin(t *testing.T) {
|
||||
for _, status := range []string{"left", "kicked", "administrator", "creator"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
api := &chatAPI{memberStatus: status}
|
||||
b := newChatBot(t, api)
|
||||
applied, err := b.ApplyChatGate(context.Background(), 777, false)
|
||||
if err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if applied {
|
||||
t.Errorf("applied = true for status %q, want a no-op", status)
|
||||
}
|
||||
if len(api.restricts) != 0 {
|
||||
t.Errorf("restricts = %+v for status %q, want none", api.restricts, status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user