Ctrl K
ComunidadGuías…

internal/app/bots/premiumbot.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package bots

import (
	"context"
	"fmt"
	"strconv"
	"strings"
	"time"

	"telesrv/internal/branding"
	"telesrv/internal/domain"
)

const premiumBotHelpText = `Покупка Telegram Premium за Stars.

/premium - доступные тарифы
/gift - подарить Premium другому пользователю
/status - проверить срок действия Premium
/history - последние покупки и подарки
/terms - условия покупки
/deposit - получить личный адрес для крипто-депозита
/help - показать это сообщение

Цена и длительность загружаются из каталога сервера. Перед оплатой цена
проверяется повторно, а списание Stars выполняется атомарно.`

const premiumBotHelpTextEN = `Buy Telegram Premium with Stars.

/premium - available plans
/gift - gift Premium to another user
/status - check your Premium expiry
/history - recent purchases and gifts
/terms - purchase terms
/deposit - get your personal crypto deposit address
/help - show this message

Prices and durations come from the server catalog. The price is checked again
before payment, and Stars are debited atomically.`

type premiumBotLocale string

const (
	premiumBotLocaleRU premiumBotLocale = "ru"
	premiumBotLocaleEN premiumBotLocale = "en"
)

func premiumLocaleFromSession(session domain.ClientSessionMetadata) premiumBotLocale {
	if session.PreferredLanguage() == "ru" {
		return premiumBotLocaleRU
	}
	if session.PreferredLanguage() == "" {
		// Preserve the historical default for legacy/internal calls without
		// initConnection metadata. Real Telegram sessions carry lang_code.
		return premiumBotLocaleRU
	}
	return premiumBotLocaleEN
}

func premiumLocaleArg(locales []premiumBotLocale) premiumBotLocale {
	if len(locales) > 0 && locales[0] != "" {
		return locales[0]
	}
	return premiumBotLocaleRU
}

// NotifyDonationCredited satisfies donations.CreditNotifier: it's called
// once per deposit the crypto donations watcher just credited (see
// cmd/telesrv, which wires donationsService.SetNotifier(botsService)), and
// sends the donor a @premiumbot message saying so. Without this wired up,
// crediting still happens exactly the same -- the user just finds out only
// by checking /deposit or their Stars balance themselves, never proactively.
func (s *Service) NotifyDonationCredited(ctx context.Context, notice domain.DonationCreditNotice) {
	if s == nil || notice.UserID <= 0 || notice.Deposit.StarsCredited <= 0 {
		return
	}
	mu := s.serviceBotReplyLock(domain.PremiumBotUserID, notice.UserID)
	mu.Lock()
	defer mu.Unlock()
	amount := formatAssetAmount(notice.Deposit.AmountRaw, notice.AssetDecimals)
	text := fmt.Sprintf("Your deposit of %s %s on %s was confirmed and credited: +%d %s.",
		amount, notice.AssetSymbol, notice.ChainName, notice.Deposit.StarsCredited, branding.StarsName())
	s.sendServiceBotReply(ctx, domain.PremiumBotUserID, notice.UserID, botReply{Text: text})
}

// formatAssetAmount renders a raw smallest-unit amount (wei, or an ERC-20's
// base units) as a human string with the asset's actual decimal point.
// Deliberately duplicated from (rather than imported off)
// app/donations.FormatAssetAmount: bots depends on donations only through
// the narrow donationsSource/CreditNotifier ports, matching every other
// cross-service dependency in this file, never the concrete package.
func formatAssetAmount(amountRaw string, decimals int) string {
	if decimals <= 0 || len(amountRaw) == 0 {
		return amountRaw
	}
	neg := strings.HasPrefix(amountRaw, "-")
	digits := strings.TrimPrefix(amountRaw, "-")
	for len(digits) <= decimals {
		digits = "0" + digits
	}
	intPart, fracPart := digits[:len(digits)-decimals], strings.TrimRight(digits[len(digits)-decimals:], "0")
	out := intPart
	if fracPart != "" {
		out += "." + fracPart
	}
	if neg {
		out = "-" + out
	}
	return out
}

func (locale premiumBotLocale) text(ru, en string) string {
	if locale == premiumBotLocaleRU {
		return ru
	}
	return en
}

func (locale premiumBotLocale) help() string {
	return locale.text(premiumBotHelpText, premiumBotHelpTextEN)
}

const premiumGiftPeerButtonID = 7101

