Release v1.1.0: Add Telegram and Todoist integrations UI
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s

- 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
This commit is contained in:
poignatov
2025-12-31 19:11:28 +03:00
parent 63af6bf4ed
commit 7398918bc0
20 changed files with 721 additions and 100 deletions

View File

@@ -56,9 +56,8 @@ docker run -d \
- `DB_USER` - пользователь БД - `DB_USER` - пользователь БД
- `DB_PASSWORD` - пароль БД - `DB_PASSWORD` - пароль БД
- `DB_NAME` - имя БД - `DB_NAME` - имя БД
- `TELEGRAM_BOT_TOKEN` - токен Telegram бота (опционально) - `WEBHOOK_BASE_URL` - базовый URL для webhook (опционально)
- `TELEGRAM_CHAT_ID` - ID чата Telegram (опционально) - Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram"
- `TELEGRAM_WEBHOOK_BASE_URL` - базовый URL для webhook (опционально)
- `TODOIST_WEBHOOK_SECRET` - секрет для Todoist webhook (опционально) - `TODOIST_WEBHOOK_SECRET` - секрет для Todoist webhook (опционально)
**Важно:** Backend внутри контейнера всегда работает на порту 8080. Nginx проксирует запросы с порта 80 на backend. **Важно:** Backend внутри контейнера всегда работает на порту 8080. Nginx проксирует запросы с порта 80 на backend.

View File

