internal/app/bots/premiumbot.go
Rama main · réplica de lectura del repositorio original.
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."),
}
}