const (
	premiumCallbackPrefix  = "premium:"
	premiumCallbackPlans   = premiumCallbackPrefix + "plans"
	premiumCallbackGift    = premiumCallbackPrefix + "gift"
	premiumCallbackStatus  = premiumCallbackPrefix + "status"
	premiumCallbackHistory = premiumCallbackPrefix + "history"
	premiumCallbackTerms   = premiumCallbackPrefix + "terms"
	premiumCallbackBuy     = premiumCallbackPrefix + "buy:"
	premiumCallbackGiftBuy = premiumCallbackPrefix + "gift-buy:"
)

func (s *Service) respondAsPremium(botUserID, userID int64, msg domain.Message, session domain.ClientSessionMetadata) {
	mu := s.serviceBotReplyLock(botUserID, userID)
	mu.Lock()
	defer mu.Unlock()

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	locale := premiumLocaleFromSession(session)
	s.sendServiceBotReply(ctx, botUserID, userID, s.handlePremiumMessage(ctx, userID, msg, locale))
}

func (s *Service) handlePremiumMessage(ctx context.Context, userID int64, msg domain.Message, locales ...premiumBotLocale) botReply {
	locale := premiumLocaleArg(locales)
	if msg.Media != nil && msg.Media.ServiceAction != nil &&
		msg.Media.ServiceAction.Kind == domain.MessageServiceActionRequestedPeer {
		action := msg.Media.ServiceAction.RequestedPeer
		if action == nil || action.ButtonID != premiumGiftPeerButtonID ||
			len(action.Peers) != 1 || action.Peers[0].Type != domain.PeerTypeUser {
			return s.premiumBotGiftError(locale.text(
				"Выберите ровно одного обычного пользователя кнопкой «Выбрать получателя».",
				"Select exactly one regular user with the “Choose recipient” button."), locale)
		}
		return s.premiumBotGiftPlans(ctx, userID, action.Peers[0].ID, locale)
	}
	return s.handlePremium(ctx, userID, msg.Body, locale)
}

func (s *Service) handlePremium(ctx context.Context, userID int64, body string, locales ...premiumBotLocale) botReply {
	locale := premiumLocaleArg(locales)
	command, argument := premiumBotCommand(body)
	switch command {
	case "start":
		if strings.HasPrefix(argument, "buy_") {
			months, err := strconv.Atoi(strings.TrimPrefix(argument, "buy_"))
			if err == nil {
				return s.premiumBotInvoice(ctx, userID, months, locale)
			}
		}
		switch argument {
		case "premium":
			return s.premiumBotPlans(ctx, locale.text("Выберите тариф Premium:", "Choose a Premium plan:"), locale)
		case "gift":
			return s.premiumBotGift(locale)
		case "status":
			return s.premiumBotStatus(ctx, userID, locale)
		case "history":
			return s.premiumBotHistory(ctx, userID, locale)
		case "terms":
			return premiumBotTerms(locale)
		default:
			// settings/profile/business/deeplink and feature identifiers are
			// navigation hints only. They never select a price or recipient.
			return s.premiumBotHome(locale.text("Добро пожаловать в магазин Premium.", "Welcome to the Premium store."), locale)
		}
	case "premium", "buy":
		if months, err := strconv.Atoi(argument); err == nil && months > 0 {
			return s.premiumBotInvoice(ctx, userID, months, locale)
		}
		return s.premiumBotPlans(ctx, locale.text("Выберите тариф Premium:", "Choose a Premium plan:"), locale)
	case "status":
		return s.premiumBotStatus(ctx, userID, locale)
	case "gift":
		return s.handlePremiumGiftCommand(ctx, userID, argument, locale)
	case "history":
		return s.premiumBotHistory(ctx, userID, locale)
	case "terms":
		return premiumBotTerms(locale)
	case "help", "":
		return s.premiumBotHome(locale.help(), locale)
	case "deposit":
		return s.premiumBotDepositReply(ctx, userID)
	default:
		return s.premiumBotHome(locale.text("Неизвестная команда.", "Unknown command.")+"\n\n"+locale.help(), locale)
	}
}

func premiumBotCommand(body string) (command, argument string) {
	fields := strings.Fields(strings.TrimSpace(body))
	if len(fields) == 0 {
		return "", ""
	}
	head := strings.TrimPrefix(fields[0], "/")
	if at := strings.IndexByte(head, '@'); at >= 0 {
		head = head[:at]
	}
	if len(fields) > 1 {
		argument = strings.Join(fields[1:], " ")
	}
	return strings.ToLower(head), argument
}