@@ -38,10 +38,9 @@
**Примечание:** API запросы автоматически проксируются к бэкенду. В development режиме Vite проксирует запросы к `http://localhost:8080`. В production nginx проксирует запросы к бэкенд контейнеру. Не требуется настройка `VITE_API_BASE_URL`. **Примечание:** API запросы автоматически проксируются к бэкенду. В development режиме Vite проксирует запросы к `http://localhost:8080`. В production nginx проксирует запросы к бэкенд контейнеру. Не требуется настройка `VITE_API_BASE_URL`.
### Telegram Bot Configuration (опционально) ### Telegram Bot Configuration
- `TELEGRAM_BOT_TOKEN` - токен бота от @BotFather - `WEBHOOK_BASE_URL` - базовый URL для автоматической настройки webhook. Webhook будет настроен автоматически при сохранении bot token через UI на `<WEBHOOK_BASE_URL>/webhook/telegram`.
- `TELEGRAM_CHAT_ID` - ID чата для отправки сообщений - Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram"
- `TELEGRAM_WEBHOOK_BASE_URL` - базовый URL для автоматической настройки webhook. Webhook будет настроен автоматически при старте сервера на `<TELEGRAM_WEBHOOK_BASE_URL>/webhook/telegram`. Если не указан, webhook нужно настраивать вручную.
**Примеры значений:** **Примеры значений:**
- Production с HTTPS: `https://your-domain.com` (порт не нужен для стандартных 80/443) - Production с HTTPS: `https://your-domain.com` (порт не нужен для стандартных 80/443)
@@ -213,12 +212,14 @@ WEB_PORT=4001 # для production Docker контейнера
#### Автоматическая настройка (рекомендуется) #### Автоматическая настройка (рекомендуется)
1. Создайте бота через [@BotFather](https://t.me/botfather) в Telegram 1. Создайте бота через [@BotFather](https://t.me/botfather) в Telegram
2. Получите токен бота и добавьте его в `.env`: 2. Получите токен бота
3. Добавьте `WEBHOOK_BASE_URL` в `.env`:
```bash ```bash
TELEGRAM_BOT_TOKEN=your_bot_token_here WEBHOOK_BASE_URL=https://your-domain.com
TELEGRAM_CHAT_ID=123456789
TELEGRAM_WEBHOOK_BASE_URL=https://your-domain.com
``` ```
4. Откройте приложение и перейдите в раздел "Интеграции" -> "Telegram"
5. Введите Bot Token в поле и нажмите "Сохранить"
6. Отправьте первое сообщение боту в Telegram - Chat ID будет сохранён автоматически
**Важно о портах:** **Важно о портах:**
- Если сервер доступен на стандартных портах (HTTP 80 или HTTPS 443), порт можно не указывать - Если сервер доступен на стандартных портах (HTTP 80 или HTTPS 443), порт можно не указывать
@@ -231,8 +232,8 @@ WEB_PORT=4001 # для production Docker контейнера
```bash ```bash
# Установите ngrok: https://ngrok.com/ # Установите ngrok: https://ngrok.com/
ngrok http 8080 ngrok http 8080
# Используйте полученный URL в TELEGRAM_WEBHOOK_BASE_URL (без порта) # Используйте полученный URL в WEBHOOK_BASE_URL (без порта)
# Например: TELEGRAM_WEBHOOK_BASE_URL=https://abc123.ngrok.io # Например: WEBHOOK_BASE_URL=https://abc123.ngrok.io
``` ```
4. Проверьте логи сервера - должно появиться сообщение: 4. Проверьте логи сервера - должно появиться сообщение:
@@ -240,9 +241,9 @@ WEB_PORT=4001 # для production Docker контейнера
Telegram webhook configured successfully: https://abc123.ngrok.io/webhook/telegram 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 ```bash
curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook" \ curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook" \

View File

@@ -1,2 +1,2 @@
1.0.1 1.1.0

View File

@@ -7,15 +7,12 @@
### Создание дампа ### Создание дампа
```bash ```bash
# Дамп из production БД (по умолчанию .env.prod) # Дамп из БД (по умолчанию .env)
./dump-db.sh ./dump-db.sh
# Дамп с именем # Дамп с именем
./dump-db.sh production-backup ./dump-db.sh production-backup
# Дамп из локальной БД
./dump-db.sh --env-file .env.local
# Дамп из другого окружения # Дамп из другого окружения
./dump-db.sh --env-file .env.prod my-backup ./dump-db.sh --env-file .env.prod my-backup
``` ```
@@ -29,10 +26,10 @@
### Восстановление дампа ### Восстановление дампа
```bash ```bash
# Восстановление в локальную БД (по умолчанию .env.local) # Восстановление в БД (по умолчанию .env)
./restore-db.sh dump_20240101_120000.sql.gz ./restore-db.sh dump_20240101_120000.sql.gz
# Восстановление в production БД # Восстановление в другое окружение
./restore-db.sh --env-file .env.prod dump_20240101_120000.sql.gz ./restore-db.sh --env-file .env.prod dump_20240101_120000.sql.gz
# Можно указать имя без расширения # Можно указать имя без расширения
@@ -41,8 +38,8 @@
## Поведение по умолчанию ## Поведение по умолчанию
- **Создание дампа**: использует `.env.prod` (production БД) - **Создание дампа**: использует `.env`
- **Восстановление**: использует `.env.local` (локальная БД) - **Восстановление**: использует `.env`
Это можно изменить с помощью параметра `--env-file`. Это можно изменить с помощью параметра `--env-file`.

View File

@@ -36,8 +36,6 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-playeng} DB_PASSWORD: ${DB_PASSWORD:-playeng}
DB_NAME: ${DB_NAME:-playeng} DB_NAME: ${DB_NAME:-playeng}
PORT: ${PORT:-8080} PORT: ${PORT:-8080}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@@ -2,14 +2,14 @@
# Скрипт для создания дампа базы данных # Скрипт для создания дампа базы данных
# Использование: # Использование:
# ./dump-db.sh [имя_дампа] # Дамп из .env.prod # ./dump-db.sh [имя_дампа] # Дамп из .env
# ./dump-db.sh --env-file .env.local [имя] # Дамп из указанного файла # ./dump-db.sh --env-file .env.prod [имя] # Дамп из указанного файла
# ./dump-db.sh production-backup # Именованный дамп из .env.prod # ./dump-db.sh production-backup # Именованный дамп из .env
set -e set -e
# Значения по умолчанию # Значения по умолчанию
DEFAULT_ENV_FILE=".env.prod" DEFAULT_ENV_FILE=".env"
ENV_FILE="$DEFAULT_ENV_FILE" ENV_FILE="$DEFAULT_ENV_FILE"
DUMP_NAME="" DUMP_NAME=""

View File

