e0360c98e2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
Intercept /support in the main bot with a dedicated handler (registered like /start), replying with a fixed support-desk info message: the operators' working hours and what to include (a description and, when possible, screenshots). The reply is Russian or English by the sender's reported Telegram language, and the command is listed in the bot's command menu (localized). Being a dedicated handler, it intercepts /support before the support relay, so the command line itself is not forwarded into an operator topic while the user's following description still is. Update the telegram README and FUNCTIONAL.md (+ _ru mirror).
599 lines
24 KiB
Go
599 lines
24 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), to /support with the support-desk info message, and to 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/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
tgbot "github.com/go-telegram/bot"
|
|
"github.com/go-telegram/bot/models"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/time/rate"
|
|
|
|
"scrabble/platform/telegram/internal/health"
|
|
"scrabble/platform/telegram/internal/outbox"
|
|
"scrabble/platform/telegram/internal/support"
|
|
)
|
|
|
|
// botPollTimeout is the getUpdates long-poll timeout. It matches the go-telegram/bot default; we set
|
|
// it explicitly only because wiring the health observer (WithHTTPClient) also sets the poll timeout.
|
|
const botPollTimeout = time.Minute
|
|
|
|
// 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. Its public
|
|
// @username is also resolved at startup for the /start welcome's discussion link.
|
|
ChatID int64
|
|
// GameChannelID is the game channel whose public @username the /start welcome links
|
|
// to (resolved from this id via getChat at startup); 0 omits that follow link.
|
|
GameChannelID int64
|
|
// SupportChatID is the private forum supergroup the bot relays direct user messages
|
|
// into (one topic per user) and reads operator replies from; 0 disables the support
|
|
// relay. The bot must be an administrator there with the manage-topics and
|
|
// delete-messages rights.
|
|
SupportChatID int64
|
|
// SupportStore persists the support relay's state (topic mapping, block list,
|
|
// relayed message ids); required when SupportChatID is set, ignored otherwise.
|
|
SupportStore *support.Store
|
|
// AcceptPayments enables the Telegram Stars rail: the bot then subscribes to
|
|
// pre_checkout_query updates and handles pre_checkout / successful_payment. The runtime
|
|
// dependencies (the validator, forwarder and outbox) are wired with SetPaymentHandlers.
|
|
AcceptPayments bool
|
|
// Health, when set, observes every Bot API request for health metrics (reported to the gateway
|
|
// over the bot-link) and honours a 429's Retry-After. Nil disables the observation.
|
|
Health *health.Reporter
|
|
}
|
|
|
|
// 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
|
|
// channelID is the game channel (0 omits its welcome follow link).
|
|
channelID int64
|
|
// channelUsername and chatUsername are the public @usernames (without the leading
|
|
// @) of the game channel and the discussion chat, resolved once at startup
|
|
// (resolveWelcomeHandles) for the /start welcome's follow links; "" when unresolved.
|
|
channelUsername string
|
|
chatUsername string
|
|
// botID is the bot's own Telegram user id (resolved at startup); it skips the
|
|
// chat_member updates the bot's own restrict actions generate — the grant loop guard —
|
|
// and the bot's own relayed copies in the support chat.
|
|
botID int64
|
|
// eligibility resolves a joining user's chat write eligibility; nil leaves a
|
|
// joiner muted (fail-closed) until it is wired.
|
|
eligibility EligibilityResolver
|
|
// supportChatID is the support relay's forum supergroup (0 disables the relay).
|
|
supportChatID int64
|
|
// support persists the support relay's per-user state; nil when the relay is off.
|
|
support *support.Store
|
|
// supportLocks serialises per-user topic creation so a burst of a new user's
|
|
// messages opens exactly one topic.
|
|
supportLocks *keyedMutex
|
|
// admins caches the support chat's administrator ids (who may reply and act).
|
|
admins *adminCache
|
|
// precheck validates a Stars pre_checkout order and forward delivers a completed payment; both
|
|
// are late-bound (SetPaymentHandlers) over the bot-link, which is built after the bot. outbox
|
|
// durably records completed payments before they are forwarded. All nil when the Stars rail is off.
|
|
precheck PreCheckoutValidator
|
|
forward PaymentForwarder
|
|
outbox *outbox.Store
|
|
}
|
|
|
|
// 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,
|
|
channelID: cfg.GameChannelID,
|
|
supportChatID: cfg.SupportChatID,
|
|
support: cfg.SupportStore,
|
|
}
|
|
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),
|
|
tgbot.WithMessageTextHandler("/support", tgbot.MatchTypePrefix, t.handleSupport),
|
|
}
|
|
if t.supportEnabled() {
|
|
t.supportLocks = newKeyedMutex()
|
|
t.admins = &adminCache{}
|
|
// The info-card buttons are callback_query updates; route them to the support
|
|
// callback handler by their "sup:" data prefix.
|
|
opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback))
|
|
}
|
|
// Allowed updates default to "all except chat_member" (which already includes
|
|
// pre_checkout_query and message-borne successful_payment). Specify an explicit set only when we
|
|
// need chat_member (moderated chat) — and then re-add callback_query and, for the Stars rail,
|
|
// pre_checkout_query, which the explicit set would otherwise drop.
|
|
if cfg.ChatID != 0 {
|
|
allowed := tgbot.AllowedUpdates{
|
|
models.AllowedUpdateMessage,
|
|
models.AllowedUpdateMyChatMember,
|
|
models.AllowedUpdateChatMember,
|
|
}
|
|
if t.supportEnabled() {
|
|
allowed = append(allowed, models.AllowedUpdateCallbackQuery)
|
|
}
|
|
if cfg.AcceptPayments {
|
|
allowed = append(allowed, models.AllowedUpdatePreCheckoutQuery)
|
|
}
|
|
opts = append(opts, tgbot.WithAllowedUpdates(allowed))
|
|
}
|
|
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))
|
|
}
|
|
if cfg.Health != nil {
|
|
// Observe every Bot API request centrally for health metrics and the 429 back-off. The
|
|
// client timeout leaves slack over the long-poll hold (botPollTimeout - 1s server-side).
|
|
base := &http.Client{Timeout: botPollTimeout + 10*time.Second}
|
|
opts = append(opts, tgbot.WithHTTPClient(botPollTimeout, cfg.Health.Wrap(base)))
|
|
}
|
|
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) {
|
|
// Command menu: the default (fallback) list is English; a Russian-scoped list
|
|
// localises the labels for ru clients. A language scope replaces the whole list, so
|
|
// it repeats /start with its own Russian label.
|
|
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
|
|
Commands: []models.BotCommand{
|
|
{Command: "start", Description: "Play Scrabble"},
|
|
{Command: "support", Description: "Contact the administration"},
|
|
},
|
|
}); err != nil {
|
|
t.log.Warn("set commands failed", zap.Error(err))
|
|
}
|
|
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
|
|
LanguageCode: "ru",
|
|
Commands: []models.BotCommand{
|
|
{Command: "start", Description: "Играть в «Эрудита»"},
|
|
{Command: "support", Description: "Связаться с администрацией"},
|
|
},
|
|
}); err != nil {
|
|
t.log.Warn("set ru 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)
|
|
}
|
|
if t.supportEnabled() {
|
|
t.initSupport(ctx)
|
|
}
|
|
t.resolveWelcomeHandles(ctx)
|
|
t.api.Start(ctx)
|
|
}
|
|
|
|
// resolveWelcomeHandles resolves, once at startup, the public @usernames of the game
|
|
// channel and the discussion chat from their configured ids (getChat), caching them for
|
|
// the /start welcome's follow links. It runs before the update loop, so the handles are
|
|
// set before any /start is handled; a chat that is unset, private (no public username)
|
|
// or unreadable simply leaves its handle empty and the welcome omits that follow link.
|
|
func (t *Bot) resolveWelcomeHandles(ctx context.Context) {
|
|
t.channelUsername = t.resolveUsername(ctx, t.channelID, "game channel")
|
|
t.chatUsername = t.resolveUsername(ctx, t.chatID, "discussion chat")
|
|
}
|
|
|
|
// resolveUsername returns the public @username (without the leading @) of the chat with
|
|
// the given id, or "" when id is 0, the chat has no public username, or getChat fails —
|
|
// logging the reason, since a missing handle silently drops a welcome follow link.
|
|
func (t *Bot) resolveUsername(ctx context.Context, id int64, label string) string {
|
|
if id == 0 {
|
|
return ""
|
|
}
|
|
chat, err := t.api.GetChat(ctx, &tgbot.GetChatParams{ChatID: id})
|
|
if err != nil {
|
|
t.log.Warn("welcome: getChat failed; follow link omitted",
|
|
zap.String("chat", label), zap.Int64("id", id), zap.Error(err))
|
|
return ""
|
|
}
|
|
if chat.Username == "" {
|
|
t.log.Warn("welcome: chat has no public @username; follow link omitted",
|
|
zap.String("chat", label), zap.Int64("id", id))
|
|
return ""
|
|
}
|
|
t.log.Info("welcome: resolved follow link",
|
|
zap.String("chat", label), zap.String("username", chat.Username))
|
|
return chat.Username
|
|
}
|
|
|
|
// 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
|
|
}
|
|
t.botID = me.ID
|
|
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
|
|
}
|
|
// Reply only in a private chat: the Mini App launch button is an inline web_app
|
|
// button, which Telegram permits only in private chats — replying to a group message
|
|
// (the bot is an admin in the moderated chat and now receives its messages) fails with
|
|
// BUTTON_TYPE_INVALID. In the group the bot only manages permissions, it never chats.
|
|
if update.Message.Chat.Type != models.ChatTypePrivate {
|
|
return
|
|
}
|
|
// The sender's Telegram language rides on the message itself (Message.from.language_code
|
|
// in the Bot API — there is no separate user-update event); fall back to English when it
|
|
// is absent.
|
|
lang := ""
|
|
if update.Message.From != nil {
|
|
lang = update.Message.From.LanguageCode
|
|
}
|
|
text, button := startText(lang, t.channelUsername, t.chatUsername)
|
|
startParam := startPayload(update.Message.Text)
|
|
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
|
|
ChatID: update.Message.Chat.ID,
|
|
Text: text,
|
|
ReplyMarkup: t.launchMarkup(button, 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
|
|
}
|
|
// Telegram Stars: the pre_checkout gate (validated against the backend) and the completed
|
|
// payment (persisted to the outbox and forwarded) — before the support relay, so a payment
|
|
// message is never mistaken for a support DM or given a launch reply.
|
|
if update.PreCheckoutQuery != nil {
|
|
t.handlePreCheckout(ctx, update.PreCheckoutQuery)
|
|
return
|
|
}
|
|
if update.Message != nil && update.Message.SuccessfulPayment != nil {
|
|
t.handleSuccessfulPayment(ctx, update.Message)
|
|
return
|
|
}
|
|
// Support relay (when enabled): a non-/start message — /start has its own handler —
|
|
// is either an operator's reply in the support chat or a user's direct message to
|
|
// relay. Everything else falls through to the Mini App launch reply.
|
|
if t.supportEnabled() && update.Message != nil {
|
|
switch {
|
|
case update.Message.Chat.ID == t.supportChatID:
|
|
t.handleSupportGroupMessage(ctx, update.Message)
|
|
return
|
|
case update.Message.Chat.Type == models.ChatTypePrivate:
|
|
t.handleSupportUserMessage(ctx, update.Message)
|
|
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 keeps a chat member's write access in sync with their eligibility.
|
|
// The chat allows sending by default, so the bot mutes an ineligible member (not
|
|
// registered, or admin-suspended, or chat_muted) and restores an eligible one it had
|
|
// muted; an eligible member that can already send is left untouched. It acts only when
|
|
// the current state differs from the desired one, so it is idempotent and does not
|
|
// re-act on its own change; a resolve failure makes no change.
|
|
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: the one place to see whether
|
|
// Telegram delivers joins, for which chat, the transition, who performed it, and the
|
|
// new member's send/membership state.
|
|
canSend, isMember := restrictedSendState(cm.NewChatMember)
|
|
t.log.Debug("chat_member update",
|
|
zap.Int64("chat_id", cm.Chat.ID),
|
|
zap.Int64("configured_chat_id", t.chatID),
|
|
zap.Int64("user_id", uid),
|
|
zap.Int64("actor_id", cm.From.ID),
|
|
zap.String("old_status", string(cm.OldChatMember.Type)),
|
|
zap.String("new_status", string(cm.NewChatMember.Type)),
|
|
zap.Bool("new_can_send", canSend),
|
|
zap.Bool("new_is_member", isMember))
|
|
|
|
if t.chatID == 0 || cm.Chat.ID != t.chatID {
|
|
return
|
|
}
|
|
if user == nil || user.IsBot {
|
|
return
|
|
}
|
|
// Loop guard: the bot's own restrict re-fires a chat_member update whose performer is
|
|
// the bot; skip those so a grant never re-triggers itself.
|
|
if t.botID != 0 && cm.From.ID == t.botID {
|
|
return
|
|
}
|
|
// The chat allows sending by default and the bot only restricts: Telegram intersects
|
|
// the chat default with the per-user permission, so a per-user grant cannot exceed a
|
|
// deny-by-default — the gate must mute the ineligible, not grant the eligible.
|
|
// Determine whether the user is in the chat and can currently send: a plain member
|
|
// follows the permissive default; a restricted member can send only with
|
|
// CanSendMessages, and only while a member.
|
|
var inChat, currentlyCanSend bool
|
|
switch cm.NewChatMember.Type {
|
|
case models.ChatMemberTypeMember:
|
|
inChat, currentlyCanSend = true, true
|
|
case models.ChatMemberTypeRestricted:
|
|
inChat, currentlyCanSend = isMember, canSend
|
|
default:
|
|
return // left / kicked / administrator / owner — not a member to gate
|
|
}
|
|
if !inChat {
|
|
return
|
|
}
|
|
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.Debug("chat access evaluated",
|
|
zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible), zap.Bool("can_send", currentlyCanSend))
|
|
// Desired: an eligible user may send, an ineligible one may not. Act only when the
|
|
// current state differs — idempotent, a no-op for the common eligible member, and it
|
|
// keeps the bot from re-acting on its own change.
|
|
if eligible == currentlyCanSend {
|
|
return
|
|
}
|
|
if err := t.setChatWrite(ctx, user.ID, eligible); err != nil {
|
|
t.log.Warn("set chat write failed",
|
|
zap.Int64("user_id", user.ID), zap.Bool("can_send", eligible), zap.Error(err))
|
|
return
|
|
}
|
|
t.log.Info("chat access applied", zap.Int64("user_id", user.ID), zap.Bool("can_send", eligible))
|
|
}
|
|
|
|
// 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.Debug("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,
|
|
}
|
|
}
|
|
|
|
// restrictedSendState returns a restricted member's text-send permission and whether
|
|
// they are currently a member of the chat; (false, false) for any non-restricted
|
|
// status (the fields exist only on the restricted variant).
|
|
func restrictedSendState(m models.ChatMember) (canSend, isMember bool) {
|
|
if m.Type == models.ChatMemberTypeRestricted && m.Restricted != nil {
|
|
return m.Restricted.CanSendMessages, m.Restricted.IsMember
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
// 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
|
|
}
|