380f82438c
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
A default-deny discussion group reports a present or freshly joined member as `restricted` (no send right), not `member`. The join filter required `member`, so the real case never matched and a registered user stayed muted. Grant any eligible in-chat member (member or restricted) that still lacks the send right, with a loop guard (skip when send is already allowed) so the bot's own grant does not re-fire. Revoking a now-ineligible user stays the chat-gate path's job, so this never fights a chat_muted/block.
394 lines
14 KiB
Go
394 lines
14 KiB
Go
// Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it
|
|
// runs the long-poll update loop — replying to /start (with an optional deep-link
|
|
// payload) and any other message with a Mini App launch button — and sends the
|
|
// notification and admin messages the connector requests. The bot token lives only
|
|
// in this process.
|
|
package bot
|
|
|
|
import (
|
|
"context"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
tgbot "github.com/go-telegram/bot"
|
|
"github.com/go-telegram/bot/models"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// Config configures the bot wrapper.
|
|
type Config struct {
|
|
// Token is the Bot API token.
|
|
Token string
|
|
// APIBaseURL overrides the Bot API host ("" uses https://api.telegram.org).
|
|
APIBaseURL string
|
|
// TestEnv routes requests to the Bot API test environment.
|
|
TestEnv bool
|
|
// MiniAppURL is the base URL of the Mini App launch button.
|
|
MiniAppURL string
|
|
// SendRatePerSecond caps outbound sends (Notify and SendText) to respect the
|
|
// 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
|
|
miniAppURL string
|
|
log *zap.Logger
|
|
// 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
|
|
// that both reply with a Mini App launch button. It does not start polling; call
|
|
// Run for that.
|
|
func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
|
if log == nil {
|
|
log = zap.NewNop()
|
|
}
|
|
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.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())
|
|
}
|
|
if cfg.APIBaseURL != "" {
|
|
opts = append(opts, tgbot.WithServerURL(cfg.APIBaseURL))
|
|
}
|
|
api, err := tgbot.New(cfg.Token, opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.api = api
|
|
return t, nil
|
|
}
|
|
|
|
// Run sets the bot commands and the Mini App menu button, then blocks on the
|
|
// long-poll update loop until ctx is cancelled.
|
|
func (t *Bot) Run(ctx context.Context) {
|
|
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
|
|
Commands: []models.BotCommand{{Command: "start", Description: "Open Scrabble"}},
|
|
}); err != nil {
|
|
t.log.Warn("set commands failed", zap.Error(err))
|
|
}
|
|
if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{
|
|
MenuButton: models.MenuButtonWebApp{
|
|
Type: models.MenuButtonTypeWebApp,
|
|
Text: "Play",
|
|
WebApp: models.WebAppInfo{URL: t.miniAppURL},
|
|
},
|
|
}); err != nil {
|
|
t.log.Warn("set menu button failed", zap.Error(err))
|
|
}
|
|
if t.chatID != 0 {
|
|
t.logChatAdminStatus(ctx)
|
|
}
|
|
t.api.Start(ctx)
|
|
}
|
|
|
|
// logChatAdminStatus checks, at startup, whether the bot can actually gate the
|
|
// moderated chat — it must be an administrator there with the restrict-members
|
|
// ("Ban users") right, or Telegram delivers no chat_member updates and restricts
|
|
// fail. It logs a prominent warning when the prerequisite is missing (the common
|
|
// misconfiguration), so the cause is visible without reproducing a join.
|
|
func (t *Bot) logChatAdminStatus(ctx context.Context) {
|
|
me, err := t.api.GetMe(ctx)
|
|
if err != nil {
|
|
t.log.Warn("chat self-check: getMe failed", zap.Error(err))
|
|
return
|
|
}
|
|
m, err := t.api.GetChatMember(ctx, &tgbot.GetChatMemberParams{ChatID: t.chatID, UserID: me.ID})
|
|
if err != nil {
|
|
t.log.Warn("chat gating self-check failed: the bot cannot read the chat — is it added and is TELEGRAM_CHAT_ID the discussion group id?",
|
|
zap.Int64("chat_id", t.chatID), zap.Error(err))
|
|
return
|
|
}
|
|
canRestrict := m.Type == models.ChatMemberTypeAdministrator && m.Administrator.CanRestrictMembers
|
|
if !canRestrict {
|
|
t.log.Warn(`chat gating WILL NOT WORK: the bot must be an administrator with the restrict-members ("Ban users") right`,
|
|
zap.Int64("chat_id", t.chatID), zap.String("bot_status", string(m.Type)))
|
|
return
|
|
}
|
|
t.log.Info("chat gating ready: bot is an admin with the restrict-members right", zap.Int64("chat_id", t.chatID))
|
|
}
|
|
|
|
// Notify sends a notification message with a Mini App launch button that opens the
|
|
// app at startParam (empty opens the lobby).
|
|
func (t *Bot) Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error {
|
|
if err := t.throttle(ctx); err != nil {
|
|
return err
|
|
}
|
|
_, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{
|
|
ChatID: chatID,
|
|
Text: text,
|
|
ReplyMarkup: t.launchMarkup(buttonText, startParam),
|
|
})
|
|
return err
|
|
}
|
|
|
|
// SendText sends a plain text message with no markup (admin use).
|
|
func (t *Bot) SendText(ctx context.Context, chatID int64, text string) error {
|
|
if err := t.throttle(ctx); err != nil {
|
|
return err
|
|
}
|
|
_, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text})
|
|
return err
|
|
}
|
|
|
|
// throttle blocks until the rate limiter admits one send, or ctx is cancelled. It
|
|
// is a no-op when no limiter is configured.
|
|
func (t *Bot) throttle(ctx context.Context) error {
|
|
if t.limiter == nil {
|
|
return nil
|
|
}
|
|
return t.limiter.Wait(ctx)
|
|
}
|
|
|
|
// handleStart replies to /start (with an optional deep-link payload) and to any
|
|
// other message with a Mini App launch button.
|
|
func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
|
|
if update.Message == nil {
|
|
return
|
|
}
|
|
startParam := startPayload(update.Message.Text)
|
|
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
|
|
ChatID: update.Message.Chat.ID,
|
|
Text: "Tap to open Scrabble.",
|
|
ReplyMarkup: t.launchMarkup("Open Scrabble", startParam),
|
|
}); err != nil {
|
|
t.log.Warn("reply to start failed", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// launchMarkup builds the single-button inline keyboard that opens the Mini App at
|
|
// startParam.
|
|
func (t *Bot) launchMarkup(buttonText, startParam string) *models.InlineKeyboardMarkup {
|
|
return &models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{{
|
|
{Text: buttonText, WebApp: &models.WebAppInfo{URL: t.launchURL(startParam)}},
|
|
}},
|
|
}
|
|
}
|
|
|
|
// launchURL appends the deep-link start parameter to the Mini App URL as a startapp
|
|
// query parameter; an empty parameter returns the base URL unchanged.
|
|
func (t *Bot) launchURL(startParam string) string {
|
|
if startParam == "" {
|
|
return t.miniAppURL
|
|
}
|
|
u, err := url.Parse(t.miniAppURL)
|
|
if err != nil {
|
|
return t.miniAppURL
|
|
}
|
|
q := u.Query()
|
|
q.Set("startapp", startParam)
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
// startPayload extracts the deep-link payload from a "/start <payload>" command;
|
|
// any other text yields an empty payload (open the lobby).
|
|
func startPayload(text string) string {
|
|
const cmd = "/start"
|
|
if !strings.HasPrefix(text, cmd) {
|
|
return ""
|
|
}
|
|
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) {
|
|
user := chatMemberUser(cm.NewChatMember)
|
|
var uid int64
|
|
if user != nil {
|
|
uid = user.ID
|
|
}
|
|
// Log every chat_member update the bot receives: this is the one place to see
|
|
// whether Telegram is delivering joins at all, for which chat, and the transition.
|
|
t.log.Info("chat_member update",
|
|
zap.Int64("chat_id", cm.Chat.ID),
|
|
zap.Int64("configured_chat_id", t.chatID),
|
|
zap.Int64("user_id", uid),
|
|
zap.String("old_status", string(cm.OldChatMember.Type)),
|
|
zap.String("new_status", string(cm.NewChatMember.Type)))
|
|
|
|
if t.chatID == 0 || cm.Chat.ID != t.chatID {
|
|
return
|
|
}
|
|
if user == nil || user.IsBot {
|
|
return
|
|
}
|
|
// (Re)grant write to an eligible user who is in the chat but cannot yet send: a join
|
|
// into the default-deny chat (where a joined member appears `restricted`, sometimes
|
|
// `member`) or a restricted member still lacking send rights. A user who can already
|
|
// send is skipped — which also breaks the feedback loop from the bot's own grant,
|
|
// since restricting re-fires a chat_member event. Revoking a now-ineligible user is
|
|
// the chat-gate path's job (an admin block or chat_muted change), not this one.
|
|
switch cm.NewChatMember.Type {
|
|
case models.ChatMemberTypeMember:
|
|
// A plain member follows the chat default; in the gated (default-deny) chat that
|
|
// denies sending, so (re)grant.
|
|
case models.ChatMemberTypeRestricted:
|
|
if cm.NewChatMember.Restricted.CanSendMessages {
|
|
return // already allowed to send
|
|
}
|
|
default:
|
|
return // left / kicked / administrator / owner — nothing to grant here
|
|
}
|
|
if t.eligibility == nil {
|
|
t.log.Warn("chat access: eligibility resolver not wired", zap.Int64("user_id", uid))
|
|
return
|
|
}
|
|
eligible, err := t.eligibility(ctx, strconv.FormatInt(user.ID, 10))
|
|
if err != nil {
|
|
t.log.Warn("chat access eligibility failed", zap.Int64("user_id", user.ID), zap.Error(err))
|
|
return
|
|
}
|
|
t.log.Info("chat access evaluated", zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible))
|
|
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))
|
|
return
|
|
}
|
|
t.log.Info("chat write granted", zap.Int64("user_id", user.ID))
|
|
}
|
|
|
|
// 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
|
|
}
|
|
t.log.Info("chat gate applied", zap.Int64("user_id", userID), zap.Bool("allow", allow))
|
|
return true, nil
|
|
default:
|
|
t.log.Info("chat gate: user not in chat, skipped", zap.Int64("user_id", userID), zap.String("status", string(member.Type)))
|
|
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
|
|
}
|