Ctrl K
ComunidadGuías…

internal/app/bots/gramsrvbot.go

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

Archivo original ↗Preguntar a KiBot
package bots

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

	"go.uber.org/zap"

	adminapp "telesrv/internal/admin"
	"telesrv/internal/domain"
)

const (
	gramsrvCallbackPrefix = "gs:account:"
	// The account-freeze schema stores frozen_until as an int32 timestamp. The
	// maximum value gives the operator bot an effectively indefinite freeze while
	// staying inside the same validation rules as the admin API.
	gramsrvFreezeUntilUnix int64 = 1<<31 - 1
)

// gramsrvFreezeAdmin is the narrow admin surface the built-in bot needs. The
// command still goes through admin.Service, so audit rows, idempotency,
// projection invalidation and online freeze notifications remain identical to
// actions from the admin API.
type gramsrvFreezeAdmin interface {
	AccountFreeze(context.Context, int64) (domain.AccountFreeze, bool, error)
	SetAccountFrozen(context.Context, adminapp.SetAccountFrozenRequest) (adminapp.CommandResult, error)
}

// WithGramsrvAdmin enables the built-in @gramsrv moderation bot and installs
// its numeric private-chat allowlist. Empty IDs leave the bot discoverable but
// deny all moderation actions.
func WithGramsrvAdmin(admin gramsrvFreezeAdmin, chatIDs []int64) Option {
	return func(s *Service) {
		s.gramsrvAdmin = admin
		s.gramsrvAdminChatIDs = make(map[int64]struct{}, len(chatIDs))
		for _, id := range chatIDs {
			if id > 0 {
				s.gramsrvAdminChatIDs[id] = struct{}{}
			}
		}
	}
}

func (s *Service) gramsrvChatAllowed(chatID int64) bool {
	if s == nil || chatID <= 0 {
		return false
	}
	_, ok := s.gramsrvAdminChatIDs[chatID]
	return ok
}

func (s *Service) respondAsGramsrv(userID int64, body string) {
	mu := s.serviceBotReplyLock(domain.GramsrvBotUserID, userID)
	mu.Lock()
	defer mu.Unlock()

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	var reply botReply
	if !s.gramsrvChatAllowed(userID) {
		reply = botReply{Text: "Нет доступа: этот chat_id не входит в allowlist Gramsrv."}
	} else {
		reply = s.handleGramsrvCommand(ctx, userID, body)
	}
	s.sendServiceBotReply(ctx, domain.GramsrvBotUserID, userID, reply)
}

func gramsrvHelpText() string {
	return "Команды @gramsrv:\n\n" +
		"/account @username — показать статус и кнопку переключения\n" +
		"/freeze @username — подготовить заморозку\n" +
		"/unfreeze @username — подготовить разморозку\n" +
		"/help — эта справка"
}

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

func (s *Service) handleGramsrvCommand(ctx context.Context, _ int64, body string) botReply {
	command, argument := parseGramsrvCommand(body)
	switch command {
	case "start", "help":
		return botReply{Text: gramsrvHelpText()}
	case "account", "status":
		if argument == "" {
			return botReply{Text: "Укажи аккаунт: /account @username или /account 123456789."}
		}
		return s.gramsrvAccountReply(ctx, argument, nil)
	case "freeze":
		if argument == "" {
			return botReply{Text: "Укажи аккаунт: /freeze @username или /freeze 123456789."}
		}
		desired := true
		return s.gramsrvAccountReply(ctx, argument, &desired)
	case "unfreeze":
		if argument == "" {
			return botReply{Text: "Укажи аккаунт: /unfreeze @username или /unfreeze 123456789."}
		}
		desired := false
		return s.gramsrvAccountReply(ctx, argument, &desired)
	default:
		return botReply{Text: gramsrvHelpText()}
	}
}