func (s *Service) premiumBotHome(text string, locales ...premiumBotLocale) botReply {
	locale := premiumLocaleArg(locales)
	button := func(label, data string) domain.MarkupButton {
		return domain.MarkupButton{
			Type: domain.MarkupButtonCallback,
			Text: label,
			Data: []byte(data),
		}
	}
	return botReply{
		Text: text,
		ReplyMarkup: &domain.MessageReplyMarkup{
			Type: domain.MessageReplyMarkupInline,
			Inline: [][]domain.MarkupButton{
				{button(locale.text("Купить Premium", "Buy Premium"), premiumCallbackPlans), button(locale.text("Подарить Premium", "Gift Premium"), premiumCallbackGift)},
				{button(locale.text("Мой Premium", "My Premium"), premiumCallbackStatus), button(locale.text("История покупок", "Purchase history"), premiumCallbackHistory)},
				{button(locale.text("Условия", "Terms"), premiumCallbackTerms)},
			},
		},
	}
}

func (s *Service) premiumBotPlans(ctx context.Context, heading string, locales ...premiumBotLocale) botReply {
	locale := premiumLocaleArg(locales)
	if s.premium == nil {
		return botReply{Text: locale.text("Покупка Premium временно недоступна.", "Premium purchases are temporarily unavailable.")}
	}
	plans, err := s.premium.Plans(ctx)
	if err != nil || len(plans) == 0 {
		return botReply{Text: locale.text("Тарифы Premium временно недоступны.", "Premium plans are temporarily unavailable.")}
	}
	var text strings.Builder
	text.WriteString(heading)
	text.WriteString("\n\n")
	rows := make([][]domain.MarkupButton, 0, len(plans))
	for _, plan := range plans {
		if !plan.Valid() || !plan.Enabled {
			continue
		}
		fmt.Fprintf(&text, "%s - %d Stars\n", plan.Label, plan.AmountStars)
		rows = append(rows, []domain.MarkupButton{{
			Type: domain.MarkupButtonCallback,
			Text: fmt.Sprintf("%s - %d Stars", plan.Label, plan.AmountStars),
			Data: []byte(premiumCallbackBuy + strconv.Itoa(plan.Months)),
		}})
	}
	if len(rows) == 0 {
		return botReply{Text: locale.text("Тарифы Premium временно недоступны.", "Premium plans are temporarily unavailable.")}
	}
	return botReply{
		Text: strings.TrimSpace(text.String()),
		ReplyMarkup: &domain.MessageReplyMarkup{
			Type:   domain.MessageReplyMarkupInline,
			Inline: rows,
		},
	}
}

// premiumBotDepositReply answers /deposit with the user's permanent crypto
// deposit address (the same address on every chain listed) and which
// chains it actually works on right now. The address is assigned once, on
// first call, and reused forever after -- see donations.Service.AddressForUser.
//
// It returns a full botReply rather than plain text because this server has
// no markdown parser on the send path (see service_bot_entities.go): the
// address must be marked up as a domain.MessageEntityCode span, the same
// way tokenReply does for BotFather tokens, not wrapped in literal
// backticks the client would show verbatim.
func (s *Service) premiumBotDepositReply(ctx context.Context, userID int64) botReply {
	if s.donations == nil || !s.donations.Ready() {
		return botReply{Text: "Crypto deposits are not available right now."}
	}
	chains, err := s.donations.EnabledChains(ctx)
	if err != nil {
		return botReply{Text: "Could not load deposit info right now. Please try again later."}
	}
	var live []string
	for _, c := range chains {
		if c.Watchable() {
			live = append(live, fmt.Sprintf("%s (%s)", c.Name, c.NativeSymbol))
		}
	}
	if len(live) == 0 {
		return botReply{Text: "Crypto deposits are not available right now -- no chain is configured yet."}
	}
	address, err := s.donations.AddressForUser(ctx, userID)
	if err != nil || address == "" {
		return botReply{Text: "Could not assign your deposit address right now. Please try again later."}
	}
	head := "Your personal crypto deposit address:\n\n"
	tail := "\n\nThis address works on: " + strings.Join(live, ", ") + ".\n\n" +
		"Send ETH, USDT or USDC to it from any wallet. Once your deposit reaches enough confirmations, it's automatically converted to " + branding.StarsName() + " and credited to your balance -- no further action needed.\n\n" +
		"This is the same address every time you check /deposit -- do not send funds on a chain not listed above, they will not be credited."
	return botReply{
		Text: head + address + tail,
		Entities: []domain.MessageEntity{
			// address is ASCII (0x + 40 lowercase hex chars), so byte length
			// and UTF-16 length coincide -- same assumption tokenReply makes.
			{Type: domain.MessageEntityCode, Offset: len(head), Length: len(address)},
		},
	}
}

