diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 179fcfb..45982e7 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 @@ -65,16 +52,16 @@ jobs: with: registry: ghcr.io username: ${{ github.actor }} - password: ${{ secrets.GHCR_TOKEN }} + password: ${{ secrets.GITHUB_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/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/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/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/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/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; 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"` -}