func (s *Service) gramsrvResolveTarget(ctx context.Context, reference string) (domain.User, error) {
	if s == nil || s.users == nil {
		return domain.User{}, fmt.Errorf("user lookup is unavailable")
	}
	reference = strings.TrimSpace(reference)
	if reference == "" {
		return domain.User{}, fmt.Errorf("account is required")
	}
	if strings.HasPrefix(reference, "@") {
		reference = strings.TrimPrefix(reference, "@")
	}
	if reference == "" {
		return domain.User{}, fmt.Errorf("username is empty")
	}
	if id, err := strconv.ParseInt(reference, 10, 64); err == nil {
		user, found, lookupErr := s.users.ByID(ctx, id)
		if lookupErr != nil {
			return domain.User{}, lookupErr
		}
		if !found {
			return domain.User{}, fmt.Errorf("аккаунт %q не найден", reference)
		}
		return user, nil
	}
	user, found, err := s.users.ByUsername(ctx, reference)
	if err != nil {
		return domain.User{}, err
	}
	if !found {
		return domain.User{}, fmt.Errorf("аккаунт @%s не найден", reference)
	}
	return user, nil
}

func gramsrvUserLabel(user domain.User) string {
	if user.Username != "" {
		return "@" + user.Username
	}
	return fmt.Sprintf("id=%d", user.ID)
}

func (s *Service) gramsrvAccountReply(ctx context.Context, reference string, requested *bool) botReply {
	user, err := s.gramsrvResolveTarget(ctx, reference)
	if err != nil {
		return botReply{Text: err.Error()}
	}
	if domain.IsSystemUserID(user.ID) {
		return botReply{Text: "Системные аккаунты через @gramsrv менять нельзя."}
	}
	if s.gramsrvAdmin == nil {
		return botReply{Text: "Модерация через @gramsrv не настроена."}
	}
	freeze, found, err := s.gramsrvAdmin.AccountFreeze(ctx, user.ID)
	if err != nil {
		return botReply{Text: "Не удалось прочитать состояние аккаунта."}
	}
	current := found && freeze.Frozen
	desired := !current
	if requested != nil {
		desired = *requested
	}
	state := "разморожен"
	if current {
		state = "заморожен"
	}
	action := "Заморозить"
	style := domain.MarkupButtonStyleDanger
	if !desired {
		action = "Разморозить"
		style = domain.MarkupButtonStyleSuccess
	}
	text := fmt.Sprintf("Аккаунт %s (id %d) сейчас %s.", gramsrvUserLabel(user), user.ID, state)
	text += "\nНажми одну кнопку для изменения состояния."
	return botReply{
		Text: text,
		ReplyMarkup: &domain.MessageReplyMarkup{
			Type: domain.MessageReplyMarkupInline,
			Inline: [][]domain.MarkupButton{{{
				Type:  domain.MarkupButtonCallback,
				Text:  action,
				Style: style,
				Data:  []byte(fmt.Sprintf("%s%d:%d", gramsrvCallbackPrefix, user.ID, boolInt(desired))),
			}}},
		},
	}
}

func boolInt(value bool) int {
	if value {
		return 1
	}
	return 0
}

func parseGramsrvCallback(data []byte) (targetID int64, frozen bool, ok bool) {
	parts := strings.Split(string(data), ":")
	if len(parts) != 4 || parts[0] != "gs" || parts[1] != "account" {
		return 0, false, false
	}
	id, err := strconv.ParseInt(parts[2], 10, 64)
	if err != nil || id <= 0 {
		return 0, false, false
	}
	state, err := strconv.Atoi(parts[3])
	if err != nil || (state != 0 && state != 1) {
		return 0, false, false
	}
	return id, state == 1, true
}