func (s *Service) premiumBotInvoice(ctx context.Context, userID int64, months int, locales ...premiumBotLocale) botReply {
	locale := premiumLocaleArg(locales)
	if s.premium == nil {
		return botReply{Text: locale.text("Покупка Premium временно недоступна.", "Premium purchases are temporarily unavailable.")}
	}
	plan, err := s.premium.Plan(ctx, months)
	if err != nil || !plan.Valid() || !plan.Enabled {
		return s.premiumBotPlans(ctx, locale.text(
			"Этот тариф больше недоступен. Выберите актуальный тариф:",
			"This plan is no longer available. Choose a current plan:"), locale)
	}
	title := fmt.Sprintf("Telegram Premium - %s", plan.Label)
	invoice := &domain.PremiumInvoice{
		Kind:         domain.PremiumPurchaseSelf,
		Months:       plan.Months,
		DurationDays: plan.DurationDays,
		AmountStars:  plan.AmountStars,
		PlanVersion:  plan.Version,
		Title:        title,
		Description: fmt.Sprintf(locale.text(
			"%d дней Telegram Premium за %d Stars.",
			"%d days of Telegram Premium for %d Stars."), plan.DurationDays, plan.AmountStars),
		StartParam: fmt.Sprintf("buy_%d", plan.Months),
	}
	balanceText := locale.text("недоступен", "unavailable")
	if balance, balanceErr := s.premium.Balance(ctx, userID); balanceErr == nil {
		balanceText = fmt.Sprintf("%d Stars", balance.Balance)
	}
	return botReply{
		Text:  fmt.Sprintf(locale.text("%s\nТекущий баланс: %s.", "%s\nCurrent balance: %s."), title, balanceText),
		Media: &domain.MessageMedia{Kind: domain.MessageMediaKindInvoice, Invoice: invoice},
		ReplyMarkup: &domain.MessageReplyMarkup{
			Type: domain.MessageReplyMarkupInline,
			Inline: [][]domain.MarkupButton{{{
				Type:  domain.MarkupButtonBuy,
				Text:  fmt.Sprintf(locale.text("Оплатить %d Stars", "Pay %d Stars"), plan.AmountStars),
				Style: domain.MarkupButtonStyleSuccess,
			}}},
		},
	}
}

func (s *Service) premiumBotGift(locales ...premiumBotLocale) botReply {
	locale := premiumLocaleArg(locales)
	filter := &domain.BotRequestPeerFilter{UserIsBotSet: true, UserIsBot: false}
	return botReply{
		Text: locale.text("Выберите пользователя, которому хотите подарить Telegram Premium.", "Choose a user to receive Telegram Premium."),
		ReplyMarkup: &domain.MessageReplyMarkup{
			Type:        domain.MessageReplyMarkupKeyboard,
			Resize:      true,
			SingleUse:   true,
			Placeholder: locale.text("Выберите получателя Premium", "Choose a Premium recipient"),
			Keyboard: [][]domain.MarkupButton{{{
				Type:              domain.MarkupButtonRequestPeer,
				Text:              locale.text("Выбрать получателя", "Choose recipient"),
				ButtonID:          premiumGiftPeerButtonID,
				RequestPeerType:   "user",
				MaxQuantity:       1,
				NameRequested:     true,
				UsernameRequested: true,
				RequestPeerFilter: filter,
			}}},
		},
	}
}

func (s *Service) premiumBotGiftError(text string, locales ...premiumBotLocale) botReply {
	reply := s.premiumBotGift(locales...)
	reply.Text = text
	return reply
}

