169 рядки
5.2 KiB
Go
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), "@"))
|
|
}
|