func (s *Service) onGramsrvCallback(ctx context.Context, query domain.BotCallbackQuery) (domain.BotCallbackAnswer, bool, error) {
	if query.Peer.Type != domain.PeerTypeUser || query.Peer.ID != query.UserID || !s.gramsrvChatAllowed(query.UserID) {
		return domain.BotCallbackAnswer{Message: "Нет доступа для этого chat_id.", Alert: true}, true, nil
	}
	targetID, desired, ok := parseGramsrvCallback(query.Data)
	if !ok {
		return domain.BotCallbackAnswer{Message: "Кнопка устарела.", Alert: true}, true, nil
	}
	if domain.IsSystemUserID(targetID) {
		return domain.BotCallbackAnswer{Message: "Системные аккаунты менять нельзя.", Alert: true}, true, nil
	}
	if s.gramsrvAdmin == nil || s.users == nil {
		return domain.BotCallbackAnswer{Message: "Модерация недоступна.", Alert: true}, true, nil
	}
	target, found, err := s.users.ByID(ctx, targetID)
	if err != nil {
		return domain.BotCallbackAnswer{}, true, err
	}
	if !found {
		return domain.BotCallbackAnswer{Message: "Аккаунт уже не существует.", Alert: true}, true, nil
	}

	// Serialize clicks for one target across all three operator chats, then
	// re-read the state so a stale button cannot overwrite a newer action.
	mu := s.serviceBotReplyLock(domain.GramsrvBotUserID, targetID)
	mu.Lock()
	defer mu.Unlock()
	currentFreeze, currentFound, err := s.gramsrvAdmin.AccountFreeze(ctx, targetID)
	if err != nil {
		return domain.BotCallbackAnswer{}, true, err
	}
	current := currentFound && currentFreeze.Frozen
	if current == desired {
		return domain.BotCallbackAnswer{Message: "Состояние уже изменено."}, true, nil
	}
	commandID := fmt.Sprintf("gramsrv-%d-%d-%d-%d", time.Now().UnixNano(), query.UserID, targetID, boolInt(desired))
	request := adminapp.SetAccountFrozenRequest{
		CommandMeta: adminapp.CommandMeta{
			CommandID: commandID,
			Actor:     fmt.Sprintf("gramsrv-bot:chat:%d", query.UserID),
			Reason:    "inline moderation in built-in @gramsrv",
		},
		UserID: targetID,
		Frozen: desired,
	}
	if desired {
		request.Until = time.Unix(gramsrvFreezeUntilUnix, 0).UTC()
		request.AppealURL = "https://t.me/SpamBot"
	}
	if _, err := s.gramsrvAdmin.SetAccountFrozen(ctx, request); err != nil {
		return domain.BotCallbackAnswer{Message: "Изменение не применено.", Alert: true}, true, err
	}

	action := "разморожен"
	if desired {
		action = "заморожен"
	}
	notification := fmt.Sprintf("@gramsrv: аккаунт %s (id %d) %s.\nИнициатор: chat_id %d.", gramsrvUserLabel(target), target.ID, action, query.UserID)
	s.notifyGramsrvOperators(notification)
	// Leave the initiator with a fresh one-button card whose action is the
	// inverse of the committed state. The old card remains immutable in the
	// message history and becomes harmlessly idempotent.
	cardCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	updatedCard := s.gramsrvAccountReply(cardCtx, strconv.FormatInt(targetID, 10), nil)
	s.sendServiceBotReply(cardCtx, domain.GramsrvBotUserID, query.UserID, updatedCard)
	return domain.BotCallbackAnswer{Message: fmt.Sprintf("Аккаунт %s.", action)}, true, nil
}

func (s *Service) notifyGramsrvOperators(text string) {
	if s == nil || s.messages == nil {
		return
	}
	ids := make([]int64, 0, len(s.gramsrvAdminChatIDs))
	for id := range s.gramsrvAdminChatIDs {
		ids = append(ids, id)
	}
	sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	for _, id := range ids {
		if _, sent := s.sendServiceBotReplyResult(ctx, domain.GramsrvBotUserID, id, botReply{Text: text}); !sent {
			s.log.Warn("gramsrv bot: operator notification failed", zap.Int64("chat_id", id))
		}
	}
}