From c9a47c9a18f1e40a50bc5e413b792bfed87e1a98 Mon Sep 17 00:00:00 2001 From: mathalama Date: Mon, 20 Apr 2026 14:54:14 +0500 Subject: [PATCH 1/5] feat(deploy): configured deploy files --- .github/workflows/ci.yml | 89 ++----- backend/cmd/api/main.go | 28 +- backend/internal/config/config.go | 37 +-- .../internal/delivery/httpapi/auth_handler.go | 56 ++-- backend/internal/delivery/httpapi/focus_ws.go | 26 +- backend/internal/delivery/httpapi/handler.go | 42 +-- backend/internal/delivery/httpapi/logging.go | 52 ++-- backend/internal/delivery/httpapi/router.go | 11 +- .../delivery/httpapi/telegram_handler.go | 199 -------------- backend/internal/domain/errors.go | 3 +- backend/internal/domain/models.go | 9 +- .../internal/infrastructure/email/outbox.go | 18 +- .../internal/infrastructure/email/resend.go | 15 +- .../internal/infrastructure/postgres/email.go | 6 +- .../infrastructure/postgres/telegram.go | 201 -------------- backend/internal/usecase/auth.go | 12 +- backend/internal/usecase/telegram.go | 48 ---- .../src/background/service-worker.ts | 65 ++++- .../src/content/content-script.ts | 125 +++++---- browser-extension/src/popup/popup.html | 5 +- browser-extension/src/popup/popup.ts | 14 +- deploy/docker-compose.server.yml | 24 +- docker-compose.yml | 25 +- frontend/src/lib/i18n.ts | 6 +- frontend/src/pages/ProfilePage.tsx | 2 +- telegram-bot/.dockerignore | 4 - telegram-bot/.env.example | 6 - telegram-bot/Dockerfile | 23 -- telegram-bot/README.md | 89 ------- telegram-bot/cmd/bot/main.go | 33 --- telegram-bot/go.mod | 3 - telegram-bot/internal/backend/client.go | 123 --------- telegram-bot/internal/backend/types.go | 30 --- telegram-bot/internal/bot/bot.go | 57 ---- telegram-bot/internal/bot/handlers.go | 250 ------------------ telegram-bot/internal/bot/messages.go | 94 ------- telegram-bot/internal/bot/polling.go | 45 ---- telegram-bot/internal/bot/server.go | 131 --------- telegram-bot/internal/config/config.go | 121 --------- telegram-bot/internal/telegram/client.go | 126 --------- telegram-bot/internal/telegram/types.go | 64 ----- 41 files changed, 325 insertions(+), 1992 deletions(-) delete mode 100644 backend/internal/delivery/httpapi/telegram_handler.go delete mode 100644 backend/internal/infrastructure/postgres/telegram.go delete mode 100644 backend/internal/usecase/telegram.go delete mode 100644 telegram-bot/.dockerignore delete mode 100644 telegram-bot/.env.example delete mode 100644 telegram-bot/Dockerfile delete mode 100644 telegram-bot/README.md delete mode 100644 telegram-bot/cmd/bot/main.go delete mode 100644 telegram-bot/go.mod delete mode 100644 telegram-bot/internal/backend/client.go delete mode 100644 telegram-bot/internal/backend/types.go delete mode 100644 telegram-bot/internal/bot/bot.go delete mode 100644 telegram-bot/internal/bot/handlers.go delete mode 100644 telegram-bot/internal/bot/messages.go delete mode 100644 telegram-bot/internal/bot/polling.go delete mode 100644 telegram-bot/internal/bot/server.go delete mode 100644 telegram-bot/internal/config/config.go delete mode 100644 telegram-bot/internal/telegram/client.go delete mode 100644 telegram-bot/internal/telegram/types.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 179fcfb..887be91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,8 @@ jobs: - name: Create placeholder env files run: | touch .env - mkdir -p backend telegram-bot - touch backend/.env telegram-bot/.env + mkdir -p backend + touch backend/.env - name: Validate local Docker Compose run: docker compose config @@ -34,26 +34,13 @@ jobs: - name: Validate server Docker Compose env: BACKEND_IMAGE: ghcr.io/example/focus/backend:test - TELEGRAM_BOT_IMAGE: ghcr.io/example/focus/telegram-bot:test DATABASE_URL: postgres://user:pass@db:5432/focus?sslmode=disable JWT_SECRET: test-secret - TELEGRAM_BOT_AUTH_TOKEN: test-bot-auth - TELEGRAM_BOT_TOKEN: test-bot-token run: docker compose -f deploy/docker-compose.server.yml config build-and-push-images: needs: compose-validate runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - service: backend - context: ./backend - image_suffix: backend - - service: telegram-bot - context: ./telegram-bot - image_suffix: telegram-bot steps: - uses: actions/checkout@v4 @@ -67,14 +54,14 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GHCR_TOKEN }} - - name: Build and push ${{ matrix.service }} + - name: Build and push backend uses: docker/build-push-action@v6 with: - context: ${{ matrix.context }} + context: ./backend push: true tags: | - ghcr.io/${{ github.repository }}/${{ matrix.image_suffix }}:${{ github.sha }} - ghcr.io/${{ github.repository }}/${{ matrix.image_suffix }}:latest + ghcr.io/${{ github.repository }}/backend:${{ github.sha }} + ghcr.io/${{ github.repository }}/backend:latest cache-from: type=gha cache-to: type=gha,mode=max @@ -92,7 +79,7 @@ jobs: SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }} BACKEND_IMAGE: ghcr.io/${{ github.repository }}/backend:${{ github.sha }} - TELEGRAM_BOT_IMAGE: ghcr.io/${{ github.repository }}/telegram-bot:${{ github.sha }} + GITHUB_ACTOR: ${{ github.actor }} steps: - uses: actions/checkout@v4 @@ -108,16 +95,12 @@ jobs: server_host="${server_host%%/*}" if [[ "$server_host" == *"@"* ]]; then - if [ -z "$server_user" ]; then - server_user="${server_host%@*}" - fi + [ -z "$server_user" ] && server_user="${server_host%@*}" server_host="${server_host#*@}" fi if printf '%s' "$server_host" | grep -Eq '^[^:/]+:[0-9]+$'; then - if [ -z "$server_port" ]; then - server_port="${server_host##*:}" - fi + [ -z "$server_port" ] && server_port="${server_host##*:}" server_host="${server_host%:*}" fi @@ -125,20 +108,9 @@ jobs: : "${server_port:=22}" : "${deploy_path:=/opt/focus}" - if [ -z "$server_host" ]; then - echo "SERVER_IP secret is empty or invalid" >&2 - exit 1 - fi - - if [ -z "$SSH_PRIVATE_KEY" ]; then - echo "SSH_PRIVATE_KEY secret is empty" >&2 - exit 1 - fi - - if [ -z "$GHCR_TOKEN" ]; then - echo "GHCR_TOKEN secret is empty" >&2 - exit 1 - fi + [ -z "$server_host" ] && { echo "SERVER_IP secret is empty or invalid" >&2; exit 1; } + [ -z "$SSH_PRIVATE_KEY" ] && { echo "SSH_PRIVATE_KEY secret is empty" >&2; exit 1; } + [ -z "$GHCR_TOKEN" ] && { echo "GHCR_TOKEN secret is empty" >&2; exit 1; } { echo "SERVER_HOST=$server_host" @@ -163,54 +135,31 @@ jobs: ssh -p "$SERVER_PORT" "$SERVER_USER@$SERVER_HOST" "mkdir -p '$DEPLOY_PATH'" scp -P "$SERVER_PORT" deploy/docker-compose.server.yml "$SERVER_USER@$SERVER_HOST:$DEPLOY_PATH/docker-compose.yml" - - name: Render production env file + - name: Render and upload env file run: | cat > deploy.env < 0 { - entry.Error = c.Errors.String() + args = append(args, slog.String("error", c.Errors.String())) } - payload, err := json.Marshal(entry) - if err != nil { - log.Printf(`{"ts":"%s","level":"error","msg":"marshal_access_log_failed","error":"%v"}`, time.Now().UTC().Format(time.RFC3339Nano), err) - return + if status >= 500 { + slog.Error("http_request", args...) + } else { + slog.Info("http_request", args...) } - log.Print(string(payload)) } } diff --git a/backend/internal/delivery/httpapi/router.go b/backend/internal/delivery/httpapi/router.go index b642b4e..0d45e50 100644 --- a/backend/internal/delivery/httpapi/router.go +++ b/backend/internal/delivery/httpapi/router.go @@ -23,8 +23,6 @@ func NewRouter(handler *Handler, corsOrigin string, enableDevLogin bool, validat AllowCredentials: true, })) authLimiter := NewIPRateLimiter(rate.Every(12*time.Second), 10, 10*time.Minute).Middleware() - botLimiter := NewIPRateLimiter(rate.Every(2*time.Second), 20, 10*time.Minute).Middleware() - telegramIdempotency := NewIdempotencyStore(30 * time.Minute).Middleware() userIdempotency := NewIdempotencyStore(30 * time.Minute).Middleware() // Public routes @@ -44,10 +42,7 @@ func NewRouter(handler *Handler, corsOrigin string, enableDevLogin bool, validat router.POST("/api/v1/auth/forgot-password", authLimiter, handler.ForgotPassword) router.POST("/api/v1/auth/reset-password", authLimiter, handler.ResetPassword) - // Bot-to-bot routes (authenticated via X-Telegram-Bot-Auth header) - router.POST("/api/v1/integrations/telegram/link", botLimiter, telegramIdempotency, handler.TelegramLinkByCode) - router.GET("/api/v1/integrations/telegram/status", botLimiter, handler.TelegramStatusByUserID) - router.PATCH("/api/v1/integrations/telegram/notifications", botLimiter, handler.TelegramSetNotificationsByUserID) + // Protected routes (JWT) api := router.Group("/api/v1") @@ -56,9 +51,7 @@ func NewRouter(handler *Handler, corsOrigin string, enableDevLogin bool, validat api.GET("/me", handler.GetMe) api.POST("/auth/logout-all", handler.LogoutAll) api.GET("/auth/sessions", handler.ListAuthSessions) - api.POST("/auth/telegram/link-code", handler.CreateTelegramLinkCode) - api.GET("/integrations/telegram", handler.GetTelegramIdentity) - api.DELETE("/integrations/telegram", handler.UnlinkTelegram) + api.POST("/goals", handler.CreateGoal) api.GET("/goals", handler.ListGoals) diff --git a/backend/internal/delivery/httpapi/telegram_handler.go b/backend/internal/delivery/httpapi/telegram_handler.go deleted file mode 100644 index d53fd30..0000000 --- a/backend/internal/delivery/httpapi/telegram_handler.go +++ /dev/null @@ -1,199 +0,0 @@ -package httpapi - -import ( - "crypto/subtle" - "errors" - "net/http" - "strconv" - "strings" - - "mathalama-focus/backend/internal/domain" - "mathalama-focus/backend/internal/usecase" - - "github.com/gin-gonic/gin" -) - -const telegramBotAuthHeader = "X-Telegram-Bot-Auth" - -func (h *Handler) validateBotAuth(c *gin.Context) bool { - if h.telegramBotAuthToken == "" { - respondError(c, http.StatusServiceUnavailable, "telegram integration is not configured") - return false - } - botAuth := strings.TrimSpace(c.GetHeader(telegramBotAuthHeader)) - if subtle.ConstantTimeCompare([]byte(botAuth), []byte(h.telegramBotAuthToken)) != 1 { - respondError(c, http.StatusUnauthorized, "invalid bot auth token") - return false - } - return true -} - -func (h *Handler) CreateTelegramLinkCode(c *gin.Context) { - userID := getUserID(c) - - code, expiresAt, err := h.telegram.CreateLinkCode(c.Request.Context(), userID) - if err != nil { - respondError(c, http.StatusInternalServerError, "failed to create telegram link code") - return - } - - c.JSON(http.StatusCreated, gin.H{"code": code, "expires_at": expiresAt.UTC()}) - h.trackEvent(c.Request.Context(), userID, "telegram.link_code.created", nil) -} - -func (h *Handler) GetTelegramIdentity(c *gin.Context) { - userID := getUserID(c) - - identity, err := h.telegram.GetIdentity(c.Request.Context(), userID) - if errors.Is(err, domain.ErrNotFound) { - c.JSON(http.StatusOK, gin.H{"identity": nil}) - return - } - if err != nil { - respondError(c, http.StatusInternalServerError, "failed to get telegram identity") - return - } - - c.JSON(http.StatusOK, gin.H{"identity": identity}) -} - -func (h *Handler) UnlinkTelegram(c *gin.Context) { - userID := getUserID(c) - if err := h.telegram.Unlink(c.Request.Context(), userID); err != nil { - respondError(c, http.StatusInternalServerError, "failed to unlink telegram account") - return - } - c.JSON(http.StatusOK, gin.H{"unlinked": true}) - h.trackEvent(c.Request.Context(), userID, "telegram.unlinked", nil) -} - -func (h *Handler) TelegramStatusByUserID(c *gin.Context) { - if !h.validateBotAuth(c) { - return - } - - rawID := strings.TrimSpace(c.Query("telegram_user_id")) - telegramUserID, err := strconv.ParseInt(rawID, 10, 64) - if err != nil || telegramUserID <= 0 { - respondError(c, http.StatusBadRequest, "telegram_user_id must be a positive integer") - return - } - - user, notif, err := h.telegram.GetStatus(c.Request.Context(), telegramUserID) - if errors.Is(err, domain.ErrNotFound) { - c.JSON(http.StatusOK, gin.H{"linked": false, "notifications_enabled": false}) - return - } - if err != nil { - respondError(c, http.StatusInternalServerError, "failed to resolve telegram link status") - return - } - - c.JSON(http.StatusOK, gin.H{ - "linked": true, - "notifications_enabled": notif, - "user": gin.H{ - "id": user.ID, - "name": user.Name, - "email": user.Email, - }, - }) -} - -type telegramNotificationsRequest struct { - TelegramUserID int64 `json:"telegram_user_id"` - Enabled bool `json:"enabled"` -} - -func (h *Handler) TelegramSetNotificationsByUserID(c *gin.Context) { - if !h.validateBotAuth(c) { - return - } - - var req telegramNotificationsRequest - if err := c.ShouldBindJSON(&req); err != nil { - respondError(c, http.StatusBadRequest, "invalid request body") - return - } - if req.TelegramUserID <= 0 { - respondError(c, http.StatusBadRequest, "telegram_user_id must be a positive integer") - return - } - - err := h.telegram.SetNotifications(c.Request.Context(), req.TelegramUserID, req.Enabled) - if errors.Is(err, domain.ErrNotFound) { - respondError(c, http.StatusNotFound, "telegram account is not linked") - return - } - if err != nil { - respondError(c, http.StatusInternalServerError, "failed to update telegram notifications") - return - } - - c.JSON(http.StatusOK, gin.H{"updated": true, "notifications_enabled": req.Enabled}) - h.trackEvent(c.Request.Context(), "", "telegram.notifications.updated", map[string]any{ - "telegram_user_id": req.TelegramUserID, - "notifications_enabled": req.Enabled, - }) -} - -type telegramLinkByCodeRequest struct { - Code string `json:"code"` - TelegramUserID int64 `json:"telegram_user_id"` - TelegramUsername string `json:"telegram_username"` - TelegramFirst string `json:"telegram_first_name"` - TelegramLast string `json:"telegram_last_name"` -} - -func (h *Handler) TelegramLinkByCode(c *gin.Context) { - if !h.validateBotAuth(c) { - return - } - - var req telegramLinkByCodeRequest - if err := c.ShouldBindJSON(&req); err != nil { - respondError(c, http.StatusBadRequest, "invalid request body") - return - } - - req.Code = strings.ToUpper(strings.TrimSpace(req.Code)) - req.TelegramUsername = strings.TrimSpace(req.TelegramUsername) - req.TelegramFirst = strings.TrimSpace(req.TelegramFirst) - req.TelegramLast = strings.TrimSpace(req.TelegramLast) - - if req.Code == "" || req.TelegramUserID <= 0 { - respondError(c, http.StatusBadRequest, "code and telegram_user_id are required") - return - } - - user, err := h.telegram.LinkByCode(c.Request.Context(), usecase.TelegramLinkInput{ - Code: req.Code, - TelegramUserID: req.TelegramUserID, - TelegramUsername: req.TelegramUsername, - TelegramFirst: req.TelegramFirst, - TelegramLast: req.TelegramLast, - }) - if errors.Is(err, domain.ErrTelegramCodeInvalid) { - respondError(c, http.StatusBadRequest, "invalid or expired code") - return - } - if errors.Is(err, domain.ErrTelegramAlreadyLinked) { - respondError(c, http.StatusConflict, "telegram account is already linked to another user") - return - } - if err != nil { - respondError(c, http.StatusInternalServerError, "failed to link telegram account") - return - } - - c.JSON(http.StatusOK, gin.H{ - "linked_user": gin.H{ - "id": user.ID, - "name": user.Name, - "email": user.Email, - }, - }) - h.trackEvent(c.Request.Context(), user.ID, "telegram.linked", map[string]any{ - "telegram_user_id": req.TelegramUserID, - }) -} diff --git a/backend/internal/domain/errors.go b/backend/internal/domain/errors.go index 70eed7d..b443239 100644 --- a/backend/internal/domain/errors.go +++ b/backend/internal/domain/errors.go @@ -7,8 +7,7 @@ var ( ErrPauseLimitReached = errors.New("pause limit reached") ErrInvalidState = errors.New("session is in invalid state for this action") ErrGoalCompleted = errors.New("goal already completed") - ErrTelegramCodeInvalid = errors.New("telegram link code invalid or expired") - ErrTelegramAlreadyLinked = errors.New("telegram account already linked") + ErrAlreadyExists = errors.New("resource already exists") ErrEmailTokenInvalid = errors.New("email verification token invalid or expired") ErrInvalidEmail = errors.New("invalid email address") diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index 48cc6ac..58a3676 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -49,14 +49,7 @@ type ProductEvent struct { CreatedAt time.Time `json:"created_at"` } -type TelegramIdentity struct { - TelegramUserID int64 `json:"telegram_user_id"` - TelegramUsername string `json:"telegram_username"` - TelegramFirst string `json:"telegram_first_name"` - TelegramLast string `json:"telegram_last_name"` - NotificationsOn bool `json:"notifications_enabled"` - LinkedAt time.Time `json:"linked_at"` -} + type Goal struct { ID string `json:"id"` diff --git a/backend/internal/infrastructure/email/outbox.go b/backend/internal/infrastructure/email/outbox.go index 2257f59..2888ab0 100644 --- a/backend/internal/infrastructure/email/outbox.go +++ b/backend/internal/infrastructure/email/outbox.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "log" + "log/slog" "strings" "sync" "time" @@ -29,8 +29,8 @@ type queuedEmail struct { type OutboxService struct { pool *pgxpool.Pool sender interface { - SendVerificationEmail(context.Context, string, string, string) error - SendPasswordResetEmail(context.Context, string, string, string) error + SendVerificationEmail(context.Context, string, string, string, string) error + SendPasswordResetEmail(context.Context, string, string, string, string) error } pollEvery time.Duration maxAttempts int @@ -40,8 +40,8 @@ type OutboxService struct { func NewOutboxService( pool *pgxpool.Pool, sender interface { - SendVerificationEmail(context.Context, string, string, string) error - SendPasswordResetEmail(context.Context, string, string, string) error + SendVerificationEmail(context.Context, string, string, string, string) error + SendPasswordResetEmail(context.Context, string, string, string, string) error }, pollEvery time.Duration, maxAttempts int, @@ -102,7 +102,7 @@ func (s *OutboxService) loop(ctx context.Context) { for i := 0; i < 50; i++ { processed, err := s.processOne(ctx) if err != nil { - log.Printf("email outbox process error: %v", err) + slog.Error("email outbox process failed", "error", err) break } if !processed { @@ -124,10 +124,10 @@ func (s *OutboxService) processOne(ctx context.Context) (bool, error) { sendCtx, cancel := context.WithTimeout(ctx, 15*time.Second) var sendErr error - if job.EmailType == "password_reset" { - sendErr = s.sender.SendPasswordResetEmail(sendCtx, job.ToEmail, job.ToName, job.ResetLink) + if job.EmailType == "verification" { + sendErr = s.sender.SendVerificationEmail(sendCtx, job.ToEmail, job.ToName, job.VerifyLink, job.ID) } else { - sendErr = s.sender.SendVerificationEmail(sendCtx, job.ToEmail, job.ToName, job.VerifyLink) + sendErr = s.sender.SendPasswordResetEmail(sendCtx, job.ToEmail, job.ToName, job.ResetLink, job.ID) } cancel() diff --git a/backend/internal/infrastructure/email/resend.go b/backend/internal/infrastructure/email/resend.go index 7c78b29..5331002 100644 --- a/backend/internal/infrastructure/email/resend.go +++ b/backend/internal/infrastructure/email/resend.go @@ -22,12 +22,12 @@ func NewResendService(apiKey, fromEmail string, ttlMin int) *ResendService { return &ResendService{ apiKey: strings.TrimSpace(apiKey), fromEmail: strings.TrimSpace(fromEmail), - client: &http.Client{Timeout: 10 * time.Second}, + client: &http.Client{Timeout: 30 * time.Second}, ttlMin: ttlMin, } } -func (s *ResendService) SendVerificationEmail(ctx context.Context, toEmail, toName, verifyLink string) error { +func (s *ResendService) SendVerificationEmail(ctx context.Context, toEmail, toName, verifyLink, idempotencyKey string) error { htmlBody := s.buildEmailHTML( fmt.Sprintf("Hello %s,", htmlEscape(strings.TrimSpace(toName))), "Welcome to Mathalama Focus! Please confirm your email address to activate your account and start your focus journey.", @@ -43,10 +43,10 @@ func (s *ResendService) SendVerificationEmail(ctx context.Context, toEmail, toNa "html": htmlBody, } - return s.send(ctx, payload) + return s.send(ctx, payload, idempotencyKey) } -func (s *ResendService) SendPasswordResetEmail(ctx context.Context, toEmail, toName, resetLink string) error { +func (s *ResendService) SendPasswordResetEmail(ctx context.Context, toEmail, toName, resetLink, idempotencyKey string) error { htmlBody := s.buildEmailHTML( fmt.Sprintf("Hello %s,", htmlEscape(strings.TrimSpace(toName))), "We received a request to reset your password. Click the button below to choose a new one.", @@ -62,10 +62,10 @@ func (s *ResendService) SendPasswordResetEmail(ctx context.Context, toEmail, toN "html": htmlBody, } - return s.send(ctx, payload) + return s.send(ctx, payload, idempotencyKey) } -func (s *ResendService) send(ctx context.Context, payload map[string]any) error { +func (s *ResendService) send(ctx context.Context, payload map[string]any, idempotencyKey string) error { body, err := json.Marshal(payload) if err != nil { return err @@ -77,6 +77,9 @@ func (s *ResendService) send(ctx context.Context, payload map[string]any) error } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+s.apiKey) + if idempotencyKey != "" { + req.Header.Set("Idempotency-Key", idempotencyKey) + } resp, err := s.client.Do(req) if err != nil { diff --git a/backend/internal/infrastructure/postgres/email.go b/backend/internal/infrastructure/postgres/email.go index 3742e18..a24c110 100644 --- a/backend/internal/infrastructure/postgres/email.go +++ b/backend/internal/infrastructure/postgres/email.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "log" + "log/slog" "strings" "time" @@ -71,7 +71,7 @@ func (r *Repository) VerifyEmailByToken(ctx context.Context, rawToken string) (d tokenHash, ).Scan(&userID); err != nil { if errors.Is(err, pgx.ErrNoRows) { - log.Printf("[Verify] Token not found or already used: %s", tokenHash) + slog.Warn("verify token not found or already used", "token_hash", tokenHash) return domain.User{}, domain.ErrEmailTokenInvalid } return domain.User{}, fmt.Errorf("find email verification token: %w", err) @@ -101,7 +101,7 @@ func (r *Repository) VerifyEmailByToken(ctx context.Context, rawToken string) (d return domain.User{}, fmt.Errorf("commit verify email tx: %w", err) } - log.Printf("[Verify] Successfully verified email for user %s (%s)", user.ID, user.Email) + slog.Info("successfully verified email", "user_id", user.ID, "email", user.Email) return user, nil } diff --git a/backend/internal/infrastructure/postgres/telegram.go b/backend/internal/infrastructure/postgres/telegram.go deleted file mode 100644 index 312bb91..0000000 --- a/backend/internal/infrastructure/postgres/telegram.go +++ /dev/null @@ -1,201 +0,0 @@ -package postgres - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "mathalama-focus/backend/internal/domain" - "mathalama-focus/backend/internal/usecase" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" -) - -func (r *Repository) CreateTelegramLinkCode(ctx context.Context, userID string, ttl time.Duration) (string, time.Time, error) { - if ttl <= 0 { - ttl = 10 * time.Minute - } - - expiresAt := time.Now().UTC().Add(ttl) - - if _, err := r.pool.Exec(ctx, - `DELETE FROM telegram_link_codes WHERE user_id = $1 OR expires_at <= NOW() OR used_at IS NOT NULL`, - userID, - ); err != nil { - return "", time.Time{}, fmt.Errorf("cleanup telegram link codes: %w", err) - } - - const insertQuery = `INSERT INTO telegram_link_codes (code, user_id, expires_at) VALUES ($1, $2, $3)` - - for attempt := 0; attempt < 5; attempt++ { - code, err := generateTelegramLinkCode(8) - if err != nil { - return "", time.Time{}, fmt.Errorf("generate telegram link code: %w", err) - } - - if _, err := r.pool.Exec(ctx, insertQuery, code, userID, expiresAt); err != nil { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == "23505" { - continue - } - return "", time.Time{}, fmt.Errorf("insert telegram link code: %w", err) - } - - return code, expiresAt, nil - } - - return "", time.Time{}, errors.New("failed to allocate unique telegram link code") -} - -func (r *Repository) LinkTelegramByCode(ctx context.Context, input usecase.TelegramLinkInput) (domain.User, error) { - input.Code = strings.ToUpper(strings.TrimSpace(input.Code)) - input.TelegramUsername = strings.TrimSpace(input.TelegramUsername) - input.TelegramFirst = strings.TrimSpace(input.TelegramFirst) - input.TelegramLast = strings.TrimSpace(input.TelegramLast) - - if input.Code == "" || input.TelegramUserID <= 0 { - return domain.User{}, domain.ErrTelegramCodeInvalid - } - - tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) - if err != nil { - return domain.User{}, fmt.Errorf("begin telegram link tx: %w", err) - } - defer tx.Rollback(ctx) - - var userID string - if err := tx.QueryRow(ctx, - `SELECT user_id FROM telegram_link_codes WHERE code = $1 AND used_at IS NULL AND expires_at > NOW() FOR UPDATE`, - input.Code, - ).Scan(&userID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return domain.User{}, domain.ErrTelegramCodeInvalid - } - return domain.User{}, fmt.Errorf("find telegram link code: %w", err) - } - - const upsertIdentityQuery = ` - INSERT INTO telegram_identities (user_id, telegram_user_id, telegram_username, telegram_first_name, telegram_last_name, notifications_enabled) - VALUES ($1, $2, $3, $4, $5, FALSE) - ON CONFLICT (user_id) DO UPDATE SET - telegram_user_id = EXCLUDED.telegram_user_id, - telegram_username = EXCLUDED.telegram_username, - telegram_first_name = EXCLUDED.telegram_first_name, - telegram_last_name = EXCLUDED.telegram_last_name, - notifications_enabled = FALSE, - linked_at = NOW() - ` - - if _, err := tx.Exec(ctx, upsertIdentityQuery, - userID, input.TelegramUserID, input.TelegramUsername, input.TelegramFirst, input.TelegramLast, - ); err != nil { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == "23505" { - return domain.User{}, domain.ErrTelegramAlreadyLinked - } - return domain.User{}, fmt.Errorf("upsert telegram identity: %w", err) - } - - if _, err := tx.Exec(ctx, `UPDATE telegram_link_codes SET used_at = NOW() WHERE code = $1`, input.Code); err != nil { - return domain.User{}, fmt.Errorf("mark telegram link code as used: %w", err) - } - - var user domain.User - if err := tx.QueryRow(ctx, - `SELECT id, email, name, nectar_balance, total_nectar_earned, created_at FROM users WHERE id = $1`, - userID, - ).Scan(&user.ID, &user.Email, &user.Name, &user.NectarBalance, &user.TotalNectarEarned, &user.CreatedAt); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return domain.User{}, domain.ErrNotFound - } - return domain.User{}, fmt.Errorf("read linked user: %w", err) - } - - if err := tx.Commit(ctx); err != nil { - return domain.User{}, fmt.Errorf("commit telegram link tx: %w", err) - } - - return user, nil -} - -func (r *Repository) GetTelegramIdentity(ctx context.Context, userID string) (domain.TelegramIdentity, error) { - const query = ` - SELECT telegram_user_id, telegram_username, telegram_first_name, telegram_last_name, notifications_enabled, linked_at - FROM telegram_identities WHERE user_id = $1 - ` - - var identity domain.TelegramIdentity - if err := r.pool.QueryRow(ctx, query, userID).Scan( - &identity.TelegramUserID, &identity.TelegramUsername, - &identity.TelegramFirst, &identity.TelegramLast, - &identity.NotificationsOn, &identity.LinkedAt, - ); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return domain.TelegramIdentity{}, domain.ErrNotFound - } - return domain.TelegramIdentity{}, fmt.Errorf("get telegram identity: %w", err) - } - - return identity, nil -} - -func (r *Repository) GetUserByTelegramUserID(ctx context.Context, telegramUserID int64) (domain.User, bool, error) { - const query = ` - SELECT u.id, u.email, u.name, u.nectar_balance, u.total_nectar_earned, u.created_at, ti.notifications_enabled - FROM telegram_identities ti - JOIN users u ON u.id = ti.user_id - WHERE ti.telegram_user_id = $1 - ` - - var user domain.User - var notificationsEnabled bool - if err := r.pool.QueryRow(ctx, query, telegramUserID).Scan( - &user.ID, &user.Email, &user.Name, - &user.NectarBalance, &user.TotalNectarEarned, &user.CreatedAt, - ¬ificationsEnabled, - ); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return domain.User{}, false, domain.ErrNotFound - } - return domain.User{}, false, fmt.Errorf("get user by telegram user id: %w", err) - } - - return user, notificationsEnabled, nil -} - -func (r *Repository) SetTelegramNotifications(ctx context.Context, telegramUserID int64, enabled bool) error { - result, err := r.pool.Exec(ctx, - `UPDATE telegram_identities SET notifications_enabled = $2 WHERE telegram_user_id = $1`, - telegramUserID, enabled, - ) - if err != nil { - return fmt.Errorf("set telegram notifications: %w", err) - } - if result.RowsAffected() == 0 { - return domain.ErrNotFound - } - return nil -} - -func (r *Repository) UnlinkTelegram(ctx context.Context, userID string) error { - tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) - if err != nil { - return fmt.Errorf("begin unlink telegram tx: %w", err) - } - defer tx.Rollback(ctx) - - if _, err := tx.Exec(ctx, `DELETE FROM telegram_identities WHERE user_id = $1`, userID); err != nil { - return fmt.Errorf("delete telegram identity: %w", err) - } - if _, err := tx.Exec(ctx, `DELETE FROM telegram_link_codes WHERE user_id = $1`, userID); err != nil { - return fmt.Errorf("delete telegram link codes: %w", err) - } - - if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("commit unlink telegram tx: %w", err) - } - return nil -} diff --git a/backend/internal/usecase/auth.go b/backend/internal/usecase/auth.go index 91a1f12..922a5e7 100644 --- a/backend/internal/usecase/auth.go +++ b/backend/internal/usecase/auth.go @@ -7,7 +7,7 @@ import ( "encoding/hex" "errors" "fmt" - "log" + "log/slog" "net/mail" "net/url" "strings" @@ -174,7 +174,7 @@ func (uc *AuthUseCase) Register(ctx context.Context, rawEmail, name, password st emailSent = true expiresAt = tokenExpiresAt.UTC() } else { - log.Printf("register verification email send failed (user=%s): %v", user.ID, err) + slog.Error("register verification email send failed", "user_id", user.ID, "error", err) } } } @@ -314,7 +314,7 @@ func (uc *AuthUseCase) ResendVerification(ctx context.Context, rawEmail string) } if err := uc.emailSvc.SendVerificationEmail(ctx, authUser.User.Email, authUser.User.Name, verifyLink); err != nil { - log.Printf("resend verification email send failed (user=%s): %v", authUser.User.ID, err) + slog.Error("resend verification email send failed", "user_id", authUser.User.ID, "error", err) return ResendOutput{}, fmt.Errorf("send verification email: %w", err) } @@ -355,20 +355,20 @@ func (uc *AuthUseCase) ForgotPassword(ctx context.Context, rawEmail string) (For // Create password reset token rawToken, expiresAt, err := uc.userRepo.CreatePasswordResetToken(ctx, user.ID, uc.verifyTTL) if err != nil { - log.Printf("forgot password: create token failed (user=%s): %v", user.ID, err) + slog.Error("forgot password: create token failed", "user_id", user.ID, "error", err) return ForgotPasswordOutput{}, fmt.Errorf("create recovery token: %w", err) } // Build password reset link resetLink, err := uc.buildResetLink(rawToken) if err != nil { - log.Printf("forgot password: build link failed (user=%s): %v", user.ID, err) + slog.Error("forgot password: build link failed", "user_id", user.ID, "error", err) return ForgotPasswordOutput{}, fmt.Errorf("build recovery link: %w", err) } // Send reset email if err := uc.emailSvc.SendPasswordResetEmail(ctx, user.Email, user.Name, resetLink); err != nil { - log.Printf("forgot password: send email failed (user=%s): %v", user.ID, err) + slog.Error("forgot password: send email failed", "user_id", user.ID, "error", err) return ForgotPasswordOutput{}, fmt.Errorf("send recovery email: %w", err) } diff --git a/backend/internal/usecase/telegram.go b/backend/internal/usecase/telegram.go deleted file mode 100644 index effb2fe..0000000 --- a/backend/internal/usecase/telegram.go +++ /dev/null @@ -1,48 +0,0 @@ -package usecase - -import ( - "context" - "time" - - "mathalama-focus/backend/internal/domain" -) - -// TelegramUseCase orchestrates telegram-integration business logic. -type TelegramUseCase struct { - telegramRepo TelegramRepository - linkCodeTTL time.Duration -} - -func NewTelegramUseCase(telegramRepo TelegramRepository, linkCodeTTL time.Duration) *TelegramUseCase { - if linkCodeTTL <= 0 { - linkCodeTTL = 10 * time.Minute - } - return &TelegramUseCase{ - telegramRepo: telegramRepo, - linkCodeTTL: linkCodeTTL, - } -} - -func (uc *TelegramUseCase) CreateLinkCode(ctx context.Context, userID string) (string, time.Time, error) { - return uc.telegramRepo.CreateTelegramLinkCode(ctx, userID, uc.linkCodeTTL) -} - -func (uc *TelegramUseCase) GetIdentity(ctx context.Context, userID string) (domain.TelegramIdentity, error) { - return uc.telegramRepo.GetTelegramIdentity(ctx, userID) -} - -func (uc *TelegramUseCase) Unlink(ctx context.Context, userID string) error { - return uc.telegramRepo.UnlinkTelegram(ctx, userID) -} - -func (uc *TelegramUseCase) LinkByCode(ctx context.Context, input TelegramLinkInput) (domain.User, error) { - return uc.telegramRepo.LinkTelegramByCode(ctx, input) -} - -func (uc *TelegramUseCase) GetStatus(ctx context.Context, telegramUserID int64) (domain.User, bool, error) { - return uc.telegramRepo.GetUserByTelegramUserID(ctx, telegramUserID) -} - -func (uc *TelegramUseCase) SetNotifications(ctx context.Context, telegramUserID int64, enabled bool) error { - return uc.telegramRepo.SetTelegramNotifications(ctx, telegramUserID, enabled) -} diff --git a/browser-extension/src/background/service-worker.ts b/browser-extension/src/background/service-worker.ts index a6a2e29..d20607d 100644 --- a/browser-extension/src/background/service-worker.ts +++ b/browser-extension/src/background/service-worker.ts @@ -42,17 +42,76 @@ async function initStorage() { } } -// Listen for messages from content script +const API_BASE_URL = 'http://localhost:8080'; + +// Backend communication service +const BackendService = { + async getToken(): Promise { + const data = await chrome.storage.local.get('authToken'); + return data.authToken || null; + }, + + async setToken(token: string): Promise { + await chrome.storage.local.set({ authToken: token }); + }, + + async sendFocusEvent(action: 'start' | 'stop', durationMinutes?: number): Promise { + const token = await this.getToken(); + if (!token) { + console.warn('Cannot send focus event: no auth token'); + return false; + } + + try { + const response = await fetch(`${API_BASE_URL}/api/v1/focus/event`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + action, + duration: durationMinutes || 0 + }) + }); + + if (!response.ok) { + console.error('Failed to send focus event:', response.statusText); + return false; + } + + return true; + } catch (error) { + console.error('Error sending focus event:', error); + return false; + } + } +}; + +// Listen for messages from content script or popup chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === 'getFocusStatus') { getFocusStatus().then(sendResponse); } else if (request.action === 'focusStateChanged') { // Focus state changed from website via content script console.log('Focus state changed:', request.state); + if (request.state === 'active') { + BackendService.sendFocusEvent('start', request.duration); + } else if (request.state === 'completed' || request.state === 'abandoned') { + BackendService.sendFocusEvent('stop'); + } sendResponse({ ok: true }); } else if (request.action === 'stopFocus') { - // User clicked End Focus button - stopFocusSession().then(sendResponse); + // User clicked End Focus button in extension + stopFocusSession().then((ok) => { + BackendService.sendFocusEvent('stop'); + sendResponse(ok); + }); + } else if (request.action === 'setToken') { + console.log('Token received from content script'); + BackendService.setToken(request.token).then(() => { + sendResponse({ ok: true }); + }); } return true; // Will respond asynchronously }); diff --git a/browser-extension/src/content/content-script.ts b/browser-extension/src/content/content-script.ts index bce52a0..3f66350 100644 --- a/browser-extension/src/content/content-script.ts +++ b/browser-extension/src/content/content-script.ts @@ -20,61 +20,86 @@ function isExtensionContextValid(): boolean { // Listen for focus state changes from the website via localStorage window.addEventListener('storage', (event) => { if (event.key === 'focus-session-state' && event.newValue) { - try { - if (!isExtensionContextValid()) { - console.warn('Extension context invalidated, reloading...'); - window.location.reload(); - return; - } + handleFocusStateChange(event.newValue); + } + + if (event.key === 'token' && event.newValue) { + syncToken(event.newValue); + } +}); - const data = JSON.parse(event.newValue); - const { state } = data; - - console.log('Content script received focus state change:', state); - - // Get blocked sites from extension storage +function handleFocusStateChange(newValue: string) { + try { + if (!isExtensionContextValid()) { + console.warn('Extension context invalidated, reloading...'); + window.location.reload(); + return; + } + + const data = JSON.parse(newValue); + const { state } = data; + + console.log('Content script received focus state change:', state); + + // Get blocked sites from extension storage + chrome.storage.local.get('defaultBlockedSites', (data) => { try { - chrome.storage.local.get('defaultBlockedSites', (data) => { - try { - const blockedSites = (data.defaultBlockedSites || []).filter( - (site: any) => site.enabled - ); - - // Update focus session state - if (state === 'active') { - // Get duration from sessionStorage if available (defaults to 25 min) - const duration = JSON.parse(sessionStorage.getItem('focus-session-info') || '{}').duration || 25; - - chrome.storage.local.set({ - focusSession: { - active: true, - startTime: Date.now(), - duration: duration * 60 * 1000, - blockedSites: blockedSites - } - }); - } else if (state === 'paused' || state === 'completed') { - chrome.storage.local.set({ - focusSession: { - active: false, - startTime: 0, - duration: 0, - blockedSites: [] - } - }); - } - } catch (storageError) { - console.error('Storage operation error:', storageError); - } + const blockedSites = (data.defaultBlockedSites || []).filter( + (site: any) => site.enabled + ); + + // Notify background script about state change + const duration = JSON.parse(sessionStorage.getItem('focus-session-info') || '{}').duration || 25; + chrome.runtime.sendMessage({ + action: 'focusStateChanged', + state: state, + duration: duration }); - } catch (chromeError) { - console.error('Chrome API error:', chromeError); + + // Still update local storage for immediate blocker feedback + if (state === 'active') { + chrome.storage.local.set({ + focusSession: { + active: true, + startTime: Date.now(), + duration: duration * 60 * 1000, + blockedSites: blockedSites + } + }); + } else { + chrome.storage.local.set({ + focusSession: { + active: false, + startTime: 0, + duration: 0, + blockedSites: [] + } + }); + } + } catch (storageError) { + console.error('Storage operation error:', storageError); } - } catch (e) { - console.error('Error parsing focus state:', e); - } + }); + } catch (e) { + console.error('Error parsing focus state:', e); } -}); +} + +function syncToken(token: string) { + if (!token || !isExtensionContextValid()) return; + console.log('Syncing token to background script'); + chrome.runtime.sendMessage({ action: 'setToken', token: token }); +} + +// Initial sync +const initialToken = localStorage.getItem('token'); +if (initialToken) { + syncToken(initialToken); +} +const initialFocusState = localStorage.getItem('focus-session-state'); +if (initialFocusState) { + handleFocusStateChange(initialFocusState); +} // Listen for storage changes from service worker (for stopFocus, etc) chrome.storage.onChanged.addListener((changes, area) => { diff --git a/browser-extension/src/popup/popup.html b/browser-extension/src/popup/popup.html index bf283af..b09882b 100644 --- a/browser-extension/src/popup/popup.html +++ b/browser-extension/src/popup/popup.html @@ -155,7 +155,10 @@