func (s *Service) handlePremiumGiftCommand(ctx context.Context, userID int64, argument string, locales ...premiumBotLocale) botReply {
	locale := premiumLocaleArg(locales)
	fields := strings.Fields(argument)
	if len(fields) == 0 {
		return s.premiumBotGift(locale)
	}
	recipientID, err := strconv.ParseInt(fields[0], 10, 64)
	if err != nil || recipientID <= 0 {
		return s.premiumBotGiftError(locale.text("Получатель указан неверно. Выберите пользователя ещё раз.", "The recipient is invalid. Choose a user again."), locale)
	}
	if len(fields) == 1 {
		return s.premiumBotGiftPlans(ctx, userID, recipientID, locale)
	}
	months, err := strconv.Atoi(fields[1])
	if err != nil || months <= 0 {
		return s.premiumBotGiftPlans(ctx, userID, recipientID, locale)
	}
	message := strings.Join(fields[2:], " ")
	if len([]rune(message)) > domain.MaxPremiumGiftMessageRunes {
		return botReply{Text: fmt.Sprintf(locale.text(
			"Поздравление слишком длинное. Допустимо не более %d символов.",
			"The greeting is too long. The limit is %d characters."), domain.MaxPremiumGiftMessageRunes)}
	}
	return s.premiumBotGiftInvoice(ctx, userID, recipientID, months, message, locale)
}

func (s *Service) premiumBotGiftRecipient(
	ctx context.Context,
	buyerUserID, recipientUserID int64,
) (domain.User, bool) {
	if s == nil || s.users == nil || recipientUserID <= 0 || recipientUserID == buyerUserID ||
		domain.IsSystemUserID(recipientUserID) {
		return domain.User{}, false
	}
	user, found, err := s.users.ByID(ctx, recipientUserID)
	if err != nil || !found || user.Bot || user.Deleted {
		return domain.User{}, false
	}
	return user, true
}

func (s *Service) premiumBotGiftPlans(ctx context.Context, buyerUserID, recipientUserID int64, locales ...premiumBotLocale) botReply {
	locale := premiumLocaleArg(locales)
	recipient, valid := s.premiumBotGiftRecipient(ctx, buyerUserID, recipientUserID)
	if !valid {
		return s.premiumBotGiftError(locale.text("Этому аккаунту нельзя подарить Premium. Выберите другого пользователя.", "Premium cannot be gifted to this account. Choose another user."), locale)
	}
	if s.premium == nil {
		return botReply{Text: locale.text("Подарки Premium временно недоступны.", "Premium gifts are temporarily unavailable.")}
	}
	plans, err := s.premium.Plans(ctx)
	if err != nil || len(plans) == 0 {
		return botReply{Text: locale.text("Тарифы Premium временно недоступны.", "Premium plans are temporarily unavailable.")}
	}
	name := strings.TrimSpace(recipient.FirstName + " " + recipient.LastName)
	if name == "" {
		name = fmt.Sprintf(locale.text("пользователь %d", "user %d"), recipient.ID)
	}
	rows := make([][]domain.MarkupButton, 0, len(plans))
	var text strings.Builder
	fmt.Fprintf(&text, locale.text("Подарить Telegram Premium пользователю %s:\n", "Gift Telegram Premium to %s:\n"), name)
	for _, plan := range plans {
		if !plan.Valid() || !plan.Enabled {
			continue
		}
		fmt.Fprintf(&text, "\n%s - %d Stars", plan.Label, plan.AmountStars)
		rows = append(rows, []domain.MarkupButton{{
			Type: domain.MarkupButtonCallback,
			Text: fmt.Sprintf("%s - %d Stars", plan.Label, plan.AmountStars),
			Data: []byte(fmt.Sprintf("%s%d:%d", premiumCallbackGiftBuy, recipient.ID, plan.Months)),
		}})
	}
	if len(rows) == 0 {
		return botReply{Text: locale.text("Тарифы Premium временно недоступны.", "Premium plans are temporarily unavailable.")}
	}
	return botReply{
		Text: text.String(),
		ReplyMarkup: &domain.MessageReplyMarkup{
			Type:   domain.MessageReplyMarkupInline,
			Inline: rows,
		},
	}
}