@@ -26,21 +26,18 @@ PORT=8080
WEB_PORT=3001 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 # 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<YOUR_BOT_TOKEN>/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 # Base URL для автоматической настройки webhook
# Примеры: # Примеры:
# - Для production с HTTPS: https://your-domain.com # - Для production с HTTPS: https://your-domain.com
# - Для локальной разработки с ngrok: https://abc123.ngrok.io # - Для локальной разработки с ngrok: https://abc123.ngrok.io
# - Для прямого доступа на нестандартном порту: http://your-server:8080 # - Для прямого доступа на нестандартном порту: http://your-server:8080
# Webhook будет настроен автоматически при старте сервера на: <TELEGRAM_WEBHOOK_BASE_URL>/webhook/telegram # Webhook будет настроен автоматически при старте сервера на: <WEBHOOK_BASE_URL>/webhook/telegram
# Если не указан, webhook нужно настраивать вручную # Если не указан, webhook нужно настраивать вручную
TELEGRAM_WEBHOOK_BASE_URL=https://your-domain.com WEBHOOK_BASE_URL=https://your-domain.com
# ============================================ # ============================================
# Todoist Webhook Configuration (optional) # Todoist Webhook Configuration (optional)

View File

@@ -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 "Всего дампов: $(ls -1 "$DUMP_DIR"/*.sql.gz 2>/dev/null | wc -l | tr -d ' ')"
echo "" echo ""
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 <имя_дампа> # В указанный файл" echo " ./restore-db.sh --env-file .env.prod <имя_дампа> # В указанный файл"
else else
echo " (нет дампов)" echo " (нет дампов)"
echo "" echo ""
echo "Для создания дампа используйте:" echo "Для создания дампа используйте:"
echo " ./dump-db.sh # Из .env.prod" echo " ./dump-db.sh # Из .env"
echo " ./dump-db.sh --env-file .env.local [имя] # Из указанного файла" echo " ./dump-db.sh --env-file .env.prod [имя] # Из указанного файла"
fi fi

View File

@@ -29,8 +29,8 @@
### Опциональные (для Telegram интеграции) ### Опциональные (для Telegram интеграции)
- `TELEGRAM_BOT_TOKEN` - токен бота от @BotFather - `WEBHOOK_BASE_URL` - базовый URL для автоматической настройки webhook
- `TELEGRAM_CHAT_ID` - ID чата для отправки сообщений - Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram"
## Использование в коде ## Использование в коде
@@ -41,7 +41,6 @@
### Вариант 1: Установить переменные вручную ### Вариант 1: Установить переменные вручную
```bash ```bash
export DB_PASSWORD=your_password export DB_PASSWORD=your_password
export TELEGRAM_BOT_TOKEN=your_token
go run main.go go run main.go
``` ```

View File

@@ -30,8 +30,6 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-playeng} DB_PASSWORD: ${DB_PASSWORD:-playeng}
DB_NAME: ${DB_NAME:-playeng} DB_NAME: ${DB_NAME:-playeng}
PORT: ${PORT:-8080} PORT: ${PORT:-8080}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@@ -8,12 +8,9 @@ DB_NAME=playeng
# Server Configuration # Server Configuration
PORT=8080 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 # 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<YOUR_BOT_TOKEN>/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 # Scheduler Configuration
# Часовой пояс для планировщика (формат IANA: Europe/Moscow, America/New_York и т.д.) # Часовой пояс для планировщика (формат IANA: Europe/Moscow, America/New_York и т.д.)

View File

