Файли
2026-11-08 03:05:46 +00:00

169 рядки
5.2 KiB
Go

package service
import (
"context"
"regexp"
"strconv"
"strings"
"git.entityseven.com/rilaxik/pllist/internal/domain"
)
var usernamePattern = regexp.MustCompile(`^[a-z0-9_]{5,32}$`)
type BotAccessRepository interface {
AllowTelegramID(ctx context.Context, telegramID int64) (bool, error)
AllowUsername(ctx context.Context, username string) (bool, error)
IsAllowed(ctx context.Context, telegramID int64) (bool, error)
TelegramIDForUsername(ctx context.Context, username string) (int64, bool, error)
Claim(ctx context.Context, telegramID int64, username string) error
RevokeTelegramID(ctx context.Context, telegramID int64) (bool, error)
RevokeUsername(ctx context.Context, username string) (bool, error)
}
// SharedAccessCleaner removes list shares when a user loses bot access.
type SharedAccessCleaner interface {
RemoveSharedAccess(ctx context.Context, telegramID int64, username string) error
}
type BotAccessService struct {
access BotAccessRepository
cleaner SharedAccessCleaner
ownerID int64
}
func NewBotAccessService(
access BotAccessRepository, ownerID int64, cleaner SharedAccessCleaner,
) *BotAccessService {
return &BotAccessService{access: access, cleaner: cleaner, ownerID: ownerID}
}
func (s *BotAccessService) IsOwner(telegramID int64) bool {
return s.ownerID > 0 && telegramID == s.ownerID
}
// IsAllowed reports bot access and includes the configured owner.
func (s *BotAccessService) IsAllowed(ctx context.Context, telegramID int64) (bool, error) {
if s.IsOwner(telegramID) {
return true, nil
}
return s.access.IsAllowed(ctx, telegramID)
}
// TelegramIDForUsername returns the bound Telegram ID for a grant. found is
// true for a pending username grant, where telegramID is zero.
func (s *BotAccessService) TelegramIDForUsername(
ctx context.Context, username string,
) (int64, bool, error) {
return s.access.TelegramIDForUsername(ctx, NormalizeUsername(username))
}
// EnsureAllowed claims any pending username grant and reports whether the
// Telegram user may use the bot.
func (s *BotAccessService) EnsureAllowed(
ctx context.Context, telegramID int64, username string,
) (bool, error) {
if s.IsOwner(telegramID) {
return true, nil
}
if err := s.access.Claim(ctx, telegramID, NormalizeUsername(username)); err != nil {
return false, err
}
return s.access.IsAllowed(ctx, telegramID)
}
type GrantTarget struct {
TelegramID int64
Username string
}
// Allow grants bot access to a username or numeric Telegram ID. It reports
// whether a new grant was created; an existing or pending grant returns false.
func (s *BotAccessService) Allow(
ctx context.Context, ownerID int64, raw string,
) (bool, GrantTarget, error) {
if !s.IsOwner(ownerID) {
return false, GrantTarget{}, domain.ErrForbidden
}
target, err := parseGrantTarget(raw, "Usage: /allow <username or id>.")
if err != nil {
return false, GrantTarget{}, err
}
if target.TelegramID == s.ownerID {
return false, target, nil
}
if target.TelegramID > 0 {
created, err := s.access.AllowTelegramID(ctx, target.TelegramID)
return created, target, err
}
created, err := s.access.AllowUsername(ctx, target.Username)
return created, target, err
}
// Kick revokes bot access and reports whether a grant was removed.
func (s *BotAccessService) Kick(
ctx context.Context, ownerID int64, raw string,
) (bool, GrantTarget, error) {
if !s.IsOwner(ownerID) {
return false, GrantTarget{}, domain.ErrForbidden
}
target, err := parseGrantTarget(raw, "Usage: /kick <username or id>.")
if err != nil {
return false, GrantTarget{}, err
}
if target.TelegramID == s.ownerID {
return false, target, domain.NewValidationError("You cannot revoke your own access.")
}
if target.TelegramID > 0 {
if err := s.removeSharedAccess(ctx, target.TelegramID, ""); err != nil {
return false, target, err
}
revoked, err := s.access.RevokeTelegramID(ctx, target.TelegramID)
return revoked, target, err
}
telegramID, _, err := s.access.TelegramIDForUsername(ctx, target.Username)
if err != nil {
return false, target, err
}
if err := s.removeSharedAccess(ctx, telegramID, target.Username); err != nil {
return false, target, err
}
revoked, err := s.access.RevokeUsername(ctx, target.Username)
return revoked, target, err
}
func (s *BotAccessService) removeSharedAccess(
ctx context.Context, telegramID int64, username string,
) error {
if s.cleaner == nil {
return nil
}
return s.cleaner.RemoveSharedAccess(ctx, telegramID, username)
}
func parseGrantTarget(raw, usage string) (GrantTarget, error) {
target := strings.TrimPrefix(strings.TrimSpace(raw), "@")
if target == "" {
return GrantTarget{}, domain.NewValidationError(usage)
}
if id, err := strconv.ParseInt(target, 10, 64); err == nil {
if id <= 0 {
return GrantTarget{}, domain.NewValidationError("User ID must be positive.")
}
return GrantTarget{TelegramID: id}, nil
}
username := NormalizeUsername(target)
if !usernamePattern.MatchString(username) {
return GrantTarget{}, domain.NewValidationError(
"Send a Telegram @username or a numeric user ID.",
)
}
return GrantTarget{Username: username}, nil
}
func NormalizeUsername(username string) string {
return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(username), "@"))
}