func (s *Service) premiumBotGiftInvoice(
	ctx context.Context,
	buyerUserID, recipientUserID int64,
	months int,
	greeting string,
	locales ...premiumBotLocale,
) botReply {
	locale := premiumLocaleArg(locales)
	recipient, valid := s.premiumBotGiftRecipient(ctx, buyerUserID, recipientUserID)
	if !valid {
		return s.premiumBotGiftError(locale.text("Этому аккаунту нельзя подарить Premium. Выберите другого пользователя.", "Premium cannot be gifted to this account. Choose another user."), locale)
	}
	if s.premium == nil {
		return botReply{Text: locale.text("Подарки Premium временно недоступны.", "Premium gifts are temporarily unavailable.")}
	}
	plan, err := s.premium.Plan(ctx, months)
	if err != nil || !plan.Valid() || !plan.Enabled {
		return s.premiumBotGiftPlans(ctx, buyerUserID, recipientUserID, locale)
	}
	name := strings.TrimSpace(recipient.FirstName + " " + recipient.LastName)
	if name == "" {
		name = fmt.Sprintf(locale.text("пользователь %d", "user %d"), recipient.ID)
	}
	title := fmt.Sprintf("Подарок Telegram Premium - %s", plan.Label)
	invoice := &domain.PremiumInvoice{
		Kind:            domain.PremiumPurchaseGift,
		RecipientUserID: recipient.ID,
		Months:          plan.Months,
		DurationDays:    plan.DurationDays,
		AmountStars:     plan.AmountStars,
		PlanVersion:     plan.Version,
		Title:           title,
		Description: fmt.Sprintf(locale.text(
			"%d дней Telegram Premium для %s за %d Stars.",
			"%d days of Telegram Premium for %s for %d Stars."), plan.DurationDays, name, plan.AmountStars),
		StartParam: fmt.Sprintf("gift_%d", plan.Months),
		Message:    domain.PremiumGiftMessage{Text: greeting},
	}
	balanceText := locale.text("недоступен", "unavailable")
	if balance, balanceErr := s.premium.Balance(ctx, buyerUserID); balanceErr == nil {
		balanceText = fmt.Sprintf("%d Stars", balance.Balance)
	}
	return botReply{
		Text: fmt.Sprintf(locale.text(
			"Подтвердите покупку тарифа %s для %s.\nЦена: %d Stars.\nТекущий баланс: %s.",
			"Confirm the %s plan for %s.\nPrice: %d Stars.\nCurrent balance: %s."), plan.Label, name, plan.AmountStars, balanceText),
		Media: &domain.MessageMedia{Kind: domain.MessageMediaKindInvoice, Invoice: invoice},
		ReplyMarkup: &domain.MessageReplyMarkup{
			Type: domain.MessageReplyMarkupInline,
			Inline: [][]domain.MarkupButton{{{
				Type:  domain.MarkupButtonBuy,
				Text:  fmt.Sprintf(locale.text("Оплатить %d Stars", "Pay %d Stars"), plan.AmountStars),
				Style: domain.MarkupButtonStyleSuccess,
			}}},
		},
	}
}

func (s *Service) premiumBotHistory(ctx context.Context, userID int64, locales ...premiumBotLocale) botReply {
	locale := premiumLocaleArg(locales)
	if s.premium == nil {
		return botReply{Text: locale.text("История покупок Premium временно недоступна.", "Premium purchase history is temporarily unavailable.")}
	}
	items, err := s.premium.PurchaseHistory(ctx, userID, 10)
	if err != nil {
		return botReply{Text: locale.text("История покупок Premium временно недоступна.", "Premium purchase history is temporarily unavailable.")}
	}
	if len(items) == 0 {
		return botReply{Text: locale.text("У вас пока нет покупок или подарков Premium.", "You do not have any Premium purchases or gifts yet.")}
	}
	var text strings.Builder
	text.WriteString(locale.text("Последние покупки и подарки Premium:\n", "Recent Premium purchases and gifts:\n"))
	for _, item := range items {
		kind := locale.text("для себя", "for yourself")
		if item.UserID != userID {
			kind = fmt.Sprintf(locale.text("подарок пользователю %d", "gift to user %d"), item.UserID)
		} else if item.Source == domain.PremiumEntitlementGift && item.SourceUserID != userID {
			kind = fmt.Sprintf(locale.text("подарок от пользователя %d", "gift from user %d"), item.SourceUserID)
		}
		fmt.Fprintf(&text, locale.text("\n%d мес. - %s - %s - %s", "\n%d mo. - %s - %s - %s"), item.Months, kind, item.Status,
			time.Unix(int64(item.CreatedAt), 0).UTC().Format("2006-01-02"))
	}
	return botReply{Text: text.String()}
}