@@ -166,9 +166,14 @@ type TelegramEntity struct {
Length int `json:"length"` Length int `json:"length"`
} }
type TelegramChat struct {
ID int64 `json:"id"`
}
type TelegramMessage struct { type TelegramMessage struct {
Text string `json:"text"` Text string `json:"text"`
Entities []TelegramEntity `json:"entities"` Entities []TelegramEntity `json:"entities"`
Chat TelegramChat `json:"chat"`
} }
type TelegramWebhook struct { type TelegramWebhook struct {
@@ -1947,6 +1952,18 @@ func (a *App) initPlayLifeDB() error {
log.Printf("Warning: Failed to create materialized view index: %v", err) 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 return nil
} }
@@ -2346,49 +2363,31 @@ func main() {
log.Printf("Successfully connected to database: %s@%s:%s/%s", dbUser, dbHost, dbPort, dbName) log.Printf("Successfully connected to database: %s@%s:%s/%s", dbUser, dbHost, dbPort, dbName)
defer db.Close() defer db.Close()
// Инициализируем Telegram бота (если токен указан) // Telegram бот теперь загружается из БД при необходимости
var telegramBot *tgbotapi.BotAPI // Webhook будет настроен автоматически при сохранении bot token через UI
var telegramChatID int64
telegramToken := getEnv("TELEGRAM_BOT_TOKEN", "")
telegramChatIDStr := getEnv("TELEGRAM_CHAT_ID", "")
telegramWebhookBaseURL := getEnv("TELEGRAM_WEBHOOK_BASE_URL", "")
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{ app := &App{
DB: db, DB: db,
lastWebhookTime: make(map[int]time.Time), lastWebhookTime: make(map[int]time.Time),
telegramBot: telegramBot, telegramBot: nil, // Больше не используем глобальный bot
telegramChatID: telegramChatID, 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 проекта // Инициализируем БД для play-life проекта
@@ -2440,6 +2439,9 @@ func main() {
r.HandleFunc("/admin", app.adminHandler).Methods("GET") r.HandleFunc("/admin", app.adminHandler).Methods("GET")
r.HandleFunc("/admin.html", app.adminHandler).Methods("GET") r.HandleFunc("/admin.html", app.adminHandler).Methods("GET")
r.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS") 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") port := getEnv("PORT", "8080")
log.Printf("Server starting on port %s", port) 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 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) { func (a *App) sendTelegramMessage(text string) {
log.Printf("sendTelegramMessage called with text length: %d", len(text)) 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 не настроен, пропускаем отправку // 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 return
} }
@@ -2545,14 +2679,14 @@ func (a *App) sendTelegramMessage(text string) {
telegramText := regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "*$1*") telegramText := regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "*$1*")
log.Printf("Sending Telegram message (converted text length: %d): %s", len(telegramText), telegramText) 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 msg.ParseMode = "Markdown" // Markdown (Legacy) format
_, err := a.telegramBot.Send(msg) _, err = bot.Send(msg)
if err != nil { if err != nil {
log.Printf("ERROR sending Telegram message: %v", err) log.Printf("ERROR sending Telegram message: %v", err)
} else { } 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 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 // Проверяем, что есть message
if update.Message.Text == "" { if update.Message.Text == "" {
log.Printf("Telegram webhook: no text in message") 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) 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,
})
}

View File

@@ -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)';

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "1.0.0", "version": "1.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -7,6 +7,7 @@ import AddWords from './components/AddWords'
import TestConfigSelection from './components/TestConfigSelection' import TestConfigSelection from './components/TestConfigSelection'
import AddConfig from './components/AddConfig' import AddConfig from './components/AddConfig'
import TestWords from './components/TestWords' import TestWords from './components/TestWords'
import Integrations from './components/Integrations'
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite) // API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
const CURRENT_WEEK_API_URL = '/playlife-feed' const CURRENT_WEEK_API_URL = '/playlife-feed'
@@ -24,6 +25,7 @@ function App() {
'test-config': false, 'test-config': false,
'add-config': false, 'add-config': false,
test: false, test: false,
integrations: false,
}) })
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок) // Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
@@ -36,6 +38,7 @@ function App() {
'test-config': false, 'test-config': false,
'add-config': false, 'add-config': false,
test: false, test: false,
integrations: false,
}) })
// Параметры для навигации между вкладками // Параметры для навигации между вкладками
@@ -74,7 +77,7 @@ function App() {
try { try {
const savedTab = window.localStorage?.getItem('activeTab') 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)) { if (savedTab && validTabs.includes(savedTab)) {
setActiveTab(savedTab) setActiveTab(savedTab)
setLoadedTabs(prev => ({ ...prev, [savedTab]: true })) setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
@@ -184,6 +187,7 @@ function App() {
'test-config': false, 'test-config': false,
'add-config': false, 'add-config': false,
test: false, test: false,
integrations: false,
}) })
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback) // Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
@@ -471,6 +475,12 @@ function App() {
/> />
</div> </div>
)} )}
{loadedTabs.integrations && (
<div className={activeTab === 'integrations' ? 'block' : 'hidden'}>
<Integrations onNavigate={handleNavigate} />
</div>
)}
</div> </div>
</div> </div>
@@ -519,6 +529,26 @@ function App() {
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div> <div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
)} )}
</button> </button>
<button
onClick={() => handleTabChange('integrations')}
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
activeTab === 'integrations'
? 'text-indigo-700 bg-white/50'
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
}`}
title="Интеграции"
>
<span className="relative z-10 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
</span>
{activeTab === 'integrations' && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
)}
</button>
</div> </div>
</div> </div>
)} )}

View File

@@ -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;
}

View File

@@ -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 <TodoistIntegration onBack={() => setSelectedIntegration(null)} />
} else if (selectedIntegration === 'telegram') {
return <TelegramIntegration onBack={() => setSelectedIntegration(null)} />
}
}
return (
<div className="p-4 md:p-6">
<h1 className="text-2xl font-bold mb-6">Интеграции</h1>
<div className="space-y-4">
{integrations.map((integration) => (
<button
key={integration.id}
onClick={() => setSelectedIntegration(integration.id)}
className="w-full p-4 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow text-left border border-gray-200 hover:border-indigo-300"
>
<div className="flex items-center justify-between">
<span className="text-lg font-semibold text-gray-800">
{integration.name}
</span>
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
))}
</div>
</div>
)
}
export default Integrations

View File

@@ -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 (
<div className="p-4 md:p-6">
<button className="close-x-button" onClick={onBack} title="Закрыть">
</button>
<h1 className="text-2xl font-bold mb-6">Telegram интеграция</h1>
{loading ? (
<div className="text-gray-500">Загрузка...</div>
) : (
<>
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Настройки</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Telegram Bot Token
</label>
<input
type="text"
value={botToken}
onChange={(e) => 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"
/>
</div>
{chatId && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Chat ID (устанавливается автоматически)
</label>
<input
type="text"
value={chatId}
readOnly
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50"
/>
</div>
)}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">
{success}
</div>
)}
<button
onClick={handleSave}
disabled={saving || !botToken.trim()}
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{saving ? 'Сохранение...' : 'Сохранить Bot Token'}
</button>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold mb-3 text-blue-900">
Откуда взять Bot Token
</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>Откройте Telegram и найдите бота @BotFather</li>
<li>Отправьте команду /newbot</li>
<li>Следуйте инструкциям для создания нового бота</li>
<li>
После создания бота BotFather предоставит вам Bot Token
</li>
<li>Скопируйте токен и вставьте его в поле выше</li>
</ol>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-3 text-yellow-900">
Что нужно сделать после сохранения Bot Token
</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>После сохранения Bot Token отправьте первое сообщение вашему боту в Telegram</li>
<li>
Chat ID будет автоматически сохранен после обработки первого
сообщения
</li>
<li>
После этого бот сможет отправлять вам ответные сообщения
</li>
</ol>
</div>
</>
)}
</div>
)
}
export default TelegramIntegration

View File

@@ -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 (
<div className="p-4 md:p-6">
<button className="close-x-button" onClick={onBack} title="Закрыть">
</button>
<h1 className="text-2xl font-bold mb-6">TODOist интеграция</h1>
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Webhook URL</h2>
{loading ? (
<div className="text-gray-500">Загрузка...</div>
) : (
<div className="flex items-center gap-2">
<input
type="text"
value={webhookURL}
readOnly
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-sm"
/>
<button
onClick={copyToClipboard}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors whitespace-nowrap"
>
{copied ? 'Скопировано!' : 'Копировать'}
</button>
</div>
)}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-3 text-blue-900">
Как использовать в приложении TODOist
</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>Откройте приложение TODOist на вашем устройстве</li>
<li>Перейдите в настройки проекта или задачи</li>
<li>Найдите раздел "Интеграции" или "Webhooks"</li>
<li>Вставьте скопированный URL webhook в соответствующее поле</li>
<li>Сохраните настройки</li>
<li>
Теперь при закрытии задач в TODOist они будут автоматически
обрабатываться системой
</li>
</ol>
</div>
</div>
)
}
export default TodoistIntegration

View File

@@ -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 --env-file .env.prod [имя_дампа] # Восстановление в указанный файл
# ./restore-db.sh production-backup.sql.gz # Восстановление в .env.local # ./restore-db.sh production-backup.sql.gz # Восстановление в .env
set -e set -e
# Значения по умолчанию # Значения по умолчанию
DEFAULT_ENV_FILE=".env.local" DEFAULT_ENV_FILE=".env"
ENV_FILE="$DEFAULT_ENV_FILE" ENV_FILE="$DEFAULT_ENV_FILE"
DUMP_FILE="" DUMP_FILE=""