From 8ba7e8fd4581e1b39003759e34a9b041e16b7c8c Mon Sep 17 00:00:00 2001 From: Play Life Bot Date: Fri, 2 Jan 2026 14:47:51 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=A0=D1=9F=D0=A0=C2=B5=D0=A1=D0=82?= =?UTF-8?q?=D0=A0=C2=B5=D0=A0=D2=91=D0=A0=C2=B5=D0=A0=C2=BB=D0=A0=D1=94?= =?UTF-8?q?=D0=A0=C2=B0=20Telegram=20=D0=A0=D1=91=D0=A0=D0=85=D0=A1?= =?UTF-8?q?=E2=80=9A=D0=A0=C2=B5=D0=A0=D1=96=D0=A1=D0=82=D0=A0=C2=B0=D0=A1?= =?UTF-8?q?=E2=80=A0=D0=A0=D1=91=D0=A0=D1=91=20=D0=A0=D0=85=D0=A0=C2=B0=20?= =?UTF-8?q?=D0=A0=C2=B5=D0=A0=D2=91=D0=A0=D1=91=D0=A0=D0=85=D0=A0=D1=95?= =?UTF-8?q?=D0=A0=D1=96=D0=A0=D1=95=20=D0=A0=C2=B1=D0=A0=D1=95=D0=A1?= =?UTF-8?q?=E2=80=9A=D0=A0=C2=B0=20(v2.1.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Единый Р±РѕС‚ для всех пользователей (токен РёР· .env) - Deep link для подключения через /start команду - Отдельная таблица todoist_integrations для Todoist webhook - Персональные отчеты для каждого пользователя - Автоматическое применение миграции 012 РїСЂРё старте - Обновлен Frontend: РєРЅРѕРїРєР° подключения вместо поля РІРІРѕРґР° токена --- VERSION | 2 +- env.example | 6 +- play-life-backend/main.go | 1158 ++++++++++------- .../012_refactor_telegram_single_bot.sql | 103 ++ .../src/components/TelegramIntegration.jsx | 185 +-- 5 files changed, 845 insertions(+), 609 deletions(-) create mode 100644 play-life-backend/migrations/012_refactor_telegram_single_bot.sql diff --git a/VERSION b/VERSION index 815e68d..7ec1d6d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.8 +2.1.0 diff --git a/env.example b/env.example index fd00567..a187e01 100644 --- a/env.example +++ b/env.example @@ -28,8 +28,10 @@ WEB_PORT=3001 # ============================================ # Telegram Bot Configuration # ============================================ -# Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram" -# Get token from @BotFather in Telegram: https://t.me/botfather +# Токен единого бота для всех пользователей +# Получить у @BotFather: https://t.me/botfather +TELEGRAM_BOT_TOKEN=your-bot-token-here + # Base URL для автоматической настройки webhook # Примеры: # - Для production с HTTPS: https://your-domain.com diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 4289511..fcf8a02 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -175,10 +175,15 @@ type TelegramChat struct { ID int64 `json:"id"` } +type TelegramUser struct { + ID int64 `json:"id"` +} + type TelegramMessage struct { Text string `json:"text"` Entities []TelegramEntity `json:"entities"` Chat TelegramChat `json:"chat"` + From *TelegramUser `json:"from,omitempty"` } type TelegramWebhook struct { @@ -244,12 +249,12 @@ type contextKey string const userIDKey contextKey = "user_id" type App struct { - DB *sql.DB - webhookMutex sync.Mutex - lastWebhookTime map[int]time.Time // config_id -> last webhook time - telegramBot *tgbotapi.BotAPI - telegramChatID int64 - jwtSecret []byte + DB *sql.DB + webhookMutex sync.Mutex + lastWebhookTime map[int]time.Time // config_id -> last webhook time + telegramBot *tgbotapi.BotAPI + telegramBotUsername string + jwtSecret []byte } func setCORSHeaders(w http.ResponseWriter) { @@ -2546,16 +2551,117 @@ func (a *App) initAuthDB() error { // Create new unique constraint per user for progress a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_progress_word_user_unique ON progress(word_id, user_id)") - // Add webhook_token to telegram_integrations for URL-based user identification + // Add webhook_token to telegram_integrations for URL-based user identification (legacy, will be removed in migration 012) a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS webhook_token VARCHAR(255)") a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_webhook_token ON telegram_integrations(webhook_token) WHERE webhook_token IS NOT NULL") + // Apply migration 012: Refactor telegram_integrations for single shared bot + if err := a.applyMigration012(); err != nil { + log.Printf("Warning: Failed to apply migration 012: %v", err) + // Не возвращаем ошибку, чтобы приложение могло запуститься + } + // Clean up expired refresh tokens a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at < NOW()") return nil } +// applyMigration012 применяет миграцию 012_refactor_telegram_single_bot.sql +func (a *App) applyMigration012() error { + log.Printf("Applying migration 012: Refactor telegram_integrations for single shared bot") + + // 1. Создаем таблицу todoist_integrations + createTodoistIntegrationsTable := ` + CREATE TABLE IF NOT EXISTS todoist_integrations ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + webhook_token VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT todoist_integrations_user_id_unique UNIQUE (user_id) + ) + ` + if _, err := a.DB.Exec(createTodoistIntegrationsTable); err != nil { + return fmt.Errorf("failed to create todoist_integrations table: %w", err) + } + + // Создаем индексы для todoist_integrations + a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_webhook_token ON todoist_integrations(webhook_token)") + a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_todoist_integrations_user_id ON todoist_integrations(user_id)") + + // 2. Мигрируем webhook_token из telegram_integrations в todoist_integrations + migrateWebhookTokens := ` + INSERT INTO todoist_integrations (user_id, webhook_token, created_at, updated_at) + SELECT user_id, webhook_token, COALESCE(created_at, CURRENT_TIMESTAMP), CURRENT_TIMESTAMP + FROM telegram_integrations + WHERE webhook_token IS NOT NULL + AND webhook_token != '' + AND user_id IS NOT NULL + ON CONFLICT (user_id) DO NOTHING + ` + if _, err := a.DB.Exec(migrateWebhookTokens); err != nil { + log.Printf("Warning: Failed to migrate webhook_token to todoist_integrations: %v", err) + // Продолжаем выполнение, так как это может быть уже выполнено + } + + // 3. Удаляем bot_token (будет в .env) + a.DB.Exec("ALTER TABLE telegram_integrations DROP COLUMN IF EXISTS bot_token") + + // 4. Удаляем webhook_token (перенесли в todoist_integrations) + a.DB.Exec("ALTER TABLE telegram_integrations DROP COLUMN IF EXISTS webhook_token") + + // 5. Добавляем telegram_user_id + a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS telegram_user_id BIGINT") + + // 6. Добавляем start_token + a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS start_token VARCHAR(255)") + + // 7. Добавляем timestamps если их нет + a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP") + a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP") + + // 8. Создаем индексы + a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_start_token ON telegram_integrations(start_token) WHERE start_token IS NOT NULL") + a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_telegram_user_id ON telegram_integrations(telegram_user_id) WHERE telegram_user_id IS NOT NULL") + + // Уникальность user_id + a.DB.Exec("DROP INDEX IF EXISTS idx_telegram_integrations_user_id") + a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_user_id_unique ON telegram_integrations(user_id) WHERE user_id IS NOT NULL") + + // Индекс для поиска по chat_id + a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_telegram_integrations_chat_id ON telegram_integrations(chat_id) WHERE chat_id IS NOT NULL") + + // Удаляем старый индекс webhook_token + a.DB.Exec("DROP INDEX IF EXISTS idx_telegram_integrations_webhook_token") + + // 9. Очищаем данные Telegram для переподключения (только если еще не очищены) + // Проверяем, есть ли записи с заполненными chat_id или telegram_user_id + var count int + err := a.DB.QueryRow(` + SELECT COUNT(*) FROM telegram_integrations + WHERE (chat_id IS NOT NULL OR telegram_user_id IS NOT NULL) + AND (start_token IS NULL OR start_token = '') + `).Scan(&count) + + // Если есть старые данные без start_token, очищаем их для переподключения + if err == nil && count > 0 { + log.Printf("Clearing old Telegram integration data for %d users (they will need to reconnect)", count) + a.DB.Exec(` + UPDATE telegram_integrations + SET chat_id = NULL, + telegram_user_id = NULL, + start_token = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE (chat_id IS NOT NULL OR telegram_user_id IS NOT NULL) + AND (start_token IS NULL OR start_token = '') + `) + } + + log.Printf("Migration 012 applied successfully") + return nil +} + func (a *App) initPlayLifeDB() error { // Создаем таблицу projects createProjectsTable := ` @@ -2943,6 +3049,170 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { return &response, nil } +// getWeeklyStatsDataForUser получает данные о проектах для конкретного пользователя +func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error) { + // Обновляем materialized view перед запросом + _, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") + if err != nil { + log.Printf("Warning: Failed to refresh materialized view: %v", err) + } + + query := ` + SELECT + p.name AS project_name, + COALESCE(wr.total_score, 0.0000) AS total_score, + wg.min_goal_score, + wg.max_goal_score, + COALESCE(wg.priority, p.priority) AS priority + FROM + projects p + LEFT JOIN + weekly_goals wg ON wg.project_id = p.id + AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER + AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER + LEFT JOIN + weekly_report_mv wr + ON p.id = wr.project_id + AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year + AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week + WHERE + p.deleted = FALSE AND p.user_id = $1 + ORDER BY + total_score DESC + ` + + rows, err := a.DB.Query(query, userID) + if err != nil { + return nil, fmt.Errorf("error querying weekly stats: %w", err) + } + defer rows.Close() + + projects := make([]WeeklyProjectStats, 0) + groups := make(map[int][]float64) + + for rows.Next() { + var project WeeklyProjectStats + var minGoalScore sql.NullFloat64 + var maxGoalScore sql.NullFloat64 + var priority sql.NullInt64 + + err := rows.Scan( + &project.ProjectName, + &project.TotalScore, + &minGoalScore, + &maxGoalScore, + &priority, + ) + if err != nil { + return nil, fmt.Errorf("error scanning weekly stats row: %w", err) + } + + if minGoalScore.Valid { + project.MinGoalScore = minGoalScore.Float64 + } else { + project.MinGoalScore = 0 + } + + if maxGoalScore.Valid { + maxGoalVal := maxGoalScore.Float64 + project.MaxGoalScore = &maxGoalVal + } + + var priorityVal int + if priority.Valid { + priorityVal = int(priority.Int64) + project.Priority = &priorityVal + } + + // Расчет calculated_score + totalScore := project.TotalScore + minGoalScoreVal := project.MinGoalScore + var maxGoalScoreVal float64 + if project.MaxGoalScore != nil { + maxGoalScoreVal = *project.MaxGoalScore + } + + var extraBonusLimit float64 = 20 + if priorityVal == 1 { + extraBonusLimit = 50 + } else if priorityVal == 2 { + extraBonusLimit = 35 + } + + var calculatedScore float64 + if minGoalScoreVal > 0 { + percentage := (totalScore / minGoalScoreVal) * 100.0 + if maxGoalScoreVal > 0 { + if totalScore >= maxGoalScoreVal { + calculatedScore = 100.0 + math.Min(extraBonusLimit, ((totalScore-maxGoalScoreVal)/maxGoalScoreVal)*100.0) + } else { + calculatedScore = percentage + } + } else { + calculatedScore = math.Min(100.0+extraBonusLimit, percentage) + } + } else { + calculatedScore = 0.0 + } + + project.CalculatedScore = roundToFourDecimals(calculatedScore) + projects = append(projects, project) + + if priorityVal > 0 { + groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) + } else { + groups[0] = append(groups[0], project.CalculatedScore) + } + } + + // Расчет средних по группам + groupAverages := make([]float64, 0) + for priorityVal, scores := range groups { + if len(scores) > 0 { + var avg float64 + + if priorityVal == 1 || priorityVal == 2 { + sum := 0.0 + for _, score := range scores { + sum += score + } + avg = sum / float64(len(scores)) + } else { + projectCount := float64(len(scores)) + multiplier := 100.0 / math.Floor(projectCount * 0.8) + + sum := 0.0 + for _, score := range scores { + scoreAsDecimal := score / 100.0 + sum += scoreAsDecimal * multiplier + } + + avg = math.Min(120.0, sum) + } + + groupAverages = append(groupAverages, avg) + } + } + + var total *float64 + if len(groupAverages) > 0 { + sum := 0.0 + for _, avg := range groupAverages { + sum += avg + } + overallProgress := sum / float64(len(groupAverages)) + overallProgressRounded := roundToFourDecimals(overallProgress) + total = &overallProgressRounded + } + + response := WeeklyStatsResponse{ + Total: total, + Projects: projects, + } + + return &response, nil +} + // formatDailyReport форматирует данные проектов в сообщение для Telegram // Формат аналогичен JS коду из n8n func (a *App) formatDailyReport(data *WeeklyStatsResponse) string { @@ -3001,26 +3271,39 @@ func (a *App) formatDailyReport(data *WeeklyStatsResponse) string { return markdownMessage } -// sendDailyReport получает данные, форматирует и отправляет отчет в Telegram +// sendDailyReport отправляет персональные ежедневные отчеты всем пользователям func (a *App) sendDailyReport() error { - log.Printf("Scheduled task: Sending daily report") + log.Printf("Scheduled task: Sending daily reports") - // Получаем данные - data, err := a.getWeeklyStatsData() + userIDs, err := a.getAllUsersWithTelegram() if err != nil { - log.Printf("Error getting weekly stats data: %v", err) - return fmt.Errorf("error getting weekly stats data: %w", err) + return fmt.Errorf("error getting users: %w", err) } - - // Форматируем сообщение - message := a.formatDailyReport(data) - if message == "" { - log.Println("No data to send in daily report") + + if len(userIDs) == 0 { + log.Printf("No users with Telegram connected, skipping daily report") return nil } - // Отправляем сообщение в Telegram (без попытки разбирать на nodes) - a.sendTelegramMessage(message) + for _, userID := range userIDs { + data, err := a.getWeeklyStatsDataForUser(userID) + if err != nil { + log.Printf("Error getting data for user %d: %v", userID, err) + continue + } + + message := a.formatDailyReport(data) + if message == "" { + continue + } + + if err := a.sendTelegramMessageToUser(userID, message); err != nil { + log.Printf("Error sending daily report to user %d: %v", userID, err) + } else { + log.Printf("Daily report sent to user %d", userID) + } + } + return nil } @@ -3128,56 +3411,48 @@ func main() { } app := &App{ - DB: db, - lastWebhookTime: make(map[int]time.Time), - telegramBot: nil, // Больше не используем глобальный bot - telegramChatID: 0, // Больше не используем глобальный chat_id - jwtSecret: []byte(jwtSecret), + DB: db, + lastWebhookTime: make(map[int]time.Time), + telegramBot: nil, + telegramBotUsername: "", + jwtSecret: []byte(jwtSecret), } - // Пытаемся настроить webhook автоматически при старте для всех пользователей с bot_token - webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "") - if webhookBaseURL != "" { - log.Printf("Setting up Telegram webhooks for all users at startup...") - rows, err := app.DB.Query(` - SELECT user_id, bot_token, webhook_token - FROM telegram_integrations - WHERE bot_token IS NOT NULL - AND bot_token != '' - AND webhook_token IS NOT NULL - AND webhook_token != '' - AND user_id IS NOT NULL - `) + // Инициализация Telegram бота из .env + telegramBotToken := getEnv("TELEGRAM_BOT_TOKEN", "") + if telegramBotToken != "" { + bot, err := tgbotapi.NewBotAPI(telegramBotToken) if err != nil { - log.Printf("Warning: Failed to query telegram integrations at startup: %v", err) + log.Printf("WARNING: Failed to initialize Telegram bot: %v", err) } else { - defer rows.Close() - configuredCount := 0 - for rows.Next() { - var userID int - var botToken, webhookToken string - if err := rows.Scan(&userID, &botToken, &webhookToken); err != nil { - log.Printf("Warning: Failed to scan telegram integration: %v", err) - continue - } - - webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram/" + webhookToken - log.Printf("Setting up Telegram webhook for user_id=%d: URL=%s", userID, webhookURL) - if err := setupTelegramWebhook(botToken, webhookURL); err != nil { - log.Printf("Warning: Failed to setup Telegram webhook for user_id=%d: %v", userID, err) - } else { - log.Printf("SUCCESS: Telegram webhook configured for user_id=%d: %s", userID, webhookURL) - configuredCount++ - } - } - if configuredCount > 0 { - log.Printf("Telegram webhooks configured for %d user(s) at startup", configuredCount) + app.telegramBot = bot + log.Printf("Telegram bot initialized successfully") + + // Получаем username бота через getMe + botInfo, err := bot.GetMe() + if err != nil { + log.Printf("WARNING: Failed to get bot info via getMe(): %v", err) } else { - log.Printf("No Telegram integrations found with bot_token and webhook_token. Webhooks will be configured when users save bot tokens.") + app.telegramBotUsername = botInfo.UserName + log.Printf("Telegram bot username: @%s", app.telegramBotUsername) + } + + // Настраиваем webhook для единого бота + webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "") + if webhookBaseURL != "" { + webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram" + log.Printf("Setting up Telegram webhook: URL=%s", webhookURL) + if err := setupTelegramWebhook(telegramBotToken, webhookURL); err != nil { + log.Printf("WARNING: Failed to setup Telegram webhook: %v", err) + } else { + log.Printf("SUCCESS: Telegram webhook configured: %s", webhookURL) + } + } else { + log.Printf("WEBHOOK_BASE_URL not set. Webhook will not be configured.") } } } else { - log.Printf("WEBHOOK_BASE_URL not set. Webhook will be configured when user saves bot token.") + log.Printf("WARNING: TELEGRAM_BOT_TOKEN not set in environment") } // Инициализируем БД для play-life проекта @@ -3214,7 +3489,7 @@ func main() { // Webhooks - no auth (external services) r.HandleFunc("/webhook/message/post", app.messagePostHandler).Methods("POST", "OPTIONS") r.HandleFunc("/webhook/todoist/{token}", app.todoistWebhookHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/webhook/telegram/{token}", app.telegramWebhookHandler).Methods("POST", "OPTIONS") + r.HandleFunc("/webhook/telegram", app.telegramWebhookHandler).Methods("POST", "OPTIONS") // Admin pages (basic access, consider adding auth later) r.HandleFunc("/admin", app.adminHandler).Methods("GET") @@ -3365,269 +3640,170 @@ func roundToFourDecimals(val float64) float64 { // TelegramIntegration представляет запись из таблицы telegram_integrations type TelegramIntegration struct { - ID int `json:"id"` - ChatID *string `json:"chat_id"` - BotToken *string `json:"bot_token"` - WebhookToken *string `json:"webhook_token"` + ID int `json:"id"` + UserID int `json:"user_id"` + TelegramUserID *int64 `json:"telegram_user_id,omitempty"` + ChatID *string `json:"chat_id,omitempty"` + StartToken *string `json:"start_token,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// TodoistIntegration представляет запись из таблицы todoist_integrations +type TodoistIntegration struct { + ID int `json:"id"` + UserID int `json:"user_id"` + WebhookToken string `json:"webhook_token"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` } // getTelegramIntegration получает telegram интеграцию из БД // getTelegramIntegrationForUser gets telegram integration for specific user func (a *App) getTelegramIntegrationForUser(userID int) (*TelegramIntegration, error) { var integration TelegramIntegration - var chatID, botToken, webhookToken sql.NullString + var telegramUserID sql.NullInt64 + var chatID, startToken sql.NullString + var createdAt, updatedAt sql.NullTime err := a.DB.QueryRow(` - SELECT id, chat_id, bot_token, webhook_token + SELECT id, user_id, telegram_user_id, chat_id, start_token, created_at, updated_at FROM telegram_integrations WHERE user_id = $1 - ORDER BY id DESC LIMIT 1 - `, userID).Scan(&integration.ID, &chatID, &botToken, &webhookToken) + `, userID).Scan( + &integration.ID, + &integration.UserID, + &telegramUserID, + &chatID, + &startToken, + &createdAt, + &updatedAt, + ) if err == sql.ErrNoRows { - // Если записи нет, создаем новую для этого пользователя с webhook токеном - webhookToken, err := generateWebhookToken() + // Создаем новую запись с start_token + startTokenValue, err := generateWebhookToken() if err != nil { - return nil, fmt.Errorf("failed to generate webhook token: %w", err) + return nil, fmt.Errorf("failed to generate start token: %w", err) } err = a.DB.QueryRow(` - INSERT INTO telegram_integrations (chat_id, bot_token, user_id, webhook_token) - VALUES (NULL, NULL, $1, $2) - RETURNING id - `, userID, webhookToken).Scan(&integration.ID) + INSERT INTO telegram_integrations (user_id, start_token) + VALUES ($1, $2) + RETURNING id, user_id, telegram_user_id, chat_id, start_token, created_at, updated_at + `, userID, startTokenValue).Scan( + &integration.ID, + &integration.UserID, + &telegramUserID, + &chatID, + &startToken, + &createdAt, + &updatedAt, + ) if err != nil { return nil, fmt.Errorf("failed to create telegram integration: %w", err) } - integration.WebhookToken = &webhookToken - return &integration, nil + startToken = sql.NullString{String: startTokenValue, Valid: true} } else if err != nil { return nil, fmt.Errorf("failed to get telegram integration: %w", err) } + // Заполняем указатели + if telegramUserID.Valid { + integration.TelegramUserID = &telegramUserID.Int64 + } if chatID.Valid { integration.ChatID = &chatID.String } - if botToken.Valid { - integration.BotToken = &botToken.String + if startToken.Valid { + integration.StartToken = &startToken.String } - if webhookToken.Valid { - integration.WebhookToken = &webhookToken.String - } else { - // Если токена нет, генерируем его - newToken, err := generateWebhookToken() - if err != nil { - return nil, fmt.Errorf("failed to generate webhook token: %w", err) - } - _, err = a.DB.Exec(` - UPDATE telegram_integrations - SET webhook_token = $1 - WHERE id = $2 - `, newToken, integration.ID) - if err != nil { - return nil, fmt.Errorf("failed to update webhook token: %w", err) - } - integration.WebhookToken = &newToken + if createdAt.Valid { + integration.CreatedAt = &createdAt.Time + } + if updatedAt.Valid { + integration.UpdatedAt = &updatedAt.Time } return &integration, nil } -func (a *App) getTelegramIntegration() (*TelegramIntegration, error) { - var integration TelegramIntegration - var chatID, botToken sql.NullString - err := a.DB.QueryRow(` - SELECT id, chat_id, bot_token - FROM telegram_integrations - ORDER BY id DESC - LIMIT 1 - `).Scan(&integration.ID, &chatID, &botToken) - - if err == sql.ErrNoRows { - // Если записи нет, создаем новую - _, err = a.DB.Exec(` - INSERT INTO telegram_integrations (chat_id, bot_token) - VALUES (NULL, NULL) - `) - if err != nil { - return nil, fmt.Errorf("failed to create telegram integration: %w", err) - } - // Повторно получаем созданную запись - err = a.DB.QueryRow(` - SELECT id, chat_id, bot_token - FROM telegram_integrations - ORDER BY id DESC - LIMIT 1 - `).Scan(&integration.ID, &chatID, &botToken) - if err != nil { - return nil, fmt.Errorf("failed to get created telegram integration: %w", err) - } - } else if err != nil { - return nil, fmt.Errorf("failed to get telegram integration: %w", err) +// sendTelegramMessageToChat - отправляет сообщение в конкретный чат по chat_id +func (a *App) sendTelegramMessageToChat(chatID int64, text string) error { + if a.telegramBot == nil { + return fmt.Errorf("telegram bot not initialized") } - if chatID.Valid { - integration.ChatID = &chatID.String - } - if botToken.Valid { - integration.BotToken = &botToken.String - } - - return &integration, nil -} - -// saveTelegramBotToken сохраняет bot token в БД -func (a *App) saveTelegramBotToken(botToken string) error { - // Проверяем, есть ли уже запись - integration, err := a.getTelegramIntegration() - if err != nil { - // Если записи нет, создаем новую - _, err = a.DB.Exec(` - INSERT INTO telegram_integrations (bot_token, chat_id) - VALUES ($1, NULL) - `, botToken) - if err != nil { - return fmt.Errorf("failed to create telegram bot token: %w", err) - } - } else { - // Обновляем существующую запись - _, err = a.DB.Exec(` - UPDATE telegram_integrations - SET bot_token = $1 - WHERE id = $2 - `, botToken, integration.ID) - if err != nil { - return fmt.Errorf("failed to update telegram bot token: %w", err) - } - } - return nil -} - -func (a *App) saveTelegramBotTokenForUser(botToken string, userID int) error { - // Проверяем, есть ли уже запись для этого пользователя - integration, err := a.getTelegramIntegrationForUser(userID) - if err != nil { - // Если записи нет, создаем новую с webhook токеном - webhookToken, err := generateWebhookToken() - if err != nil { - return fmt.Errorf("failed to generate webhook token: %w", err) - } - _, err = a.DB.Exec(` - INSERT INTO telegram_integrations (bot_token, chat_id, user_id, webhook_token) - VALUES ($1, NULL, $2, $3) - `, botToken, userID, webhookToken) - if err != nil { - return fmt.Errorf("failed to create telegram bot token: %w", err) - } - } else { - // Обновляем существующую запись - _, err = a.DB.Exec(` - UPDATE telegram_integrations - SET bot_token = $1 - WHERE id = $2 AND user_id = $3 - `, botToken, integration.ID, userID) - if err != nil { - return fmt.Errorf("failed to update telegram bot token: %w", err) - } - // Убеждаемся, что webhook_token есть - if integration.WebhookToken == nil || *integration.WebhookToken == "" { - webhookToken, err := generateWebhookToken() - if err != nil { - return fmt.Errorf("failed to generate webhook token: %w", err) - } - _, err = a.DB.Exec(` - UPDATE telegram_integrations - SET webhook_token = $1 - WHERE id = $2 - `, webhookToken, integration.ID) - if err != nil { - return fmt.Errorf("failed to update webhook token: %w", err) - } - } - } - return nil -} - -// saveTelegramChatID сохраняет chat_id в БД -func (a *App) saveTelegramChatID(chatID string) error { - // Получаем текущую интеграцию - integration, err := a.getTelegramIntegration() - if err != nil { - return fmt.Errorf("failed to get telegram integration: %w", err) - } - - _, err = a.DB.Exec(` - UPDATE telegram_integrations - SET chat_id = $1 - WHERE id = $2 - `, chatID, integration.ID) - if err != nil { - return fmt.Errorf("failed to save telegram chat_id: %w", err) - } - return nil -} - -// getTelegramBotAndChatID получает bot token и chat_id из БД и создает bot API -func (a *App) getTelegramBotAndChatID() (*tgbotapi.BotAPI, int64, error) { - integration, err := a.getTelegramIntegration() - if err != nil { - return nil, 0, err - } - - if integration.BotToken == nil || *integration.BotToken == "" { - return nil, 0, nil // Bot token не настроен - } - - bot, err := tgbotapi.NewBotAPI(*integration.BotToken) - if err != nil { - return nil, 0, fmt.Errorf("failed to initialize Telegram bot: %w", err) - } - - var chatID int64 = 0 - if integration.ChatID != nil && *integration.ChatID != "" { - chatID, err = strconv.ParseInt(*integration.ChatID, 10, 64) - if err != nil { - log.Printf("Warning: Invalid chat_id format in database: %v", err) - chatID = 0 - } - } - - return bot, chatID, nil -} - -func (a *App) sendTelegramMessage(text string) { - log.Printf("sendTelegramMessage called with text length: %d", len(text)) - - // Получаем bot и chat_id из БД - bot, chatID, err := a.getTelegramBotAndChatID() - if err != nil { - log.Printf("WARNING: Failed to get Telegram bot from database: %v, skipping message send", err) - return - } - - if bot == nil || chatID == 0 { - // Telegram не настроен, пропускаем отправку - log.Printf("WARNING: Telegram bot not configured (bot=%v, chatID=%d), skipping message send", bot != nil, chatID) - return - } - - // Конвертируем **текст** в *текст* для Markdown (Legacy) - // Markdown (Legacy) использует одинарную звездочку для жирного текста - // Используем регулярное выражение для замены только парных ** telegramText := regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "*$1*") - log.Printf("Sending Telegram message (converted text length: %d): %s", len(telegramText), telegramText) - msg := tgbotapi.NewMessage(chatID, telegramText) - msg.ParseMode = "Markdown" // Markdown (Legacy) format - - _, err = bot.Send(msg) + msg.ParseMode = "Markdown" + + _, err := a.telegramBot.Send(msg) if err != nil { - log.Printf("ERROR sending Telegram message: %v", err) - } else { - log.Printf("Telegram message sent successfully to chat ID %d", chatID) + // Проверяем, не заблокирован ли бот + if strings.Contains(err.Error(), "blocked") || + strings.Contains(err.Error(), "chat not found") || + strings.Contains(err.Error(), "bot was blocked") { + // Пользователь заблокировал бота - очищаем данные + chatIDStr := strconv.FormatInt(chatID, 10) + a.DB.Exec(` + UPDATE telegram_integrations + SET telegram_user_id = NULL, chat_id = NULL, updated_at = CURRENT_TIMESTAMP + WHERE chat_id = $1 + `, chatIDStr) + log.Printf("User blocked bot, cleared integration for chat_id=%d", chatID) + } + return err } + + log.Printf("Message sent to chat_id=%d", chatID) + return nil +} + +// sendTelegramMessageToUser - отправляет сообщение пользователю по user_id +func (a *App) sendTelegramMessageToUser(userID int, text string) error { + var chatID sql.NullString + err := a.DB.QueryRow(` + SELECT chat_id FROM telegram_integrations + WHERE user_id = $1 AND chat_id IS NOT NULL + `, userID).Scan(&chatID) + + if err == sql.ErrNoRows || !chatID.Valid { + return fmt.Errorf("telegram not connected for user %d", userID) + } + if err != nil { + return err + } + + chatIDInt, err := strconv.ParseInt(chatID.String, 10, 64) + if err != nil { + return fmt.Errorf("invalid chat_id format: %w", err) + } + + return a.sendTelegramMessageToChat(chatIDInt, text) +} + +// getAllUsersWithTelegram - получает список всех user_id с подключенным Telegram +func (a *App) getAllUsersWithTelegram() ([]int, error) { + rows, err := a.DB.Query(` + SELECT user_id FROM telegram_integrations + WHERE chat_id IS NOT NULL AND telegram_user_id IS NOT NULL + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var userIDs []int + for rows.Next() { + var userID int + if err := rows.Scan(&userID); err == nil { + userIDs = append(userIDs, userID) + } + } + return userIDs, nil } // utf16OffsetToUTF8 конвертирует UTF-16 offset в UTF-8 byte offset @@ -3893,8 +4069,10 @@ func (a *App) processMessageInternal(rawText string, sendToTelegram bool, userID } // Отправляем дублирующее сообщение в Telegram только если указано - if sendToTelegram { - a.sendTelegramMessage(rawText) + if sendToTelegram && userID != nil { + if err := a.sendTelegramMessageToUser(*userID, rawText); err != nil { + log.Printf("Error sending Telegram message: %v", err) + } } return response, nil @@ -4180,9 +4358,8 @@ func (a *App) setupWeeklyGoals() error { return nil } -// sendWeeklyGoalsTelegramMessage получает зафиксированные цели и отправляет их в Telegram -func (a *App) sendWeeklyGoalsTelegramMessage() error { - // Получаем цели из базы данных +// getWeeklyGoalsForUser получает цели для конкретного пользователя +func (a *App) getWeeklyGoalsForUser(userID int) ([]WeeklyGoalSetup, error) { selectQuery := ` SELECT p.name AS project_name, @@ -4196,13 +4373,14 @@ func (a *App) sendWeeklyGoalsTelegramMessage() error { wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AND p.deleted = FALSE + AND p.user_id = $1 ORDER BY p.name ` - rows, err := a.DB.Query(selectQuery) + rows, err := a.DB.Query(selectQuery, userID) if err != nil { - return fmt.Errorf("error querying weekly goals: %w", err) + return nil, fmt.Errorf("error querying weekly goals: %w", err) } defer rows.Close() @@ -4224,22 +4402,39 @@ func (a *App) sendWeeklyGoalsTelegramMessage() error { if maxGoalScore.Valid { goal.MaxGoalScore = maxGoalScore.Float64 } else { - // Если maxGoalScore не установлен (NULL), используем NaN для корректной проверки в форматировании goal.MaxGoalScore = math.NaN() } goals = append(goals, goal) } - // Форматируем сообщение - message := a.formatWeeklyGoalsMessage(goals) - if message == "" { - log.Println("No goals to send in Telegram message") - return nil + return goals, nil +} + +// sendWeeklyGoalsTelegramMessage отправляет персональные цели всем пользователям +func (a *App) sendWeeklyGoalsTelegramMessage() error { + userIDs, err := a.getAllUsersWithTelegram() + if err != nil { + return err + } + + for _, userID := range userIDs { + goals, err := a.getWeeklyGoalsForUser(userID) + if err != nil { + log.Printf("Error getting goals for user %d: %v", userID, err) + continue + } + + message := a.formatWeeklyGoalsMessage(goals) + if message == "" { + continue + } + + if err := a.sendTelegramMessageToUser(userID, message); err != nil { + log.Printf("Error sending weekly goals to user %d: %v", userID, err) + } } - // Отправляем сообщение в Telegram - a.sendTelegramMessage(message) return nil } @@ -4999,11 +5194,11 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) { return } - // Находим пользователя по токену из telegram_integrations (используем тот же механизм) + // Находим пользователя по токену из todoist_integrations var userID int err := a.DB.QueryRow(` - SELECT user_id FROM telegram_integrations - WHERE webhook_token = $1 AND user_id IS NOT NULL + SELECT user_id FROM todoist_integrations + WHERE webhook_token = $1 LIMIT 1 `, token).Scan(&userID) @@ -5201,8 +5396,11 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) { // Отправляем сообщение в Telegram после успешной обработки log.Printf("Preparing to send message to Telegram...") log.Printf("Combined text to send: '%s'", combinedText) - a.sendTelegramMessage(combinedText) - log.Printf("sendTelegramMessage call completed") + if err := a.sendTelegramMessageToUser(userID, combinedText); err != nil { + log.Printf("Error sending Telegram message: %v", err) + } else { + log.Printf("sendTelegramMessage call completed") + } } else { log.Printf("No nodes found, skipping Telegram message") } @@ -5218,172 +5416,154 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) { } func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) { - log.Printf("=== Telegram Webhook Request ===") - log.Printf("Method: %s", r.Method) - log.Printf("URL: %s", r.URL.String()) - log.Printf("Path: %s", r.URL.Path) - if r.Method == "OPTIONS" { - log.Printf("OPTIONS request, returning OK") setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) - // Извлекаем токен из URL - vars := mux.Vars(r) - token := vars["token"] - log.Printf("Extracted token from URL: '%s'", token) - if token == "" { - log.Printf("Telegram webhook: missing token in URL") - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]interface{}{ - "ok": false, - "error": "Missing webhook token", - "message": "Token required in URL", - }) - return - } - - // Находим пользователя по токену - var userID int - err := a.DB.QueryRow(` - SELECT user_id FROM telegram_integrations - WHERE webhook_token = $1 AND user_id IS NOT NULL - LIMIT 1 - `, token).Scan(&userID) - - if err == sql.ErrNoRows { - log.Printf("Telegram webhook: invalid token: %s", token) - // Возвращаем 200 OK, но логируем ошибку (не хотим, чтобы Telegram повторял запрос) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]interface{}{ - "ok": false, - "error": "Invalid webhook token", - "message": "Token not found", - }) - return - } else if err != nil { - log.Printf("Error finding user by webhook token: %v", err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]interface{}{ - "ok": false, - "error": "Internal server error", - "message": "Database error", - }) - return - } - - log.Printf("Telegram webhook: token=%s, user_id=%d", token, userID) - // Парсим webhook от Telegram var update TelegramUpdate if err := json.NewDecoder(r.Body).Decode(&update); err != nil { log.Printf("Error decoding Telegram webhook: %v", err) - // Возвращаем 200 OK, чтобы Telegram не повторял запрос w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ - "ok": false, - "error": "Invalid request body", - "message": "Failed to decode webhook", + "ok": false, + "error": "Invalid request body", }) return } - // Определяем, какое сообщение использовать (message или edited_message) + // Определяем сообщение var message *TelegramMessage if update.Message != nil { message = update.Message - log.Printf("Telegram webhook received: update_id=%d, message type=message", update.UpdateID) } else if update.EditedMessage != nil { message = update.EditedMessage - log.Printf("Telegram webhook received: update_id=%d, message type=edited_message", update.UpdateID) } else { - log.Printf("Telegram webhook received: update_id=%d, but no message or edited_message found", update.UpdateID) w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) // Возвращаем 200 OK для Telegram - json.NewEncoder(w).Encode(map[string]interface{}{ - "ok": true, - "message": "No message found in update", - }) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]bool{"ok": true}) return } - log.Printf("Telegram webhook: message present, chat_id=%d, user_id=%d", message.Chat.ID, userID) + if message.From == nil { + log.Printf("Telegram webhook: message without From field") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]bool{"ok": true}) + return + } - // Сохраняем chat_id при первом сообщении (если еще не сохранен) - if message.Chat.ID != 0 { - chatIDStr := strconv.FormatInt(message.Chat.ID, 10) - var existingChatID sql.NullString - err := a.DB.QueryRow(` - SELECT chat_id FROM telegram_integrations - WHERE user_id = $1 - LIMIT 1 - `, userID).Scan(&existingChatID) - - if err == nil && (!existingChatID.Valid || existingChatID.String == "") { - // Сохраняем chat_id, если его еще нет - _, err = a.DB.Exec(` - UPDATE telegram_integrations - SET chat_id = $1 - WHERE user_id = $2 - `, chatIDStr, userID) - if err != nil { - log.Printf("Warning: Failed to save chat_id: %v", err) + telegramUserID := message.From.ID + chatID := message.Chat.ID + chatIDStr := strconv.FormatInt(chatID, 10) + + log.Printf("Telegram webhook: telegram_user_id=%d, chat_id=%d, text=%s", + telegramUserID, chatID, message.Text) + + // Обработка команды /start с токеном + if strings.HasPrefix(message.Text, "/start") { + parts := strings.Fields(message.Text) + if len(parts) > 1 { + startToken := parts[1] + + var userID int + err := a.DB.QueryRow(` + SELECT user_id FROM telegram_integrations + WHERE start_token = $1 + `, startToken).Scan(&userID) + + if err == nil { + // Привязываем Telegram к пользователю + telegramUserIDStr := strconv.FormatInt(telegramUserID, 10) + _, err = a.DB.Exec(` + UPDATE telegram_integrations + SET telegram_user_id = $1, + chat_id = $2, + start_token = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE user_id = $3 + `, telegramUserIDStr, chatIDStr, userID) + + if err != nil { + log.Printf("Error updating telegram integration: %v", err) + } else { + log.Printf("Telegram connected for user_id=%d", userID) + + // Приветственное сообщение + welcomeMsg := "✅ Telegram успешно подключен к Play Life!\n\nТеперь вы будете получать уведомления и отчеты." + if err := a.sendTelegramMessageToChat(chatID, welcomeMsg); err != nil { + log.Printf("Error sending welcome message: %v", err) + } + } } else { - log.Printf("Successfully saved chat_id from first message: %s", chatIDStr) + log.Printf("Invalid start_token: %s", startToken) + a.sendTelegramMessageToChat(chatID, "❌ Неверный токен. Попробуйте получить новую ссылку в приложении.") } + } else { + // /start без токена + a.sendTelegramMessageToChat(chatID, "Привет! Для подключения используйте ссылку из приложения Play Life.") } - } - - userIDPtr := &userID - - // Проверяем, что есть текст в сообщении - if message.Text == "" { - log.Printf("Telegram webhook: no text in message") + w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) // Возвращаем 200 OK для Telegram - json.NewEncoder(w).Encode(map[string]interface{}{ - "ok": true, - "message": "No text in message, ignored", - }) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]bool{"ok": true}) + return + } + + // Обычное сообщение - ищем пользователя по telegram_user_id + var userID int + err := a.DB.QueryRow(` + SELECT user_id FROM telegram_integrations + WHERE telegram_user_id = $1 + `, telegramUserID).Scan(&userID) + + if err == sql.ErrNoRows { + log.Printf("User not found for telegram_user_id=%d", telegramUserID) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]bool{"ok": true}) + return + } else if err != nil { + log.Printf("Error finding user: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + return + } + + // Обновляем chat_id (на случай переподключения) + a.DB.Exec(` + UPDATE telegram_integrations + SET chat_id = $1, updated_at = CURRENT_TIMESTAMP + WHERE user_id = $2 + `, chatIDStr, userID) + + // Обрабатываем сообщение + if message.Text == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]bool{"ok": true}) return } - fullText := message.Text entities := message.Entities if entities == nil { entities = []TelegramEntity{} } - log.Printf("Processing Telegram message: text='%s', entities count=%d, user_id=%d", fullText, len(entities), userID) - - // Обрабатываем сообщение через новую логику (с entities, без отправки обратно в Telegram) - response, err := a.processTelegramMessage(fullText, entities, userIDPtr) + userIDPtr := &userID + response, err := a.processTelegramMessage(message.Text, entities, userIDPtr) if err != nil { - log.Printf("Error processing Telegram message: %v", err) - // Возвращаем 200 OK, чтобы Telegram не повторял запрос - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "ok": false, - "error": err.Error(), - "message": "Error processing message", - }) - return + log.Printf("Error processing message: %v", err) } - log.Printf("Successfully processed Telegram message, found %d nodes", len(response.Nodes)) - w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "ok": true, - "message": "Message processed successfully", - "result": response, + "ok": true, + "result": response, }) } @@ -5470,7 +5650,7 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(statistics) } -// getTelegramIntegrationHandler возвращает текущую telegram интеграцию +// getTelegramIntegrationHandler возвращает текущую telegram интеграцию с deep link func (a *App) getTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) @@ -5491,16 +5671,38 @@ func (a *App) getTelegramIntegrationHandler(w http.ResponseWriter, r *http.Reque return } + // Генерируем start_token если его нет + if integration.StartToken == nil || *integration.StartToken == "" { + token, err := generateWebhookToken() + if err == nil { + _, _ = a.DB.Exec(` + UPDATE telegram_integrations + SET start_token = $1, updated_at = CURRENT_TIMESTAMP + WHERE user_id = $2 + `, token, userID) + integration.StartToken = &token + } + } + + // Формируем deep link + var deepLink string + if a.telegramBotUsername != "" && integration.StartToken != nil { + deepLink = fmt.Sprintf("https://t.me/%s?start=%s", a.telegramBotUsername, *integration.StartToken) + } + + isConnected := integration.ChatID != nil && integration.TelegramUserID != nil + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(integration) + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": integration.ID, + "telegram_user_id": integration.TelegramUserID, + "is_connected": isConnected, + "deep_link": deepLink, + }) } -// TelegramIntegrationUpdateRequest представляет запрос на обновление telegram интеграции -type TelegramIntegrationUpdateRequest struct { - BotToken string `json:"bot_token"` -} - -// updateTelegramIntegrationHandler обновляет bot token для telegram интеграции +// updateTelegramIntegrationHandler больше не используется (bot_token теперь в .env) +// Оставлен для совместимости, возвращает ошибку func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) @@ -5508,54 +5710,8 @@ func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Re return } setCORSHeaders(w) - - userID, ok := getUserIDFromContext(r) - if !ok { - sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) - return - } - - var req TelegramIntegrationUpdateRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.BotToken == "" { - sendErrorWithCORS(w, "bot_token is required", http.StatusBadRequest) - return - } - - if err := a.saveTelegramBotTokenForUser(req.BotToken, userID); err != nil { - sendErrorWithCORS(w, fmt.Sprintf("Failed to save bot token: %v", err), http.StatusInternalServerError) - return - } - - // Получаем обновленную интеграцию с webhook токеном - integration, err := a.getTelegramIntegrationForUser(userID) - if err != nil { - sendErrorWithCORS(w, fmt.Sprintf("Failed to get updated integration: %v", err), http.StatusInternalServerError) - return - } - - // Настраиваем webhook автоматически при сохранении токена - webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "") - log.Printf("Attempting to setup Telegram webhook. WEBHOOK_BASE_URL='%s'", webhookBaseURL) - if webhookBaseURL != "" && integration.WebhookToken != nil && *integration.WebhookToken != "" { - webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram/" + *integration.WebhookToken - log.Printf("Setting up Telegram webhook: URL=%s", webhookURL) - if err := setupTelegramWebhook(req.BotToken, webhookURL); err != nil { - log.Printf("ERROR: Failed to setup Telegram webhook: %v", err) - // Не возвращаем ошибку, так как токен уже сохранен - } else { - log.Printf("SUCCESS: Telegram webhook configured successfully: %s", webhookURL) - } - } else { - log.Printf("WARNING: WEBHOOK_BASE_URL not set or webhook_token missing. Webhook will not be configured automatically.") - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(integration) + + sendErrorWithCORS(w, "Bot token is now configured via TELEGRAM_BOT_TOKEN environment variable", http.StatusBadRequest) } // getTodoistWebhookURLHandler возвращает URL для Todoist webhook @@ -5573,15 +5729,30 @@ func (a *App) getTodoistWebhookURLHandler(w http.ResponseWriter, r *http.Request return } - // Получаем webhook токен для пользователя - integration, err := a.getTelegramIntegrationForUser(userID) - if err != nil { - sendErrorWithCORS(w, fmt.Sprintf("Failed to get telegram integration: %v", err), http.StatusInternalServerError) - return - } - - if integration.WebhookToken == nil || *integration.WebhookToken == "" { - sendErrorWithCORS(w, "Webhook token not available", http.StatusInternalServerError) + // Получаем или создаем интеграцию Todoist + var webhookToken string + err := a.DB.QueryRow(` + SELECT webhook_token FROM todoist_integrations + WHERE user_id = $1 + `, userID).Scan(&webhookToken) + + if err == sql.ErrNoRows { + // Создаем новую интеграцию + webhookToken, err = generateWebhookToken() + if err != nil { + sendErrorWithCORS(w, fmt.Sprintf("Failed to generate token: %v", err), http.StatusInternalServerError) + return + } + _, err = a.DB.Exec(` + INSERT INTO todoist_integrations (user_id, webhook_token) + VALUES ($1, $2) + `, userID, webhookToken) + if err != nil { + sendErrorWithCORS(w, fmt.Sprintf("Failed to create integration: %v", err), http.StatusInternalServerError) + return + } + } else if err != nil { + sendErrorWithCORS(w, fmt.Sprintf("Failed to get integration: %v", err), http.StatusInternalServerError) return } @@ -5592,11 +5763,12 @@ func (a *App) getTodoistWebhookURLHandler(w http.ResponseWriter, r *http.Request return } - webhookURL := strings.TrimRight(baseURL, "/") + "/webhook/todoist/" + *integration.WebhookToken + webhookURL := strings.TrimRight(baseURL, "/") + "/webhook/todoist/" + webhookToken w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ - "webhook_url": webhookURL, + "webhook_url": webhookURL, + "webhook_token": webhookToken, }) } diff --git a/play-life-backend/migrations/012_refactor_telegram_single_bot.sql b/play-life-backend/migrations/012_refactor_telegram_single_bot.sql new file mode 100644 index 0000000..5e9d528 --- /dev/null +++ b/play-life-backend/migrations/012_refactor_telegram_single_bot.sql @@ -0,0 +1,103 @@ +-- Migration: Refactor telegram_integrations for single shared bot +-- and move Todoist webhook_token to separate table + +-- ============================================ +-- 1. Создаем таблицу todoist_integrations +-- ============================================ +CREATE TABLE IF NOT EXISTS todoist_integrations ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + webhook_token VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT todoist_integrations_user_id_unique UNIQUE (user_id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_webhook_token +ON todoist_integrations(webhook_token); + +CREATE INDEX IF NOT EXISTS idx_todoist_integrations_user_id +ON todoist_integrations(user_id); + +COMMENT ON TABLE todoist_integrations IS 'Todoist webhook integration settings per user'; +COMMENT ON COLUMN todoist_integrations.webhook_token IS 'Unique token for Todoist webhook URL'; + +-- ============================================ +-- 2. Мигрируем webhook_token из telegram_integrations в todoist_integrations +-- ============================================ +INSERT INTO todoist_integrations (user_id, webhook_token, created_at, updated_at) +SELECT user_id, webhook_token, COALESCE(created_at, CURRENT_TIMESTAMP), CURRENT_TIMESTAMP +FROM telegram_integrations +WHERE webhook_token IS NOT NULL + AND webhook_token != '' + AND user_id IS NOT NULL +ON CONFLICT (user_id) DO NOTHING; + +-- ============================================ +-- 3. Модифицируем telegram_integrations +-- ============================================ + +-- Удаляем bot_token (будет в .env) +ALTER TABLE telegram_integrations +DROP COLUMN IF EXISTS bot_token; + +-- Удаляем webhook_token (перенесли в todoist_integrations) +ALTER TABLE telegram_integrations +DROP COLUMN IF EXISTS webhook_token; + +-- Добавляем telegram_user_id +ALTER TABLE telegram_integrations +ADD COLUMN IF NOT EXISTS telegram_user_id BIGINT; + +-- Добавляем start_token для deep links +ALTER TABLE telegram_integrations +ADD COLUMN IF NOT EXISTS start_token VARCHAR(255); + +-- Добавляем timestamps если их нет +ALTER TABLE telegram_integrations +ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + +ALTER TABLE telegram_integrations +ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + +-- ============================================ +-- 4. Создаем индексы +-- ============================================ +CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_start_token +ON telegram_integrations(start_token) +WHERE start_token IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_telegram_user_id +ON telegram_integrations(telegram_user_id) +WHERE telegram_user_id IS NOT NULL; + +-- Уникальность user_id +DROP INDEX IF EXISTS idx_telegram_integrations_user_id; +CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_user_id_unique +ON telegram_integrations(user_id) +WHERE user_id IS NOT NULL; + +-- Индекс для поиска по chat_id +CREATE INDEX IF NOT EXISTS idx_telegram_integrations_chat_id +ON telegram_integrations(chat_id) +WHERE chat_id IS NOT NULL; + +-- Удаляем старый индекс webhook_token +DROP INDEX IF EXISTS idx_telegram_integrations_webhook_token; + +-- ============================================ +-- 5. Очищаем данные Telegram для переподключения +-- ============================================ +UPDATE telegram_integrations +SET chat_id = NULL, + telegram_user_id = NULL, + start_token = NULL, + updated_at = CURRENT_TIMESTAMP; + +-- ============================================ +-- Комментарии +-- ============================================ +COMMENT ON COLUMN telegram_integrations.telegram_user_id IS 'Telegram user ID (message.from.id)'; +COMMENT ON COLUMN telegram_integrations.chat_id IS 'Telegram chat ID для отправки сообщений'; +COMMENT ON COLUMN telegram_integrations.start_token IS 'Временный токен для deep link при первом подключении'; + diff --git a/play-life-web/src/components/TelegramIntegration.jsx b/play-life-web/src/components/TelegramIntegration.jsx index fbccc5c..f4f7537 100644 --- a/play-life-web/src/components/TelegramIntegration.jsx +++ b/play-life-web/src/components/TelegramIntegration.jsx @@ -4,12 +4,9 @@ import './Integrations.css' function TelegramIntegration({ onBack }) { const { authFetch } = useAuth() - const [botToken, setBotToken] = useState('') - const [chatId, setChatId] = useState('') + const [integration, setIntegration] = useState(null) const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) const [error, setError] = useState('') - const [success, setSuccess] = useState('') useEffect(() => { fetchIntegration() @@ -23,8 +20,7 @@ function TelegramIntegration({ onBack }) { throw new Error('Ошибка при загрузке интеграции') } const data = await response.json() - setBotToken(data.bot_token || '') - setChatId(data.chat_id || '') + setIntegration(data) } catch (error) { console.error('Error fetching integration:', error) setError('Не удалось загрузить данные интеграции') @@ -33,40 +29,22 @@ function TelegramIntegration({ onBack }) { } } - const handleSave = async () => { - if (!botToken.trim()) { - setError('Bot Token обязателен для заполнения') - return + const handleOpenBot = () => { + if (integration?.deep_link) { + window.open(integration.deep_link, '_blank') } + } - try { - setSaving(true) - setError('') - setSuccess('') + const handleRefresh = () => { + fetchIntegration() + } - const response = await authFetch('/api/integrations/telegram', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ bot_token: botToken }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Ошибка при сохранении') - } - - const data = await response.json() - setBotToken(data.bot_token || '') - setChatId(data.chat_id || '') - setSuccess('Bot Token успешно сохранен!') - } catch (error) { - console.error('Error saving integration:', error) - setError(error.message || 'Не удалось сохранить Bot Token') - } finally { - setSaving(false) - } + if (loading) { + return ( +
+
Загрузка...
+
+ ) } return ( @@ -77,96 +55,77 @@ function TelegramIntegration({ onBack }) {

Telegram интеграция

- {loading ? ( -
Загрузка...
- ) : ( - <> -
-

Настройки

+ {error && ( +
+ {error} +
+ )} -
- - setBotToken(e.target.value)} - placeholder="Введите Bot Token" - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" - /> +
+

Статус подключения

+ + {integration?.is_connected ? ( +
+
+
+ + Telegram подключен +
- - {chatId && ( -
- - -
- )} - - {error && ( -
- {error} -
- )} - - {success && ( -
- {success} + + {integration.telegram_user_id && ( +
+ Telegram ID: {integration.telegram_user_id}
)}
+ ) : ( +
+
+
+ + Telegram не подключен +
+

+ Нажмите кнопку ниже и отправьте команду /start в боте +

+
-
-

- Откуда взять Bot Token -

-
    -
  1. Откройте Telegram и найдите бота @BotFather
  2. -
  3. Отправьте команду /newbot
  4. -
  5. Следуйте инструкциям для создания нового бота
  6. -
  7. - После создания бота BotFather предоставит вам Bot Token -
  8. -
  9. Скопируйте токен и вставьте его в поле выше
  10. -
-
+ -
-

- Что нужно сделать после сохранения Bot Token -

-
    -
  1. После сохранения Bot Token отправьте первое сообщение вашему боту в Telegram
  2. -
  3. - Chat ID будет автоматически сохранен после обработки первого - сообщения -
  4. -
  5. - После этого бот сможет отправлять вам ответные сообщения -
  6. -
+
- - )} + )} +
+ +
+

Инструкция

+
    +
  1. Нажмите кнопку "Подключить Telegram"
  2. +
  3. В открывшемся Telegram нажмите "Start" или отправьте /start
  4. +
  5. Вернитесь сюда и нажмите "Проверить подключение"
  6. +
+
) } export default TelegramIntegration -