func premiumBotTerms(locales ...premiumBotLocale) botReply {
	locale := premiumLocaleArg(locales)
	return botReply{Text: locale.text(`Premium продаётся за Telegram Stars по цене, указанной в форме оплаты.

Новая покупка продлевает текущий срок Premium и никогда не сокращает его. Каждое успешное списание записывается в журнал Stars и создаёт отдельное право Premium. Возврат выполняется отдельной компенсирующей операцией; исходная транзакция не удаляется.`, `Premium is sold for Telegram Stars at the price shown on the payment form.

A new purchase extends the current Premium term and never shortens it. Every successful debit is recorded in the Stars ledger and creates a separate Premium entitlement. Refunds use a separate compensating operation; the original transaction is retained.`)}
}

func (s *Service) premiumBotStatus(ctx context.Context, userID int64, locales ...premiumBotLocale) botReply {
	locale := premiumLocaleArg(locales)
	if s.premium == nil {
		return botReply{Text: locale.text("Статус Premium временно недоступен.", "Premium status is temporarily unavailable.")}
	}
	now := int(s.now().Unix())
	entitlements, err := s.premium.ActiveEntitlements(ctx, userID, now)
	if err != nil {
		return botReply{Text: locale.text("Статус Premium временно недоступен.", "Premium status is temporarily unavailable.")}
	}
	var expiresAt int
	for _, entitlement := range entitlements {
		if entitlement.Status == domain.PremiumEntitlementActive && entitlement.ExpiresAt > expiresAt {
			expiresAt = entitlement.ExpiresAt
		}
	}
	if expiresAt <= now {
		return s.premiumBotPlans(ctx, locale.text("Premium не активен. Выберите тариф:", "Premium is not active. Choose a plan:"), locale)
	}
	return botReply{Text: fmt.Sprintf(locale.text("Premium активен до %s UTC.", "Premium is active until %s UTC."),
		time.Unix(int64(expiresAt), 0).UTC().Format("2006-01-02 15:04"))}
}

func (s *Service) onPremiumCallback(
	ctx context.Context,
	query domain.BotCallbackQuery,
) (domain.BotCallbackAnswer, bool, error) {
	userID := query.UserID
	locale := premiumLocaleFromSession(query.ClientSession)
	mu := s.serviceBotReplyLock(query.BotUserID, userID)
	mu.Lock()
	defer mu.Unlock()

	data := string(query.Data)
	var reply botReply
	switch data {
	case premiumCallbackPlans:
		reply = s.premiumBotPlans(ctx, locale.text("Выберите тариф Premium:", "Choose a Premium plan:"), locale)
	case premiumCallbackGift:
		reply = s.premiumBotGift(locale)
	case premiumCallbackStatus:
		reply = s.premiumBotStatus(ctx, userID, locale)
	case premiumCallbackHistory:
		reply = s.premiumBotHistory(ctx, userID, locale)
	case premiumCallbackTerms:
		reply = premiumBotTerms(locale)
	default:
		switch {
		case strings.HasPrefix(data, premiumCallbackBuy):
			months, err := strconv.Atoi(strings.TrimPrefix(data, premiumCallbackBuy))
			if err != nil || months <= 0 {
				return premiumBotCallbackExpired(locale), true, nil
			}
			reply = s.premiumBotInvoice(ctx, userID, months, locale)
		case strings.HasPrefix(data, premiumCallbackGiftBuy):
			payload := strings.TrimPrefix(data, premiumCallbackGiftBuy)
			recipientText, monthsText, ok := strings.Cut(payload, ":")
			recipientID, recipientErr := strconv.ParseInt(recipientText, 10, 64)
			months, monthsErr := strconv.Atoi(monthsText)
			if !ok || recipientErr != nil || monthsErr != nil || recipientID <= 0 || months <= 0 {
				return premiumBotCallbackExpired(locale), true, nil
			}
			reply = s.premiumBotGiftInvoice(ctx, userID, recipientID, months, "", locale)
		default:
			return premiumBotCallbackExpired(locale), true, nil
		}
	}
	sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
	defer cancel()
	s.sendServiceBotReply(sendCtx, query.BotUserID, userID, reply)
	return domain.BotCallbackAnswer{}, true, nil
}

func premiumBotCallbackExpired(locales ...premiumBotLocale) domain.BotCallbackAnswer {
	locale := premiumLocaleArg(locales)
	return domain.BotCallbackAnswer{
		Alert:   true,
		Message: locale.text("Кнопка устарела. Отправьте /start и попробуйте ещё раз.", "This button has expired. Send /start and try again."),
	}
}