Focus Mode

-
○ Not Active
+
+
○ Not Active
+
Syncing...
+

diff --git a/browser-extension/src/popup/popup.ts b/browser-extension/src/popup/popup.ts index 6557fbc..65d4096 100644 --- a/browser-extension/src/popup/popup.ts +++ b/browser-extension/src/popup/popup.ts @@ -49,11 +49,23 @@ function setupEventListeners(): void { } async function updateUI(): Promise { - const data = await chrome.storage.local.get('focusSession'); + const data = await chrome.storage.local.get(['focusSession', 'authToken']); const session = data.focusSession as FocusSession; + const hasToken = !!data.authToken; const statusBadge = document.getElementById('status-badge'); const timer = document.getElementById('timer'); + const syncStatus = document.getElementById('sync-status'); + + if (syncStatus) { + if (hasToken) { + syncStatus.textContent = '● Synced'; + syncStatus.style.color = '#4CAF50'; + } else { + syncStatus.textContent = '○ Needs Login'; + syncStatus.style.color = '#f44336'; + } + } if (session?.active) { if (statusBadge) statusBadge.textContent = '● Focus Mode Active'; diff --git a/deploy/docker-compose.server.yml b/deploy/docker-compose.server.yml index 68ba698..fd1f4b9 100644 --- a/deploy/docker-compose.server.yml +++ b/deploy/docker-compose.server.yml @@ -45,29 +45,7 @@ services: - default - proxy - telegram-bot: - image: ${TELEGRAM_BOT_IMAGE} - container_name: mathalama-focus-telegram-bot - environment: - TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} - TELEGRAM_BOT_AUTH_TOKEN: ${TELEGRAM_BOT_AUTH_TOKEN} - BACKEND_URL: ${BACKEND_URL:-http://backend:8080} - APP_URL: ${APP_URL:-http://localhost:5173} - TELEGRAM_POLL_TIMEOUT_SECONDS: ${TELEGRAM_POLL_TIMEOUT_SECONDS:-30} - BOT_INTERNAL_API_ADDR: ${BOT_INTERNAL_API_ADDR:-:8091} - restart: unless-stopped - ports: - - ${BOT_PORT:-8091}:8091 - depends_on: - backend: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:8091/health >/dev/null || exit 1"] - interval: 10s - timeout: 5s - retries: 5 - networks: - - default + networks: proxy: diff --git a/docker-compose.yml b/docker-compose.yml index 0323dc5..427ec27 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,7 +43,9 @@ services: environment: PORT: "8080" DATABASE_URL: postgres://mathalama:mathalama@postgres:5432/mathalama?sslmode=disable - ENABLE_DEV_LOGIN: "false" + + env_file: + - backend/.env ports: - "8080:8080" restart: unless-stopped @@ -53,27 +55,6 @@ services: timeout: 5s retries: 5 - telegram-bot: - build: - context: ./telegram-bot - dockerfile: Dockerfile - container_name: mathalama-focus-telegram-bot - depends_on: - backend: - condition: service_healthy - environment: - BACKEND_URL: http://backend:8080 - APP_URL: http://localhost:5173 - BOT_INTERNAL_API_ADDR: ":8091" - ports: - - "8091:8091" - restart: unless-stopped - healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:8091/health >/dev/null || exit 1"] - interval: 10s - timeout: 5s - retries: 5 - # Frontend intentionally runs outside Docker: # cd frontend && npm install && npm run dev diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 0dde5a6..071c6e9 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -247,7 +247,7 @@ const translations: Record = { 'profile.notificationsHint': 'Get browser notifications about session completion and break end, even if the tab is inactive.', 'profile.enabled': 'Enabled', 'profile.disabled': 'Disabled', - 'profile.telegram.title': 'Mathalama Lab', + 'profile.telegram.title': 'Mathalama Hub', 'profile.telegram.subscribe': 'Subscribe to our Telegram channel for updates', 'password.resetTitle': 'Reset Password', @@ -534,7 +534,7 @@ const translations: Record = { 'profile.notificationsHint': 'Получайте уведомления о завершении сессии и перерывов, даже если вкладка неактивна.', 'profile.enabled': 'Включены', 'profile.disabled': 'Отключены', - 'profile.telegram.title': 'Mathalama Lab', + 'profile.telegram.title': 'Mathalama Hub', 'profile.telegram.subscribe': 'Подпишитесь на наш Telegram-канал для обновлений', 'password.resetTitle': 'Сбросить пароль', @@ -821,7 +821,7 @@ const translations: Record = { 'profile.notificationsHint': 'Сессия аяқталғанда және ара қарсудың аяқталғанда ескертпелер алыңыз, тіпті вкладка белсенді болмаса да.', 'profile.enabled': 'Қосулы', 'profile.disabled': 'Өшімі', - 'profile.telegram.title': 'Mathalama Lab', + 'profile.telegram.title': 'Mathalama Hub', 'profile.telegram.subscribe': 'Жаңартулар үшін біздің Telegram каналымызға жазылыңыз', 'password.resetTitle': 'Құпиясөзді ысыру', diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 20f6d37..9c5e8a0 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -139,7 +139,7 @@ export const ProfilePage: React.FC = () => { " -``` - -2. User sends to bot: - -```text -/link ABCD2345 -``` - -3. Bot calls: - -- `POST /api/v1/integrations/telegram/link` with header `X-Telegram-Bot-Auth`. -- `GET /api/v1/integrations/telegram/status?telegram_user_id=` with header `X-Telegram-Bot-Auth` to check if Telegram is already linked. -- `PATCH /api/v1/integrations/telegram/notifications` with header `X-Telegram-Bot-Auth` to enable/disable notifications. - -## Commands - -- `/start` or `/status` - check link status and show onboarding steps -- `/link ` - link Telegram using one-time code from app profile -- `/notify on` / `/notify off` - enable or disable notifications -- `/subscribe` / `/unsubscribe` - quick enable/disable notifications -- `/help` - list commands - -By default, notifications are **disabled** right after linking. - -The bot also shows inline buttons in each response: -- `Включить` -- `Выключить` -- `Статус` -- `Помощь` - -## Internal Notify API - -Bot exposes an internal API for programmatic notifications. - -- `GET /health` -- `POST /internal/notify` - - Header: `X-Telegram-Bot-Auth: ` - - Body: - - `telegram_user_id` (required) - - `message` (required) - - `disable_buttons` (optional, default `false`) - - `force` (optional, default `false`) - -When `force=false`, bot checks backend link status and `notifications_enabled` first. - -Example: - -```bash -curl -X POST http://localhost:8091/internal/notify \ - -H "Content-Type: application/json" \ - -H "X-Telegram-Bot-Auth: " \ - -d '{ - "telegram_user_id": 123456789, - "message": "Через 5 минут начнется фокус-сессия", - "disable_buttons": true - }' -``` diff --git a/telegram-bot/cmd/bot/main.go b/telegram-bot/cmd/bot/main.go deleted file mode 100644 index ee41184..0000000 --- a/telegram-bot/cmd/bot/main.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import ( - "context" - "errors" - "log" - "os/signal" - "syscall" - - "mathalama-focus/telegram-bot/internal/backend" - "mathalama-focus/telegram-bot/internal/bot" - "mathalama-focus/telegram-bot/internal/config" - "mathalama-focus/telegram-bot/internal/telegram" -) - -func main() { - cfg, err := config.Load() - if err != nil { - log.Fatalf("config error: %v", err) - } - - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - - tg := telegram.NewClient(cfg.Token, cfg.PollTimeout) - be := backend.NewClient(cfg) - b := bot.New(cfg, tg, be) - - log.Printf("telegram bot started (backend=%s)", cfg.BackendURL) - if err := b.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { - log.Fatalf("bot stopped with error: %v", err) - } -} diff --git a/telegram-bot/go.mod b/telegram-bot/go.mod deleted file mode 100644 index fb2ba0a..0000000 --- a/telegram-bot/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module mathalama-focus/telegram-bot - -go 1.24 diff --git a/telegram-bot/internal/backend/client.go b/telegram-bot/internal/backend/client.go deleted file mode 100644 index 97ea62e..0000000 --- a/telegram-bot/internal/backend/client.go +++ /dev/null @@ -1,123 +0,0 @@ -package backend - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strconv" - "strings" - "time" - - "mathalama-focus/telegram-bot/internal/config" -) - -type Client struct { - httpClient *http.Client - baseURL string - botAuth string -} - -func NewClient(cfg config.Config) *Client { - return &Client{ - httpClient: &http.Client{Timeout: 15 * time.Second}, - baseURL: cfg.BackendURL, - botAuth: cfg.BotAuth, - } -} - -func (c *Client) GetTelegramStatus(ctx context.Context, telegramUserID int64) (TelegramStatusResponse, int, string) { - if telegramUserID <= 0 { - return TelegramStatusResponse{}, http.StatusBadRequest, "telegram_user_id is invalid" - } - - statusURL := fmt.Sprintf("%s/api/v1/integrations/telegram/status?telegram_user_id=%d", c.baseURL, telegramUserID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil) - if err != nil { - return TelegramStatusResponse{}, 0, err.Error() - } - req.Header.Set(config.BotAuthHeader, c.botAuth) - - resp, err := c.httpClient.Do(req) - if err != nil { - return TelegramStatusResponse{}, 0, err.Error() - } - defer resp.Body.Close() - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - var payload TelegramStatusResponse - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return TelegramStatusResponse{}, resp.StatusCode, err.Error() - } - return payload, resp.StatusCode, "" - } - - return TelegramStatusResponse{}, resp.StatusCode, readErrorBody(resp.Body) -} - -func (c *Client) LinkTelegram(ctx context.Context, payload TelegramLinkRequest) (int, string) { - body, err := json.Marshal(payload) - if err != nil { - return 0, err.Error() - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/integrations/telegram/link", bytes.NewReader(body)) - if err != nil { - return 0, err.Error() - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set(config.BotAuthHeader, c.botAuth) - req.Header.Set("Idempotency-Key", "tg-link-"+strconv.FormatInt(payload.TelegramUserID, 10)+"-"+strconv.FormatInt(time.Now().UnixNano(), 10)) - - resp, err := c.httpClient.Do(req) - if err != nil { - return 0, err.Error() - } - defer resp.Body.Close() - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return resp.StatusCode, "" - } - - return resp.StatusCode, readErrorBody(resp.Body) -} - -func (c *Client) SetTelegramNotifications(ctx context.Context, telegramUserID int64, enabled bool) (int, string) { - body, err := json.Marshal(TelegramNotificationsRequest{ - TelegramUserID: telegramUserID, - Enabled: enabled, - }) - if err != nil { - return 0, err.Error() - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.baseURL+"/api/v1/integrations/telegram/notifications", bytes.NewReader(body)) - if err != nil { - return 0, err.Error() - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set(config.BotAuthHeader, c.botAuth) - - resp, err := c.httpClient.Do(req) - if err != nil { - return 0, err.Error() - } - defer resp.Body.Close() - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return resp.StatusCode, "" - } - - return resp.StatusCode, readErrorBody(resp.Body) -} - -func readErrorBody(body io.Reader) string { - raw, _ := io.ReadAll(io.LimitReader(body, 4096)) - var parsed ErrorResponse - if err := json.Unmarshal(raw, &parsed); err == nil && parsed.Error != "" { - return parsed.Error - } - return strings.TrimSpace(string(raw)) -} diff --git a/telegram-bot/internal/backend/types.go b/telegram-bot/internal/backend/types.go deleted file mode 100644 index ad71d74..0000000 --- a/telegram-bot/internal/backend/types.go +++ /dev/null @@ -1,30 +0,0 @@ -package backend - -type ErrorResponse struct { - Error string `json:"error"` -} - -type TelegramLinkRequest struct { - Code string `json:"code"` - TelegramUserID int64 `json:"telegram_user_id"` - TelegramUsername string `json:"telegram_username"` - TelegramFirst string `json:"telegram_first_name"` - TelegramLast string `json:"telegram_last_name"` -} - -type LinkedUser struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` -} - -type TelegramStatusResponse struct { - Linked bool `json:"linked"` - NotificationsEnabled bool `json:"notifications_enabled"` - User *LinkedUser `json:"user"` -} - -type TelegramNotificationsRequest struct { - TelegramUserID int64 `json:"telegram_user_id"` - Enabled bool `json:"enabled"` -} diff --git a/telegram-bot/internal/bot/bot.go b/telegram-bot/internal/bot/bot.go deleted file mode 100644 index 6747d9d..0000000 --- a/telegram-bot/internal/bot/bot.go +++ /dev/null @@ -1,57 +0,0 @@ -package bot - -import ( - "context" - "errors" - "log" - "net/http" - - "mathalama-focus/telegram-bot/internal/backend" - "mathalama-focus/telegram-bot/internal/config" - "mathalama-focus/telegram-bot/internal/telegram" -) - -// Bot orchestrates the Telegram polling loop and internal API server. -type Bot struct { - cfg config.Config - tg *telegram.Client - backend *backend.Client -} - -// New creates a new Bot instance. -func New(cfg config.Config, tg *telegram.Client, be *backend.Client) *Bot { - return &Bot{cfg: cfg, tg: tg, backend: be} -} - -// Run starts the polling loop and internal API server, blocking until ctx is cancelled. -func (b *Bot) Run(ctx context.Context) error { - errCh := make(chan error, 2) - - go func() { - errCh <- b.runPolling(ctx) - }() - - go func() { - errCh <- b.runInternalAPI(ctx) - }() - - var firstErr error - for i := 0; i < cap(errCh); i++ { - err := <-errCh - if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, http.ErrServerClosed) { - continue - } - if firstErr == nil { - firstErr = err - } - } - - return firstErr -} - -// sendMessage sends a text message with the default action keyboard. -func (b *Bot) sendMessage(ctx context.Context, chatID int64, text string) { - if err := b.tg.SendMessage(ctx, chatID, text, ActionKeyboard()); err != nil { - log.Printf("sendMessage failed: %v", err) - } -} diff --git a/telegram-bot/internal/bot/handlers.go b/telegram-bot/internal/bot/handlers.go deleted file mode 100644 index 4fed237..0000000 --- a/telegram-bot/internal/bot/handlers.go +++ /dev/null @@ -1,250 +0,0 @@ -package bot - -import ( - "context" - "log" - "net/http" - "strings" - - "mathalama-focus/telegram-bot/internal/backend" - "mathalama-focus/telegram-bot/internal/telegram" -) - -func (b *Bot) handleMessage(ctx context.Context, msg telegram.Message) { - text := strings.TrimSpace(msg.Text) - if text == "" { - return - } - - command, arg := parseCommand(text) - switch command { - case "/start": - code := parseStartCode(arg) - if code == "" { - b.sendLinkStatus(ctx, msg) - return - } - b.tryLink(ctx, msg, code) - case "/status": - b.sendLinkStatus(ctx, msg) - case "/help": - b.sendMessage(ctx, msg.Chat.ID, helpText()) - case "/notify", "/notifications": - b.handleNotifyCommand(ctx, msg, arg) - case "/subscribe": - b.setNotifications(ctx, msg, true) - case "/unsubscribe": - b.setNotifications(ctx, msg, false) - case "/link": - if arg == "" { - b.sendMessage(ctx, msg.Chat.ID, "Использование: /link ") - return - } - b.tryLink(ctx, msg, arg) - default: - b.sendMessage(ctx, msg.Chat.ID, helpText()) - } -} - -func (b *Bot) handleCallbackQuery(ctx context.Context, query telegram.CallbackQuery) { - if query.ID == "" { - return - } - - chatID := int64(0) - if query.Message != nil { - chatID = query.Message.Chat.ID - } - if chatID <= 0 { - b.tg.AnswerCallbackQuery(ctx, query.ID, "Чат недоступен") - return - } - - action := strings.TrimSpace(query.Data) - switch action { - case telegram.CallbackNotifyOn: - b.setNotificationsByUser(ctx, query.From, chatID, true) - b.tg.AnswerCallbackQuery(ctx, query.ID, "Уведомления: ON") - case telegram.CallbackNotifyOff: - b.setNotificationsByUser(ctx, query.From, chatID, false) - b.tg.AnswerCallbackQuery(ctx, query.ID, "Уведомления: OFF") - case telegram.CallbackStatus: - b.sendLinkStatusByUser(ctx, query.From, chatID) - b.tg.AnswerCallbackQuery(ctx, query.ID, "Статус обновлен") - case telegram.CallbackHelp: - b.sendMessage(ctx, chatID, helpText()) - b.tg.AnswerCallbackQuery(ctx, query.ID, "Открываю помощь") - default: - b.tg.AnswerCallbackQuery(ctx, query.ID, "Неизвестная кнопка") - } -} - -func (b *Bot) handleNotifyCommand(ctx context.Context, msg telegram.Message, arg string) { - enabled, ok := parseNotifyArg(arg) - if !ok { - b.sendMessage(ctx, msg.Chat.ID, "Использование: /notify on или /notify off") - return - } - b.setNotifications(ctx, msg, enabled) -} - -func (b *Bot) sendLinkStatus(ctx context.Context, msg telegram.Message) { - b.sendLinkStatusByUser(ctx, msg.From, msg.Chat.ID) -} - -func (b *Bot) sendLinkStatusByUser(ctx context.Context, from telegram.User, chatID int64) { - status, statusCode, backendErr := b.backend.GetTelegramStatus(ctx, from.ID) - if statusCode == http.StatusOK { - if status.Linked { - b.sendMessage(ctx, chatID, alreadyLinkedText(status.User, status.NotificationsEnabled)) - return - } - b.sendMessage(ctx, chatID, notLinkedText(b.cfg.AppURL)) - return - } - - if statusCode == http.StatusUnauthorized || statusCode == http.StatusServiceUnavailable { - log.Printf("telegram status check rejected (status=%d): %s", statusCode, backendErr) - b.sendMessage(ctx, chatID, "Интеграция Telegram временно недоступна. Попробуй позже.") - return - } - - log.Printf("telegram status check failed (status=%d): %s", statusCode, backendErr) - b.sendMessage(ctx, chatID, "Не удалось проверить статус привязки. Попробуй позже.") -} - -func (b *Bot) setNotifications(ctx context.Context, msg telegram.Message, enabled bool) { - b.setNotificationsByUser(ctx, msg.From, msg.Chat.ID, enabled) -} - -func (b *Bot) setNotificationsByUser(ctx context.Context, from telegram.User, chatID int64, enabled bool) { - status, statusCode, backendErr := b.backend.GetTelegramStatus(ctx, from.ID) - if statusCode == http.StatusOK && !status.Linked { - b.sendMessage(ctx, chatID, notLinkedText(b.cfg.AppURL)) - return - } - if statusCode == http.StatusOK && status.Linked && status.NotificationsEnabled == enabled { - if enabled { - b.sendMessage(ctx, chatID, "Уведомления уже включены.\nЧтобы выключить: /notify off") - } else { - b.sendMessage(ctx, chatID, "Уведомления уже выключены.\nЧтобы включить: /notify on") - } - return - } - if statusCode != http.StatusOK { - log.Printf("telegram status check before notify update failed (status=%d): %s", statusCode, backendErr) - b.sendMessage(ctx, chatID, "Не удалось проверить статус привязки. Попробуй позже.") - return - } - - updateStatusCode, updateErr := b.backend.SetTelegramNotifications(ctx, from.ID, enabled) - switch updateStatusCode { - case http.StatusOK: - updatedStatus, updatedStatusCode, updatedStatusErr := b.backend.GetTelegramStatus(ctx, from.ID) - if updatedStatusCode == http.StatusOK && updatedStatus.Linked { - b.sendMessage(ctx, chatID, alreadyLinkedText(updatedStatus.User, updatedStatus.NotificationsEnabled)) - return - } - if updatedStatusCode != http.StatusOK { - log.Printf("telegram status check after notify update failed (status=%d): %s", updatedStatusCode, updatedStatusErr) - } - if enabled { - b.sendMessage(ctx, chatID, "Уведомления включены.") - } else { - b.sendMessage(ctx, chatID, "Уведомления выключены.") - } - case http.StatusNotFound: - b.sendMessage(ctx, chatID, notLinkedText(b.cfg.AppURL)) - case http.StatusUnauthorized, http.StatusServiceUnavailable: - log.Printf("telegram notify update rejected (status=%d): %s", updateStatusCode, updateErr) - b.sendMessage(ctx, chatID, "Интеграция Telegram временно недоступна. Попробуй позже.") - default: - log.Printf("telegram notify update failed (status=%d): %s", updateStatusCode, updateErr) - b.sendMessage(ctx, chatID, "Не удалось обновить настройки уведомлений. Попробуй позже.") - } -} - -func (b *Bot) tryLink(ctx context.Context, msg telegram.Message, rawCode string) { - code := strings.ToUpper(strings.TrimSpace(rawCode)) - if code == "" { - b.sendMessage(ctx, msg.Chat.ID, "Код не найден. Использование: /link ") - return - } - - status, statusCode, backendErr := b.backend.GetTelegramStatus(ctx, msg.From.ID) - if statusCode == http.StatusOK && status.Linked { - b.sendMessage(ctx, msg.Chat.ID, alreadyLinkedText(status.User, status.NotificationsEnabled)) - return - } - if statusCode != http.StatusOK && statusCode != http.StatusNotFound && statusCode != http.StatusBadRequest { - log.Printf("telegram status check before link failed (status=%d): %s", statusCode, backendErr) - b.sendMessage(ctx, msg.Chat.ID, "Не удалось проверить текущую привязку. Попробуй позже.") - return - } - - statusCode, backendErr = b.backend.LinkTelegram(ctx, backend.TelegramLinkRequest{ - Code: code, - TelegramUserID: msg.From.ID, - TelegramUsername: msg.From.Username, - TelegramFirst: msg.From.FirstName, - TelegramLast: msg.From.LastName, - }) - switch statusCode { - case http.StatusOK: - linkedStatus, linkedStatusCode, linkedErr := b.backend.GetTelegramStatus(ctx, msg.From.ID) - if linkedStatusCode == http.StatusOK && linkedStatus.Linked { - b.sendMessage(ctx, msg.Chat.ID, "Готово.\n"+alreadyLinkedText(linkedStatus.User, linkedStatus.NotificationsEnabled)) - return - } - if linkedStatusCode != http.StatusOK { - log.Printf("telegram status check after link failed (status=%d): %s", linkedStatusCode, linkedErr) - } - b.sendMessage(ctx, msg.Chat.ID, "Готово. Telegram аккаунт успешно привязан.") - case http.StatusBadRequest: - b.sendMessage(ctx, msg.Chat.ID, "Код недействителен или истек. Запроси новый код в приложении.") - case http.StatusConflict: - b.sendMessage(ctx, msg.Chat.ID, "Этот Telegram уже привязан к другому пользователю. Сначала отвяжи его в профиле того аккаунта.") - default: - log.Printf("telegram link failed (status=%d): %s", statusCode, backendErr) - b.sendMessage(ctx, msg.Chat.ID, "Не удалось привязать аккаунт. Попробуй позже.") - } -} - -func parseCommand(text string) (string, string) { - parts := strings.Fields(text) - if len(parts) == 0 { - return "", "" - } - - command := strings.ToLower(parts[0]) - if at := strings.Index(command, "@"); at > 0 { - command = command[:at] - } - if len(parts) == 1 { - return command, "" - } - return command, strings.TrimSpace(strings.Join(parts[1:], " ")) -} - -func parseStartCode(arg string) string { - value := strings.TrimSpace(arg) - if value == "" { - return "" - } - - if strings.HasPrefix(strings.ToLower(value), "link_") { - value = value[5:] - } - return strings.TrimSpace(value) -} - -func parseNotifyArg(arg string) (bool, bool) { - switch strings.ToLower(strings.TrimSpace(arg)) { - case "on", "true", "1", "enable", "enabled": - return true, true - case "off", "false", "0", "disable", "disabled": - return false, true - default: - return false, false - } -} diff --git a/telegram-bot/internal/bot/messages.go b/telegram-bot/internal/bot/messages.go deleted file mode 100644 index 08af1d6..0000000 --- a/telegram-bot/internal/bot/messages.go +++ /dev/null @@ -1,94 +0,0 @@ -package bot - -import ( - "fmt" - "strings" - - "mathalama-focus/telegram-bot/internal/backend" - "mathalama-focus/telegram-bot/internal/telegram" -) - -func helpText() string { - return strings.Join([]string{ - "Команды бота:", - "/start или /status — показать статус привязки", - "/link — привязать аккаунт по коду из приложения", - "/notify on — включить уведомления", - "/notify off — выключить уведомления", - "/subscribe — включить уведомления", - "/unsubscribe — выключить уведомления", - "/help — показать команды", - }, "\n") -} - -func notLinkedText(appURL string) string { - lines := []string{ - "Telegram пока не привязан к аккаунту.", - "", - "Что сделать:", - } - - if appURL != "" { - lines = append(lines, "1) Открой приложение: "+appURL) - lines = append(lines, "2) Войди в аккаунт") - lines = append(lines, "3) В профиле открой блок Telegram и сгенерируй код") - lines = append(lines, "4) Отправь сюда: /link ") - } else { - lines = append(lines, "1) Войди в приложение") - lines = append(lines, "2) В профиле открой блок Telegram и сгенерируй код") - lines = append(lines, "3) Отправь сюда: /link ") - } - - lines = append(lines, "", "Пример: /link ABCD2345", "После привязки уведомления по умолчанию выключены.") - return strings.Join(lines, "\n") -} - -func alreadyLinkedText(user *backend.LinkedUser, notificationsEnabled bool) string { - notificationState := "выключены" - nextNotificationAction := "Чтобы включить: /notify on" - if notificationsEnabled { - notificationState = "включены" - nextNotificationAction = "Чтобы выключить: /notify off" - } - - if user == nil { - return strings.Join([]string{ - "Telegram уже привязан к аккаунту.", - fmt.Sprintf("Уведомления: %s.", notificationState), - nextNotificationAction, - "Если нужно перепривязать — сначала отвяжи Telegram в профиле приложения.", - }, "\n") - } - - userLabel := strings.TrimSpace(user.Name) - if userLabel == "" { - userLabel = strings.TrimSpace(user.Email) - } - if userLabel == "" { - userLabel = "аккаунт" - } - - return strings.Join([]string{ - "Telegram уже привязан.", - fmt.Sprintf("Аккаунт: %s", userLabel), - fmt.Sprintf("Уведомления: %s.", notificationState), - nextNotificationAction, - "Если нужно перепривязать — сначала отвяжи Telegram в профиле приложения, потом отправь новый /link код.", - }, "\n") -} - -// ActionKeyboard returns the default inline keyboard shown with every message. -func ActionKeyboard() *telegram.InlineKeyboardMarkup { - return &telegram.InlineKeyboardMarkup{ - InlineKeyboard: [][]telegram.InlineKeyboardButton{ - { - {Text: "Включить", CallbackData: telegram.CallbackNotifyOn}, - {Text: "Выключить", CallbackData: telegram.CallbackNotifyOff}, - }, - { - {Text: "Статус", CallbackData: telegram.CallbackStatus}, - {Text: "Помощь", CallbackData: telegram.CallbackHelp}, - }, - }, - } -} diff --git a/telegram-bot/internal/bot/polling.go b/telegram-bot/internal/bot/polling.go deleted file mode 100644 index 3ce5bea..0000000 --- a/telegram-bot/internal/bot/polling.go +++ /dev/null @@ -1,45 +0,0 @@ -package bot - -import ( - "context" - "log" - "strings" - "time" -) - -func (b *Bot) runPolling(ctx context.Context) error { - offset := 0 - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - updates, err := b.tg.GetUpdates(ctx, offset, b.cfg.PollTimeout) - if err != nil { - log.Printf("getUpdates error: %v", err) - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(2 * time.Second): - } - continue - } - - for _, update := range updates { - if update.UpdateID >= offset { - offset = update.UpdateID + 1 - } - if update.CallbackQuery != nil { - b.handleCallbackQuery(ctx, *update.CallbackQuery) - continue - } - if update.Message == nil || strings.TrimSpace(update.Message.Text) == "" { - continue - } - - b.handleMessage(ctx, *update.Message) - } - } -} diff --git a/telegram-bot/internal/bot/server.go b/telegram-bot/internal/bot/server.go deleted file mode 100644 index 5c9acd9..0000000 --- a/telegram-bot/internal/bot/server.go +++ /dev/null @@ -1,131 +0,0 @@ -package bot - -import ( - "context" - "crypto/subtle" - "encoding/json" - "errors" - "io" - "log" - "net/http" - "strings" - "time" - - "mathalama-focus/telegram-bot/internal/config" -) - -// NotifyRequest is the payload for the internal /internal/notify endpoint. -type NotifyRequest struct { - TelegramUserID int64 `json:"telegram_user_id"` - Message string `json:"message"` - DisableButtons bool `json:"disable_buttons"` - Force bool `json:"force"` -} - -func (b *Bot) runInternalAPI(ctx context.Context) error { - mux := http.NewServeMux() - mux.HandleFunc("/health", b.handleHealth) - mux.HandleFunc("/internal/notify", b.handleNotify) - - srv := &http.Server{ - Addr: b.cfg.InternalAPIAddr, - Handler: mux, - ReadHeaderTimeout: 5 * time.Second, - } - - go func() { - <-ctx.Done() - shutdownCtx, cancel := context.WithTimeout(context.Background(), 8*time.Second) - defer cancel() - if err := srv.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Printf("internal api shutdown error: %v", err) - } - }() - - log.Printf("telegram internal api listening on %s", b.cfg.InternalAPIAddr) - err := srv.ListenAndServe() - if err != nil && !errors.Is(err, http.ErrServerClosed) { - return err - } - return nil -} - -func (b *Bot) handleHealth(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) -} - -func (b *Bot) handleNotify(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(r.Header.Get(config.BotAuthHeader))), []byte(b.cfg.BotAuth)) != 1 { - writeJSONError(w, http.StatusUnauthorized, "invalid bot auth token") - return - } - - var req NotifyRequest - decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&req); err != nil { - writeJSONError(w, http.StatusBadRequest, "invalid request body") - return - } - - req.Message = strings.TrimSpace(req.Message) - if req.TelegramUserID <= 0 || req.Message == "" { - writeJSONError(w, http.StatusBadRequest, "telegram_user_id and message are required") - return - } - - if !req.Force { - status, statusCode, statusErr := b.backend.GetTelegramStatus(r.Context(), req.TelegramUserID) - if statusCode != http.StatusOK { - log.Printf("internal notify status check failed (status=%d): %s", statusCode, statusErr) - writeJSONError(w, http.StatusBadGateway, "failed to resolve telegram link status") - return - } - if !status.Linked { - writeJSONError(w, http.StatusNotFound, "telegram account is not linked") - return - } - if !status.NotificationsEnabled { - writeJSONError(w, http.StatusConflict, "telegram notifications are disabled") - return - } - } - - markup := ActionKeyboard() - if req.DisableButtons { - markup = nil - } - - if err := b.tg.SendMessage(r.Context(), req.TelegramUserID, req.Message, markup); err != nil { - log.Printf("internal notify send failed: %v", err) - writeJSONError(w, http.StatusBadGateway, "failed to send telegram message") - return - } - - writeJSON(w, http.StatusOK, map[string]any{ - "sent": true, - "telegram_user_id": req.TelegramUserID, - "notifications_forced": req.Force, - }) -} - -func writeJSON(w http.ResponseWriter, statusCode int, payload any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(statusCode) - if err := json.NewEncoder(w).Encode(payload); err != nil { - log.Printf("write json response failed: %v", err) - } -} - -func writeJSONError(w http.ResponseWriter, statusCode int, message string) { - writeJSON(w, statusCode, map[string]any{"error": strings.TrimSpace(message)}) -} diff --git a/telegram-bot/internal/config/config.go b/telegram-bot/internal/config/config.go deleted file mode 100644 index a021b7f..0000000 --- a/telegram-bot/internal/config/config.go +++ /dev/null @@ -1,121 +0,0 @@ -package config - -import ( - "bufio" - "errors" - "os" - "strconv" - "strings" - "time" -) - -const ( - DefaultBackendURL = "http://localhost:8080" - DefaultAppURL = "http://localhost:5173" - DefaultPollWait = 30 * time.Second - DefaultAPIAddr = ":8091" - BotAuthHeader = "X-Telegram-Bot-Auth" -) - -type Config struct { - Token string - BackendURL string - AppURL string - BotAuth string - PollTimeout time.Duration - InternalAPIAddr string -} - -func Load() (Config, error) { - if err := loadDotEnvIfPresent(".env"); err != nil { - return Config{}, err - } - - cfg := Config{ - Token: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")), - BackendURL: strings.TrimRight(strings.TrimSpace(envOrDefault("BACKEND_URL", DefaultBackendURL)), "/"), - AppURL: strings.TrimRight(strings.TrimSpace(envOrDefault("APP_URL", DefaultAppURL)), "/"), - BotAuth: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_AUTH_TOKEN")), - PollTimeout: parseDurationSeconds(os.Getenv("TELEGRAM_POLL_TIMEOUT_SECONDS"), DefaultPollWait), - InternalAPIAddr: strings.TrimSpace(envOrDefault("BOT_INTERNAL_API_ADDR", DefaultAPIAddr)), - } - - if cfg.Token == "" { - return Config{}, errors.New("TELEGRAM_BOT_TOKEN is required") - } - if cfg.BotAuth == "" { - return Config{}, errors.New("TELEGRAM_BOT_AUTH_TOKEN is required") - } - - return cfg, nil -} - -func envOrDefault(key, fallback string) string { - if value := strings.TrimSpace(os.Getenv(key)); value != "" { - return value - } - return fallback -} - -func loadDotEnvIfPresent(path string) error { - content, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return nil - } - if err != nil { - return err - } - - scanner := bufio.NewScanner(strings.NewReader(string(content))) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - if strings.HasPrefix(line, "export ") { - line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) - } - - key, rawValue, ok := strings.Cut(line, "=") - if !ok { - continue - } - key = strings.TrimSpace(key) - if key == "" { - continue - } - - if _, exists := os.LookupEnv(key); exists { - continue - } - - value := normalizeEnvValue(rawValue) - if err := os.Setenv(key, value); err != nil { - return err - } - } - - return scanner.Err() -} - -func normalizeEnvValue(raw string) string { - value := strings.TrimSpace(raw) - if len(value) >= 2 { - if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') { - return value[1 : len(value)-1] - } - } - return value -} - -func parseDurationSeconds(raw string, fallback time.Duration) time.Duration { - raw = strings.TrimSpace(raw) - if raw == "" { - return fallback - } - seconds, err := strconv.Atoi(raw) - if err != nil || seconds <= 0 { - return fallback - } - return time.Duration(seconds) * time.Second -} diff --git a/telegram-bot/internal/telegram/client.go b/telegram-bot/internal/telegram/client.go deleted file mode 100644 index dec3c6e..0000000 --- a/telegram-bot/internal/telegram/client.go +++ /dev/null @@ -1,126 +0,0 @@ -package telegram - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "net/url" - "strconv" - "strings" - "time" -) - -type Client struct { - httpClient *http.Client - apiURL string -} - -func NewClient(token string, pollTimeout time.Duration) *Client { - return &Client{ - httpClient: &http.Client{ - Timeout: pollTimeout + 15*time.Second, - }, - apiURL: fmt.Sprintf("https://api.telegram.org/bot%s", token), - } -} - -func (c *Client) GetUpdates(ctx context.Context, offset int, pollTimeout time.Duration) ([]Update, error) { - updatesURL, err := url.Parse(c.apiURL + "/getUpdates") - if err != nil { - return nil, err - } - - query := updatesURL.Query() - query.Set("offset", strconv.Itoa(offset)) - query.Set("timeout", strconv.Itoa(int(pollTimeout.Seconds()))) - updatesURL.RawQuery = query.Encode() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, updatesURL.String(), nil) - if err != nil { - return nil, err - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var payload APIResponse[[]Update] - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return nil, err - } - if !payload.OK { - return nil, fmt.Errorf("telegram getUpdates failed: %s", payload.Description) - } - - return payload.Result, nil -} - -func (c *Client) SendMessage(ctx context.Context, chatID int64, text string, markup *InlineKeyboardMarkup) error { - payload, err := json.Marshal(SendMessageRequest{ - ChatID: chatID, - Text: text, - ReplyMarkup: markup, - }) - if err != nil { - return fmt.Errorf("marshal sendMessage payload: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURL+"/sendMessage", bytes.NewReader(payload)) - if err != nil { - return fmt.Errorf("build sendMessage request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("sendMessage request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return fmt.Errorf("telegram sendMessage failed (status=%d): %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - - return nil -} - -func (c *Client) AnswerCallbackQuery(ctx context.Context, queryID, text string) { - if strings.TrimSpace(queryID) == "" { - return - } - - payload, err := json.Marshal(AnswerCallbackQueryRequest{ - CallbackQueryID: queryID, - Text: strings.TrimSpace(text), - }) - if err != nil { - log.Printf("marshal answerCallbackQuery payload: %v", err) - return - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiURL+"/answerCallbackQuery", bytes.NewReader(payload)) - if err != nil { - log.Printf("build answerCallbackQuery request: %v", err) - return - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - log.Printf("answerCallbackQuery request failed: %v", err) - return - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - log.Printf("answerCallbackQuery failed (status=%d): %s", resp.StatusCode, strings.TrimSpace(string(body))) - } -} diff --git a/telegram-bot/internal/telegram/types.go b/telegram-bot/internal/telegram/types.go deleted file mode 100644 index 476628a..0000000 --- a/telegram-bot/internal/telegram/types.go +++ /dev/null @@ -1,64 +0,0 @@ -package telegram - -const ( - CallbackNotifyOn = "notify_on" - CallbackNotifyOff = "notify_off" - CallbackStatus = "show_status" - CallbackHelp = "show_help" -) - -type APIResponse[T any] struct { - OK bool `json:"ok"` - Description string `json:"description"` - Result T `json:"result"` -} - -type Update struct { - UpdateID int `json:"update_id"` - Message *Message `json:"message"` - CallbackQuery *CallbackQuery `json:"callback_query"` -} - -type Message struct { - Chat Chat `json:"chat"` - From User `json:"from"` - Text string `json:"text"` -} - -type Chat struct { - ID int64 `json:"id"` -} - -type CallbackQuery struct { - ID string `json:"id"` - From User `json:"from"` - Message *Message `json:"message"` - Data string `json:"data"` -} - -type User struct { - ID int64 `json:"id"` - Username string `json:"username"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` -} - -type SendMessageRequest struct { - ChatID int64 `json:"chat_id"` - Text string `json:"text"` - ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` -} - -type InlineKeyboardButton struct { - Text string `json:"text"` - CallbackData string `json:"callback_data,omitempty"` -} - -type InlineKeyboardMarkup struct { - InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard"` -} - -type AnswerCallbackQueryRequest struct { - CallbackQueryID string `json:"callback_query_id"` - Text string `json:"text,omitempty"` -} From 3e8a37f6eb79d4f9bc1f27ae60987d23a2c9a704 Mon Sep 17 00:00:00 2001 From: mathalama Date: Mon, 20 Apr 2026 14:58:20 +0500 Subject: [PATCH 2/5] feat(deploy): currently working github token instead GHCR token --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 887be91..45982e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: with: registry: ghcr.io username: ${{ github.actor }} - password: ${{ secrets.GHCR_TOKEN }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push backend uses: docker/build-push-action@v6 From 9e8f79849b7533d9dcd717d7e288a9f2157193c6 Mon Sep 17 00:00:00 2001 From: mathalama Date: Mon, 20 Apr 2026 15:51:55 +0500 Subject: [PATCH 3/5] refactor(backend): remove telegram bot integration and update dockerfile to go 1.25 --- backend/.env.example | 2 - backend/Dockerfile | 25 ++++------ backend/go.mod | 22 ++++----- backend/go.sum | 46 ++++++++----------- backend/internal/delivery/httpapi/handler.go | 22 ++++----- .../infrastructure/postgres/helpers.go | 18 -------- backend/internal/usecase/dto.go | 7 --- backend/internal/usecase/repository.go | 9 ---- backend/migrations/004_telegram_auth.sql | 19 -------- .../migrations/005_telegram_notifications.sql | 2 - 10 files changed, 51 insertions(+), 121 deletions(-) delete mode 100644 backend/migrations/004_telegram_auth.sql delete mode 100644 backend/migrations/005_telegram_notifications.sql diff --git a/backend/.env.example b/backend/.env.example index 97ff879..744939c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,8 +7,6 @@ REFRESH_SESSION_TTL_HOURS=720 MAX_ACTIVE_AUTH_SESSIONS=5 AUTH_SESSION_BIND_CLIENT=true MAX_SESSION_PAUSES=3 -TELEGRAM_BOT_AUTH_TOKEN=dev-telegram-bot-auth-change-me -TELEGRAM_LINK_CODE_TTL_MINUTES=10 RESEND_API_KEY= RESEND_FROM_EMAIL= EMAIL_OUTBOX_POLL_SECONDS=2 diff --git a/backend/Dockerfile b/backend/Dockerfile index 463671b..63e9db8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,26 +1,19 @@ # syntax=docker/dockerfile:1.7 - -FROM golang:1.24-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /src - COPY go.mod go.sum ./ -RUN go mod download - +RUN go mod download -x COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/backend ./cmd/api -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/migrate ./cmd/migrate +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/backend ./cmd/api \ + && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/migrate ./cmd/migrate FROM alpine:3.21 RUN apk add --no-cache ca-certificates \ - && addgroup -S app \ - && adduser -S -G app app - + && addgroup -S app \ + && adduser -S -G app app WORKDIR /app -COPY --from=builder /out/backend /app/backend -COPY --from=builder /out/migrate /app/migrate -COPY --from=builder /src/migrations /app/migrations - +COPY --from=builder /out/backend /out/migrate ./ +COPY --from=builder /src/migrations ./migrations USER app EXPOSE 8080 - -ENTRYPOINT ["/app/backend"] +ENTRYPOINT ["/app/backend"] \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index 056c848..857f949 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,14 +1,19 @@ module mathalama-focus/backend -go 1.24 +go 1.25.0 require ( github.com/gin-contrib/cors v1.7.2 github.com/gin-gonic/gin v1.10.0 + github.com/go-playground/validator/v10 v10.30.2 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.7.1 github.com/joho/godotenv v1.5.1 + github.com/prometheus/client_golang v1.20.5 + golang.org/x/crypto v0.49.0 + golang.org/x/time v0.7.0 ) require ( @@ -18,13 +23,11 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -38,19 +41,16 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect - github.com/prometheus/client_golang v1.20.5 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.27.0 // indirect - golang.org/x/net v0.26.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.25.0 // indirect - golang.org/x/text v0.18.0 // indirect - golang.org/x/time v0.7.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 54d7a59..b9584e1 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -14,8 +14,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= @@ -28,15 +28,14 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= -github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -60,11 +59,12 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02 github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -88,8 +88,8 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -109,26 +109,20 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/internal/delivery/httpapi/handler.go b/backend/internal/delivery/httpapi/handler.go index 68b0cee..6b7167a 100644 --- a/backend/internal/delivery/httpapi/handler.go +++ b/backend/internal/delivery/httpapi/handler.go @@ -12,7 +12,7 @@ import ( "mathalama-focus/backend/internal/usecase" "github.com/gin-gonic/gin" - "github.com/go-playground/validator/10" + "github.com/go-playground/validator/v10" "github.com/google/uuid" ) @@ -31,16 +31,16 @@ type DBKeepAlive interface { // Handler groups all HTTP handlers and their use-case dependencies. type Handler struct { - auth *usecase.AuthUseCase - session *usecase.SessionUseCase - goal *usecase.GoalUseCase - analytics *usecase.AnalyticsUseCase - shop *usecase.ShopUseCase - notification *usecase.NotificationUseCase - preferences *usecase.PreferencesUseCase - dbKeepAlive DBKeepAlive - emailDeliveries EmailDeliveryReader - eventTracker EventTracker + auth *usecase.AuthUseCase + session *usecase.SessionUseCase + goal *usecase.GoalUseCase + analytics *usecase.AnalyticsUseCase + shop *usecase.ShopUseCase + notification *usecase.NotificationUseCase + preferences *usecase.PreferencesUseCase + dbKeepAlive DBKeepAlive + emailDeliveries EmailDeliveryReader + eventTracker EventTracker } // NewHandler creates a new Handler with all use-case dependencies. diff --git a/backend/internal/infrastructure/postgres/helpers.go b/backend/internal/infrastructure/postgres/helpers.go index cc781f5..501c2d2 100644 --- a/backend/internal/infrastructure/postgres/helpers.go +++ b/backend/internal/infrastructure/postgres/helpers.go @@ -34,24 +34,6 @@ func roundToTenth(value float64) float64 { return math.Round(value*10) / 10 } -func generateTelegramLinkCode(length int) (string, error) { - const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" - if length <= 0 { - return "", errors.New("invalid code length") - } - - buf := make([]byte, length) - randomBuf, err := randomBytes(length) - if err != nil { - return "", err - } - - for i, b := range randomBuf { - buf[i] = alphabet[int(b)%len(alphabet)] - } - - return string(buf), nil -} func generateRandomToken(byteLength int) (string, error) { if byteLength <= 0 { diff --git a/backend/internal/usecase/dto.go b/backend/internal/usecase/dto.go index 084f679..eee7a0a 100644 --- a/backend/internal/usecase/dto.go +++ b/backend/internal/usecase/dto.go @@ -32,13 +32,6 @@ type ReflectionInput struct { NextAction string } -type TelegramLinkInput struct { - Code string - TelegramUserID int64 - TelegramUsername string - TelegramFirst string - TelegramLast string -} type SessionHistoryFilter struct { Period string diff --git a/backend/internal/usecase/repository.go b/backend/internal/usecase/repository.go index 4b50565..b05a579 100644 --- a/backend/internal/usecase/repository.go +++ b/backend/internal/usecase/repository.go @@ -69,15 +69,6 @@ type ShopRepository interface { GetLeaderboard(ctx context.Context) ([]domain.LeaderboardEntry, error) } -// TelegramRepository handles telegram-identity persistence. -type TelegramRepository interface { - CreateTelegramLinkCode(ctx context.Context, userID string, ttl time.Duration) (string, time.Time, error) - LinkTelegramByCode(ctx context.Context, input TelegramLinkInput) (domain.User, error) - GetTelegramIdentity(ctx context.Context, userID string) (domain.TelegramIdentity, error) - GetUserByTelegramUserID(ctx context.Context, telegramUserID int64) (domain.User, bool, error) - SetTelegramNotifications(ctx context.Context, telegramUserID int64, enabled bool) error - UnlinkTelegram(ctx context.Context, userID string) error -} // NotificationRepository handles notification sound preferences. type NotificationRepository interface { diff --git a/backend/migrations/004_telegram_auth.sql b/backend/migrations/004_telegram_auth.sql deleted file mode 100644 index 1250bb4..0000000 --- a/backend/migrations/004_telegram_auth.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE TABLE IF NOT EXISTS telegram_link_codes ( - code TEXT PRIMARY KEY CHECK (code ~ '^[A-Z0-9]{8}$'), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - expires_at TIMESTAMPTZ NOT NULL, - used_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_telegram_link_codes_user_id ON telegram_link_codes(user_id); -CREATE INDEX IF NOT EXISTS idx_telegram_link_codes_expires_at ON telegram_link_codes(expires_at); - -CREATE TABLE IF NOT EXISTS telegram_identities ( - user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - telegram_user_id BIGINT NOT NULL UNIQUE, - telegram_username TEXT, - telegram_first_name TEXT NOT NULL DEFAULT '', - telegram_last_name TEXT NOT NULL DEFAULT '', - linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); diff --git a/backend/migrations/005_telegram_notifications.sql b/backend/migrations/005_telegram_notifications.sql deleted file mode 100644 index 1ca1f81..0000000 --- a/backend/migrations/005_telegram_notifications.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE telegram_identities - ADD COLUMN IF NOT EXISTS notifications_enabled BOOLEAN NOT NULL DEFAULT FALSE; From 6420412d080263868fc5c167ed4530bfd630a968 Mon Sep 17 00:00:00 2001 From: mathalama Date: Mon, 20 Apr 2026 16:00:18 +0500 Subject: [PATCH 4/5] ci: use GHCR_TOKEN for docker login to fix 403 error --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45982e7..887be91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: with: registry: ghcr.io username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + password: ${{ secrets.GHCR_TOKEN }} - name: Build and push backend uses: docker/build-push-action@v6 From 6a26cc657c6fef81ed6bead69b5dd9ba3128f020 Mon Sep 17 00:00:00 2001 From: mathalama Date: Mon, 20 Apr 2026 16:04:04 +0500 Subject: [PATCH 5/5] ci: revert to GITHUB_TOKEN for docker login --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 887be91..45982e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: with: registry: ghcr.io username: ${{ github.actor }} - password: ${{ secrets.GHCR_TOKEN }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push backend uses: docker/build-push-action@v6