From 7398918bc0c34f410e7f405139b87f10307d8354 Mon Sep 17 00:00:00 2001 From: poignatov Date: Wed, 31 Dec 2025 19:11:28 +0300 Subject: [PATCH] Release v1.1.0: Add Telegram and Todoist integrations UI - Add telegram_integrations table to store bot token and chat_id - Add Integrations tab with Todoist and Telegram integration screens - Remove TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID from env variables - All Telegram configuration now done through UI - Telegram webhook registration happens when user saves bot token - Rename TELEGRAM_WEBHOOK_BASE_URL to WEBHOOK_BASE_URL --- BUILD_INSTRUCTIONS.md | 5 +- ENV_SETUP.md | 25 +- VERSION | 2 +- database-dumps/README.md | 13 +- docker-compose.yml | 2 - dump-db.sh | 8 +- env.example | 11 +- list-dumps.sh | 6 +- play-life-backend/ENV_SETUP.md | 5 +- play-life-backend/docker-compose.yml | 2 - play-life-backend/env.example | 7 +- play-life-backend/main.go | 337 +++++++++++++++--- .../008_add_telegram_integrations.sql | 16 + play-life-web/package.json | 2 +- play-life-web/src/App.jsx | 32 +- play-life-web/src/components/Integrations.css | 25 ++ play-life-web/src/components/Integrations.jsx | 57 +++ .../src/components/TelegramIntegration.jsx | 170 +++++++++ .../src/components/TodoistIntegration.jsx | 90 +++++ restore-db.sh | 6 +- 20 files changed, 721 insertions(+), 100 deletions(-) create mode 100644 play-life-backend/migrations/008_add_telegram_integrations.sql create mode 100644 play-life-web/src/components/Integrations.css create mode 100644 play-life-web/src/components/Integrations.jsx create mode 100644 play-life-web/src/components/TelegramIntegration.jsx create mode 100644 play-life-web/src/components/TodoistIntegration.jsx diff --git a/BUILD_INSTRUCTIONS.md b/BUILD_INSTRUCTIONS.md index b1d3f16..940f3c4 100644 --- a/BUILD_INSTRUCTIONS.md +++ b/BUILD_INSTRUCTIONS.md @@ -56,9 +56,8 @@ docker run -d \ - `DB_USER` - пользователь БД - `DB_PASSWORD` - пароль БД - `DB_NAME` - имя БД -- `TELEGRAM_BOT_TOKEN` - токен Telegram бота (опционально) -- `TELEGRAM_CHAT_ID` - ID чата Telegram (опционально) -- `TELEGRAM_WEBHOOK_BASE_URL` - базовый URL для webhook (опционально) +- `WEBHOOK_BASE_URL` - базовый URL для webhook (опционально) + - Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram" - `TODOIST_WEBHOOK_SECRET` - секрет для Todoist webhook (опционально) **Важно:** Backend внутри контейнера всегда работает на порту 8080. Nginx проксирует запросы с порта 80 на backend. diff --git a/ENV_SETUP.md b/ENV_SETUP.md index 81c17e7..4628061 100644 --- a/ENV_SETUP.md +++ b/ENV_SETUP.md @@ -38,10 +38,9 @@ **Примечание:** API запросы автоматически проксируются к бэкенду. В development режиме Vite проксирует запросы к `http://localhost:8080`. В production nginx проксирует запросы к бэкенд контейнеру. Не требуется настройка `VITE_API_BASE_URL`. -### Telegram Bot Configuration (опционально) -- `TELEGRAM_BOT_TOKEN` - токен бота от @BotFather -- `TELEGRAM_CHAT_ID` - ID чата для отправки сообщений -- `TELEGRAM_WEBHOOK_BASE_URL` - базовый URL для автоматической настройки webhook. Webhook будет настроен автоматически при старте сервера на `/webhook/telegram`. Если не указан, webhook нужно настраивать вручную. +### Telegram Bot Configuration +- `WEBHOOK_BASE_URL` - базовый URL для автоматической настройки webhook. Webhook будет настроен автоматически при сохранении bot token через UI на `/webhook/telegram`. +- Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram" **Примеры значений:** - Production с HTTPS: `https://your-domain.com` (порт не нужен для стандартных 80/443) @@ -213,12 +212,14 @@ WEB_PORT=4001 # для production Docker контейнера #### Автоматическая настройка (рекомендуется) 1. Создайте бота через [@BotFather](https://t.me/botfather) в Telegram -2. Получите токен бота и добавьте его в `.env`: +2. Получите токен бота +3. Добавьте `WEBHOOK_BASE_URL` в `.env`: ```bash - TELEGRAM_BOT_TOKEN=your_bot_token_here - TELEGRAM_CHAT_ID=123456789 - TELEGRAM_WEBHOOK_BASE_URL=https://your-domain.com + WEBHOOK_BASE_URL=https://your-domain.com ``` +4. Откройте приложение и перейдите в раздел "Интеграции" -> "Telegram" +5. Введите Bot Token в поле и нажмите "Сохранить" +6. Отправьте первое сообщение боту в Telegram - Chat ID будет сохранён автоматически **Важно о портах:** - Если сервер доступен на стандартных портах (HTTP 80 или HTTPS 443), порт можно не указывать @@ -231,8 +232,8 @@ WEB_PORT=4001 # для production Docker контейнера ```bash # Установите ngrok: https://ngrok.com/ ngrok http 8080 - # Используйте полученный URL в TELEGRAM_WEBHOOK_BASE_URL (без порта) - # Например: TELEGRAM_WEBHOOK_BASE_URL=https://abc123.ngrok.io + # Используйте полученный URL в WEBHOOK_BASE_URL (без порта) + # Например: WEBHOOK_BASE_URL=https://abc123.ngrok.io ``` 4. Проверьте логи сервера - должно появиться сообщение: @@ -240,9 +241,9 @@ WEB_PORT=4001 # для production Docker контейнера Telegram webhook configured successfully: https://abc123.ngrok.io/webhook/telegram ``` -#### Ручная настройка (если не указан TELEGRAM_WEBHOOK_BASE_URL) +#### Ручная настройка (если не указан WEBHOOK_BASE_URL) -Если вы не указали `TELEGRAM_WEBHOOK_BASE_URL`, webhook нужно настроить вручную: +Если вы не указали `WEBHOOK_BASE_URL`, webhook нужно настроить вручную: ```bash curl -X POST "https://api.telegram.org/bot/setWebhook" \ diff --git a/VERSION b/VERSION index 658aed9..653f458 100644 --- a/VERSION +++ b/VERSION @@ -1,2 +1,2 @@ -1.0.1 +1.1.0 diff --git a/database-dumps/README.md b/database-dumps/README.md index b658108..34579df 100644 --- a/database-dumps/README.md +++ b/database-dumps/README.md @@ -7,15 +7,12 @@ ### Создание дампа ```bash -# Дамп из production БД (по умолчанию .env.prod) +# Дамп из БД (по умолчанию .env) ./dump-db.sh # Дамп с именем ./dump-db.sh production-backup -# Дамп из локальной БД -./dump-db.sh --env-file .env.local - # Дамп из другого окружения ./dump-db.sh --env-file .env.prod my-backup ``` @@ -29,10 +26,10 @@ ### Восстановление дампа ```bash -# Восстановление в локальную БД (по умолчанию .env.local) +# Восстановление в БД (по умолчанию .env) ./restore-db.sh dump_20240101_120000.sql.gz -# Восстановление в production БД +# Восстановление в другое окружение ./restore-db.sh --env-file .env.prod dump_20240101_120000.sql.gz # Можно указать имя без расширения @@ -41,8 +38,8 @@ ## Поведение по умолчанию -- **Создание дампа**: использует `.env.prod` (production БД) -- **Восстановление**: использует `.env.local` (локальная БД) +- **Создание дампа**: использует `.env` +- **Восстановление**: использует `.env` Это можно изменить с помощью параметра `--env-file`. diff --git a/docker-compose.yml b/docker-compose.yml index 756c8a2..2ff1f66 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,8 +36,6 @@ services: DB_PASSWORD: ${DB_PASSWORD:-playeng} DB_NAME: ${DB_NAME:-playeng} PORT: ${PORT:-8080} - TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} - TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} depends_on: db: condition: service_healthy diff --git a/dump-db.sh b/dump-db.sh index 2a01d67..314e9a6 100755 --- a/dump-db.sh +++ b/dump-db.sh @@ -2,14 +2,14 @@ # Скрипт для создания дампа базы данных # Использование: -# ./dump-db.sh [имя_дампа] # Дамп из .env.prod -# ./dump-db.sh --env-file .env.local [имя] # Дамп из указанного файла -# ./dump-db.sh production-backup # Именованный дамп из .env.prod +# ./dump-db.sh [имя_дампа] # Дамп из .env +# ./dump-db.sh --env-file .env.prod [имя] # Дамп из указанного файла +# ./dump-db.sh production-backup # Именованный дамп из .env set -e # Значения по умолчанию -DEFAULT_ENV_FILE=".env.prod" +DEFAULT_ENV_FILE=".env" ENV_FILE="$DEFAULT_ENV_FILE" DUMP_NAME="" diff --git a/env.example b/env.example index 855e3e0..93a8ab2 100644 --- a/env.example +++ b/env.example @@ -26,21 +26,18 @@ PORT=8080 WEB_PORT=3001 # ============================================ -# Telegram Bot Configuration (optional) +# Telegram Bot Configuration # ============================================ +# Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram" # Get token from @BotFather in Telegram: https://t.me/botfather -# To get chat ID: send a message to your bot, then visit: https://api.telegram.org/bot/getUpdates -# Look for "chat":{"id":123456789} - that number is your chat ID -TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here -TELEGRAM_CHAT_ID=123456789 # Base URL для автоматической настройки webhook # Примеры: # - Для production с HTTPS: https://your-domain.com # - Для локальной разработки с ngrok: https://abc123.ngrok.io # - Для прямого доступа на нестандартном порту: http://your-server:8080 -# Webhook будет настроен автоматически при старте сервера на: /webhook/telegram +# Webhook будет настроен автоматически при старте сервера на: /webhook/telegram # Если не указан, webhook нужно настраивать вручную -TELEGRAM_WEBHOOK_BASE_URL=https://your-domain.com +WEBHOOK_BASE_URL=https://your-domain.com # ============================================ # Todoist Webhook Configuration (optional) diff --git a/list-dumps.sh b/list-dumps.sh index 3e736f5..a07eac8 100755 --- a/list-dumps.sh +++ b/list-dumps.sh @@ -23,13 +23,13 @@ if ls "$DUMP_DIR"/*.sql.gz 2>/dev/null | grep -q .; then echo "Всего дампов: $(ls -1 "$DUMP_DIR"/*.sql.gz 2>/dev/null | wc -l | tr -d ' ')" echo "" echo "Для восстановления используйте:" - echo " ./restore-db.sh <имя_дампа.sql.gz> # В .env.local" + echo " ./restore-db.sh <имя_дампа.sql.gz> # В .env" echo " ./restore-db.sh --env-file .env.prod <имя_дампа> # В указанный файл" else echo " (нет дампов)" echo "" echo "Для создания дампа используйте:" - echo " ./dump-db.sh # Из .env.prod" - echo " ./dump-db.sh --env-file .env.local [имя] # Из указанного файла" + echo " ./dump-db.sh # Из .env" + echo " ./dump-db.sh --env-file .env.prod [имя] # Из указанного файла" fi diff --git a/play-life-backend/ENV_SETUP.md b/play-life-backend/ENV_SETUP.md index 129b945..a6c917b 100644 --- a/play-life-backend/ENV_SETUP.md +++ b/play-life-backend/ENV_SETUP.md @@ -29,8 +29,8 @@ ### Опциональные (для Telegram интеграции) -- `TELEGRAM_BOT_TOKEN` - токен бота от @BotFather -- `TELEGRAM_CHAT_ID` - ID чата для отправки сообщений +- `WEBHOOK_BASE_URL` - базовый URL для автоматической настройки webhook +- Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram" ## Использование в коде @@ -41,7 +41,6 @@ ### Вариант 1: Установить переменные вручную ```bash export DB_PASSWORD=your_password -export TELEGRAM_BOT_TOKEN=your_token go run main.go ``` diff --git a/play-life-backend/docker-compose.yml b/play-life-backend/docker-compose.yml index bdd5ad1..060fc1c 100644 --- a/play-life-backend/docker-compose.yml +++ b/play-life-backend/docker-compose.yml @@ -30,8 +30,6 @@ services: DB_PASSWORD: ${DB_PASSWORD:-playeng} DB_NAME: ${DB_NAME:-playeng} PORT: ${PORT:-8080} - TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} - TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} depends_on: db: condition: service_healthy diff --git a/play-life-backend/env.example b/play-life-backend/env.example index 54041eb..fa5a06c 100644 --- a/play-life-backend/env.example +++ b/play-life-backend/env.example @@ -8,12 +8,9 @@ DB_NAME=playeng # Server Configuration PORT=8080 -# Telegram Bot Configuration (optional - for direct Telegram integration) +# Telegram Bot Configuration +# Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram" # Get token from @BotFather in Telegram: https://t.me/botfather -# To get chat ID: send a message to your bot, then visit: https://api.telegram.org/bot/getUpdates -# Look for "chat":{"id":123456789} - that number is your chat ID -TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here -TELEGRAM_CHAT_ID=123456789 # Scheduler Configuration # Часовой пояс для планировщика (формат IANA: Europe/Moscow, America/New_York и т.д.) diff --git a/play-life-backend/main.go b/play-life-backend/main.go index a8e3872..2ede70a 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -166,9 +166,14 @@ type TelegramEntity struct { Length int `json:"length"` } +type TelegramChat struct { + ID int64 `json:"id"` +} + type TelegramMessage struct { Text string `json:"text"` Entities []TelegramEntity `json:"entities"` + Chat TelegramChat `json:"chat"` } type TelegramWebhook struct { @@ -1947,6 +1952,18 @@ func (a *App) initPlayLifeDB() error { log.Printf("Warning: Failed to create materialized view index: %v", err) } + // Создаем таблицу telegram_integrations + createTelegramIntegrationsTable := ` + CREATE TABLE IF NOT EXISTS telegram_integrations ( + id SERIAL PRIMARY KEY, + chat_id VARCHAR(255), + bot_token VARCHAR(255) + ) + ` + if _, err := a.DB.Exec(createTelegramIntegrationsTable); err != nil { + return fmt.Errorf("failed to create telegram_integrations table: %w", err) + } + return nil } @@ -2346,49 +2363,31 @@ func main() { log.Printf("Successfully connected to database: %s@%s:%s/%s", dbUser, dbHost, dbPort, dbName) defer db.Close() - // Инициализируем Telegram бота (если токен указан) - var telegramBot *tgbotapi.BotAPI - var telegramChatID int64 - telegramToken := getEnv("TELEGRAM_BOT_TOKEN", "") - telegramChatIDStr := getEnv("TELEGRAM_CHAT_ID", "") - telegramWebhookBaseURL := getEnv("TELEGRAM_WEBHOOK_BASE_URL", "") + // Telegram бот теперь загружается из БД при необходимости + // Webhook будет настроен автоматически при сохранении bot token через UI - if telegramToken != "" && telegramChatIDStr != "" { - bot, err := tgbotapi.NewBotAPI(telegramToken) - if err != nil { - log.Printf("Warning: Failed to initialize Telegram bot: %v. Telegram notifications will be disabled.", err) - } else { - telegramBot = bot - chatID, err := strconv.ParseInt(telegramChatIDStr, 10, 64) - if err != nil { - log.Printf("Warning: Invalid TELEGRAM_CHAT_ID format: %v. Telegram notifications will be disabled.", err) - telegramBot = nil - } else { - telegramChatID = chatID - log.Printf("Telegram bot initialized successfully. Chat ID: %d", telegramChatID) - } - } - } else { - log.Println("Telegram bot token or chat ID not provided. Telegram notifications disabled.") - } - - // Настраиваем webhook для Telegram (если указан base URL) - if telegramToken != "" && telegramWebhookBaseURL != "" { - webhookURL := strings.TrimRight(telegramWebhookBaseURL, "/") + "/webhook/telegram" - if err := setupTelegramWebhook(telegramToken, webhookURL); err != nil { - log.Printf("Warning: Failed to setup Telegram webhook: %v. Webhook will not be configured.", err) - } else { - log.Printf("Telegram webhook configured successfully: %s", webhookURL) - } - } else if telegramToken != "" { - log.Println("TELEGRAM_WEBHOOK_BASE_URL not provided. Telegram webhook will not be configured automatically.") - } - app := &App{ DB: db, lastWebhookTime: make(map[int]time.Time), - telegramBot: telegramBot, - telegramChatID: telegramChatID, + telegramBot: nil, // Больше не используем глобальный bot + telegramChatID: 0, // Больше не используем глобальный chat_id + } + + // Пытаемся настроить webhook автоматически при старте, если есть base URL и bot token в БД + // Это опционально - основная регистрация происходит при сохранении токена через UI + webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "") + if webhookBaseURL != "" { + integration, err := app.getTelegramIntegration() + if err == nil && integration.BotToken != nil && *integration.BotToken != "" { + webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram" + if err := setupTelegramWebhook(*integration.BotToken, webhookURL); err != nil { + log.Printf("Warning: Failed to setup Telegram webhook at startup: %v. Webhook will be configured when user saves bot token.", err) + } else { + log.Printf("Telegram webhook configured successfully at startup: %s", webhookURL) + } + } else { + log.Printf("Telegram bot token not found in database. Webhook will be configured when user saves bot token.") + } } // Инициализируем БД для play-life проекта @@ -2440,6 +2439,9 @@ func main() { r.HandleFunc("/admin", app.adminHandler).Methods("GET") r.HandleFunc("/admin.html", app.adminHandler).Methods("GET") r.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS") + r.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS") + r.HandleFunc("/api/integrations/telegram", app.updateTelegramIntegrationHandler).Methods("POST", "OPTIONS") + r.HandleFunc("/api/integrations/todoist/webhook-url", app.getTodoistWebhookURLHandler).Methods("GET", "OPTIONS") port := getEnv("PORT", "8080") log.Printf("Server starting on port %s", port) @@ -2529,13 +2531,145 @@ func roundToFourDecimals(val float64) float64 { return float64(int(val*10000+0.5)) / 10000.0 } +// TelegramIntegration представляет запись из таблицы telegram_integrations +type TelegramIntegration struct { + ID int `json:"id"` + ChatID *string `json:"chat_id"` + BotToken *string `json:"bot_token"` +} + +// getTelegramIntegration получает telegram интеграцию из БД +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) + } + + 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 +} + +// 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)) - log.Printf("Telegram bot status: bot=%v, chatID=%d", a.telegramBot != nil, a.telegramChatID) - if a.telegramBot == nil || a.telegramChatID == 0 { + // Получаем 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 initialized (bot=%v, chatID=%d), skipping message send", a.telegramBot != nil, a.telegramChatID) + log.Printf("WARNING: Telegram bot not configured (bot=%v, chatID=%d), skipping message send", bot != nil, chatID) return } @@ -2545,14 +2679,14 @@ func (a *App) sendTelegramMessage(text string) { telegramText := regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "*$1*") log.Printf("Sending Telegram message (converted text length: %d): %s", len(telegramText), telegramText) - msg := tgbotapi.NewMessage(a.telegramChatID, telegramText) + msg := tgbotapi.NewMessage(chatID, telegramText) msg.ParseMode = "Markdown" // Markdown (Legacy) format - _, err := a.telegramBot.Send(msg) + _, err = bot.Send(msg) if err != nil { log.Printf("ERROR sending Telegram message: %v", err) } else { - log.Printf("Telegram message sent successfully to chat ID %d", a.telegramChatID) + log.Printf("Telegram message sent successfully to chat ID %d", chatID) } } @@ -3966,6 +4100,22 @@ func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) { return } + // Сохраняем chat_id при первом сообщении + if update.Message.Chat.ID != 0 { + chatIDStr := strconv.FormatInt(update.Message.Chat.ID, 10) + integration, err := a.getTelegramIntegration() + if err == nil { + // Сохраняем chat_id, если его еще нет + if integration.ChatID == nil || *integration.ChatID == "" { + if err := a.saveTelegramChatID(chatIDStr); err != nil { + log.Printf("Warning: Failed to save chat_id: %v", err) + } else { + log.Printf("Saved chat_id from first message: %s", chatIDStr) + } + } + } + } + // Проверяем, что есть message if update.Message.Text == "" { log.Printf("Telegram webhook: no text in message") @@ -4077,3 +4227,100 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(statistics) } +// getTelegramIntegrationHandler возвращает текущую telegram интеграцию +func (a *App) getTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + integration, err := a.getTelegramIntegration() + if err != nil { + sendErrorWithCORS(w, fmt.Sprintf("Failed to get telegram integration: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(integration) +} + +// TelegramIntegrationUpdateRequest представляет запрос на обновление telegram интеграции +type TelegramIntegrationUpdateRequest struct { + BotToken string `json:"bot_token"` +} + +// updateTelegramIntegrationHandler обновляет bot token для telegram интеграции +func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + 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.saveTelegramBotToken(req.BotToken); err != nil { + sendErrorWithCORS(w, fmt.Sprintf("Failed to save bot token: %v", err), http.StatusInternalServerError) + return + } + + // Настраиваем webhook автоматически при сохранении токена + webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "") + if webhookBaseURL != "" { + webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram" + if err := setupTelegramWebhook(req.BotToken, webhookURL); err != nil { + log.Printf("Warning: Failed to setup Telegram webhook: %v", err) + // Не возвращаем ошибку, так как токен уже сохранен + } else { + log.Printf("Telegram webhook configured successfully: %s", webhookURL) + } + } else { + log.Printf("Warning: WEBHOOK_BASE_URL not set. Webhook will not be configured automatically.") + } + + integration, err := a.getTelegramIntegration() + if err != nil { + sendErrorWithCORS(w, fmt.Sprintf("Failed to get updated integration: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(integration) +} + +// getTodoistWebhookURLHandler возвращает URL для Todoist webhook +func (a *App) getTodoistWebhookURLHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + // Получаем base URL из env + baseURL := getEnv("WEBHOOK_BASE_URL", "") + if baseURL == "" { + sendErrorWithCORS(w, "WEBHOOK_BASE_URL not configured", http.StatusInternalServerError) + return + } + + webhookURL := strings.TrimRight(baseURL, "/") + "/webhook/todoist" + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "webhook_url": webhookURL, + }) +} + diff --git a/play-life-backend/migrations/008_add_telegram_integrations.sql b/play-life-backend/migrations/008_add_telegram_integrations.sql new file mode 100644 index 0000000..ac807a2 --- /dev/null +++ b/play-life-backend/migrations/008_add_telegram_integrations.sql @@ -0,0 +1,16 @@ +-- Migration: Add telegram_integrations table +-- This script creates a table to store Telegram bot tokens and chat IDs + +-- Create telegram_integrations table +CREATE TABLE IF NOT EXISTS telegram_integrations ( + id SERIAL PRIMARY KEY, + chat_id VARCHAR(255), + bot_token VARCHAR(255) +); + +-- Add comment for documentation +COMMENT ON TABLE telegram_integrations IS 'Stores Telegram bot tokens and chat IDs for integrations'; +COMMENT ON COLUMN telegram_integrations.id IS 'Auto-increment primary key'; +COMMENT ON COLUMN telegram_integrations.chat_id IS 'Telegram chat ID (nullable, set automatically after first message)'; +COMMENT ON COLUMN telegram_integrations.bot_token IS 'Telegram bot token (nullable, set by user)'; + diff --git a/play-life-web/package.json b/play-life-web/package.json index 2c5c3f9..c3203e5 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "1.0.0", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index 3090bc3..77ac7d0 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -7,6 +7,7 @@ import AddWords from './components/AddWords' import TestConfigSelection from './components/TestConfigSelection' import AddConfig from './components/AddConfig' import TestWords from './components/TestWords' +import Integrations from './components/Integrations' // API endpoints (используем относительные пути, проксирование настроено в nginx/vite) const CURRENT_WEEK_API_URL = '/playlife-feed' @@ -24,6 +25,7 @@ function App() { 'test-config': false, 'add-config': false, test: false, + integrations: false, }) // Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок) @@ -36,6 +38,7 @@ function App() { 'test-config': false, 'add-config': false, test: false, + integrations: false, }) // Параметры для навигации между вкладками @@ -74,7 +77,7 @@ function App() { try { const savedTab = window.localStorage?.getItem('activeTab') - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test'] + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test', 'integrations'] if (savedTab && validTabs.includes(savedTab)) { setActiveTab(savedTab) setLoadedTabs(prev => ({ ...prev, [savedTab]: true })) @@ -184,6 +187,7 @@ function App() { 'test-config': false, 'add-config': false, test: false, + integrations: false, }) // Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback) @@ -471,6 +475,12 @@ function App() { /> )} + + {loadedTabs.integrations && ( +
+ +
+ )} @@ -519,6 +529,26 @@ function App() {
)} + )} diff --git a/play-life-web/src/components/Integrations.css b/play-life-web/src/components/Integrations.css new file mode 100644 index 0000000..7bfabf6 --- /dev/null +++ b/play-life-web/src/components/Integrations.css @@ -0,0 +1,25 @@ +.close-x-button { + position: fixed; + top: 1rem; + right: 1rem; + background: rgba(255, 255, 255, 0.9); + border: none; + font-size: 1.5rem; + color: #7f8c8d; + cursor: pointer; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background-color 0.2s, color 0.2s; + z-index: 1600; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.close-x-button:hover { + background-color: #ffffff; + color: #2c3e50; +} + diff --git a/play-life-web/src/components/Integrations.jsx b/play-life-web/src/components/Integrations.jsx new file mode 100644 index 0000000..9c1fd2f --- /dev/null +++ b/play-life-web/src/components/Integrations.jsx @@ -0,0 +1,57 @@ +import React, { useState } from 'react' +import TodoistIntegration from './TodoistIntegration' +import TelegramIntegration from './TelegramIntegration' + +function Integrations({ onNavigate }) { + const [selectedIntegration, setSelectedIntegration] = useState(null) + + const integrations = [ + { id: 'todoist', name: 'TODOist' }, + { id: 'telegram', name: 'Telegram' }, + ] + + if (selectedIntegration) { + if (selectedIntegration === 'todoist') { + return setSelectedIntegration(null)} /> + } else if (selectedIntegration === 'telegram') { + return setSelectedIntegration(null)} /> + } + } + + return ( +
+

Интеграции

+
+ {integrations.map((integration) => ( + + ))} +
+
+ ) +} + +export default Integrations + diff --git a/play-life-web/src/components/TelegramIntegration.jsx b/play-life-web/src/components/TelegramIntegration.jsx new file mode 100644 index 0000000..fd60cf8 --- /dev/null +++ b/play-life-web/src/components/TelegramIntegration.jsx @@ -0,0 +1,170 @@ +import React, { useState, useEffect } from 'react' +import './Integrations.css' + +function TelegramIntegration({ onBack }) { + const [botToken, setBotToken] = useState('') + const [chatId, setChatId] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + + useEffect(() => { + fetchIntegration() + }, []) + + const fetchIntegration = async () => { + try { + setLoading(true) + const response = await fetch('/api/integrations/telegram') + if (!response.ok) { + throw new Error('Ошибка при загрузке интеграции') + } + const data = await response.json() + setBotToken(data.bot_token || '') + setChatId(data.chat_id || '') + } catch (error) { + console.error('Error fetching integration:', error) + setError('Не удалось загрузить данные интеграции') + } finally { + setLoading(false) + } + } + + const handleSave = async () => { + if (!botToken.trim()) { + setError('Bot Token обязателен для заполнения') + return + } + + try { + setSaving(true) + setError('') + setSuccess('') + + const response = await fetch('/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) + } + } + + return ( +
+ + +

Telegram интеграция

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

Настройки

+ +
+ + 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" + /> +
+ + {chatId && ( +
+ + +
+ )} + + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + + +
+ +
+

+ Откуда взять 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. +
+
+ + )} +
+ ) +} + +export default TelegramIntegration + diff --git a/play-life-web/src/components/TodoistIntegration.jsx b/play-life-web/src/components/TodoistIntegration.jsx new file mode 100644 index 0000000..f11b96f --- /dev/null +++ b/play-life-web/src/components/TodoistIntegration.jsx @@ -0,0 +1,90 @@ +import React, { useState, useEffect } from 'react' +import './Integrations.css' + +function TodoistIntegration({ onBack }) { + const [webhookURL, setWebhookURL] = useState('') + const [loading, setLoading] = useState(true) + const [copied, setCopied] = useState(false) + + useEffect(() => { + fetchWebhookURL() + }, []) + + const fetchWebhookURL = async () => { + try { + setLoading(true) + const response = await fetch('/api/integrations/todoist/webhook-url') + if (!response.ok) { + throw new Error('Ошибка при загрузке URL webhook') + } + const data = await response.json() + setWebhookURL(data.webhook_url) + } catch (error) { + console.error('Error fetching webhook URL:', error) + } finally { + setLoading(false) + } + } + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(webhookURL) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (error) { + console.error('Error copying to clipboard:', error) + } + } + + return ( +
+ + +

TODOist интеграция

+ +
+

Webhook URL

+ {loading ? ( +
Загрузка...
+ ) : ( +
+ + +
+ )} +
+ +
+

+ Как использовать в приложении TODOist +

+
    +
  1. Откройте приложение TODOist на вашем устройстве
  2. +
  3. Перейдите в настройки проекта или задачи
  4. +
  5. Найдите раздел "Интеграции" или "Webhooks"
  6. +
  7. Вставьте скопированный URL webhook в соответствующее поле
  8. +
  9. Сохраните настройки
  10. +
  11. + Теперь при закрытии задач в TODOist они будут автоматически + обрабатываться системой +
  12. +
+
+
+ ) +} + +export default TodoistIntegration + diff --git a/restore-db.sh b/restore-db.sh index 5496223..fb374b8 100755 --- a/restore-db.sh +++ b/restore-db.sh @@ -2,14 +2,14 @@ # Скрипт для восстановления базы данных из дампа # Использование: -# ./restore-db.sh [имя_дампа.sql.gz] # Восстановление в .env.local +# ./restore-db.sh [имя_дампа.sql.gz] # Восстановление в .env # ./restore-db.sh --env-file .env.prod [имя_дампа] # Восстановление в указанный файл -# ./restore-db.sh production-backup.sql.gz # Восстановление в .env.local +# ./restore-db.sh production-backup.sql.gz # Восстановление в .env set -e # Значения по умолчанию -DEFAULT_ENV_FILE=".env.local" +DEFAULT_ENV_FILE=".env" ENV_FILE="$DEFAULT_ENV_FILE" DUMP_FILE=""