6e03ce0131
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
Accept real money via Telegram Stars (XTR) — the third intake rail alongside Robokassa (direct) and VK Votes. Only the bot reaches Telegram, so the rail funnels through the reverse mTLS bot-link: - the gateway mints the invoice on a CreateInvoice command (the bot calls createInvoiceLink, XTR; the link goes to WebApp.openInvoice); - the bot gates each pre_checkout_query via a ValidatePreCheckout unary (the order must exist, be still creditable and not already paid — the reusable-invoice double-pay guard; the decline reason is localised to the order account's language); - a completed successful_payment is queued in a durable pure-Go SQLite outbox and forwarded via a ForwardPayment unary, credited once (idempotent on telegram_payment_charge_id, honours an expired order), re-driven on restart and every 30s. The rail is wired by TELEGRAM_STARS_OUTBOX_DIR (default /data) but stays inert until a chip pack carries an XTR price, so seeding a Stars price in the admin is the go-live. Tests: backend integration (order->forward->credit once, duplicate, pre_checkout gate) + bot outbox unit (idempotent, restart re-drive) + executor createInvoice. Docs: PAYMENTS(+ru) §9, ARCHITECTURE, the platform/telegram README, PLAN.
566 lines
22 KiB
Go
566 lines
22 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"
|
|
|
|
"scrabble/platform/telegram/internal/outbox"
|
|
"scrabble/platform/telegram/internal/support"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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),
|
|
}
|
|
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))
|
|
}
|
|
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)
|
|
}
|
|
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
|
|
}
|