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)) } } }