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
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:
@@ -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.
|
||||||
|
|||||||
25
ENV_SETUP.md
25
ENV_SETUP.md
@@ -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" \
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=""
|
||||||
|
|
||||||
|
|||||||
11
env.example
11
env.example
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 и т.д.)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)';
|
||||||
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
25
play-life-web/src/components/Integrations.css
Normal file
25
play-life-web/src/components/Integrations.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
57
play-life-web/src/components/Integrations.jsx
Normal file
57
play-life-web/src/components/Integrations.jsx
Normal 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
|
||||||
|
|
||||||
170
play-life-web/src/components/TelegramIntegration.jsx
Normal file
170
play-life-web/src/components/TelegramIntegration.jsx
Normal 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
|
||||||
|
|
||||||
90
play-life-web/src/components/TodoistIntegration.jsx
Normal file
90
play-life-web/src/components/TodoistIntegration.jsx
Normal 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
|
||||||
|
|
||||||
@@ -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=""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user