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

@@ -166,9 +166,14 @@ type TelegramEntity struct {
Length int `json:"length"`
}
type TelegramChat struct {
ID int64 `json:"id"`
}
type TelegramMessage struct {
Text string `json:"text"`
Entities []TelegramEntity `json:"entities"`
Chat TelegramChat `json:"chat"`
}
type TelegramWebhook struct {
@@ -1947,6 +1952,18 @@ func (a *App) initPlayLifeDB() error {
log.Printf("Warning: Failed to create materialized view index: %v", err)
}
// Создаем таблицу telegram_integrations
createTelegramIntegrationsTable := `
CREATE TABLE IF NOT EXISTS telegram_integrations (
id SERIAL PRIMARY KEY,
chat_id VARCHAR(255),
bot_token VARCHAR(255)
)
`
if _, err := a.DB.Exec(createTelegramIntegrationsTable); err != nil {
return fmt.Errorf("failed to create telegram_integrations table: %w", err)
}
return nil
}
@@ -2346,49 +2363,31 @@ func main() {
log.Printf("Successfully connected to database: %s@%s:%s/%s", dbUser, dbHost, dbPort, dbName)
defer db.Close()
// Инициализируем Telegram бота (если токен указан)
var telegramBot *tgbotapi.BotAPI
var telegramChatID int64
telegramToken := getEnv("TELEGRAM_BOT_TOKEN", "")
telegramChatIDStr := getEnv("TELEGRAM_CHAT_ID", "")
telegramWebhookBaseURL := getEnv("TELEGRAM_WEBHOOK_BASE_URL", "")
// Telegram бот теперь загружается из БД при необходимости
// Webhook будет настроен автоматически при сохранении bot token через UI
if telegramToken != "" && telegramChatIDStr != "" {
bot, err := tgbotapi.NewBotAPI(telegramToken)
if err != nil {
log.Printf("Warning: Failed to initialize Telegram bot: %v. Telegram notifications will be disabled.", err)
} else {
telegramBot = bot
chatID, err := strconv.ParseInt(telegramChatIDStr, 10, 64)
if err != nil {
log.Printf("Warning: Invalid TELEGRAM_CHAT_ID format: %v. Telegram notifications will be disabled.", err)
telegramBot = nil
} else {
telegramChatID = chatID
log.Printf("Telegram bot initialized successfully. Chat ID: %d", telegramChatID)
}
}
} else {
log.Println("Telegram bot token or chat ID not provided. Telegram notifications disabled.")
}
// Настраиваем webhook для Telegram (если указан base URL)
if telegramToken != "" && telegramWebhookBaseURL != "" {
webhookURL := strings.TrimRight(telegramWebhookBaseURL, "/") + "/webhook/telegram"
if err := setupTelegramWebhook(telegramToken, webhookURL); err != nil {
log.Printf("Warning: Failed to setup Telegram webhook: %v. Webhook will not be configured.", err)
} else {
log.Printf("Telegram webhook configured successfully: %s", webhookURL)
}
} else if telegramToken != "" {
log.Println("TELEGRAM_WEBHOOK_BASE_URL not provided. Telegram webhook will not be configured automatically.")
}
app := &App{
DB: db,
lastWebhookTime: make(map[int]time.Time),
telegramBot: telegramBot,
telegramChatID: telegramChatID,
telegramBot: nil, // Больше не используем глобальный bot
telegramChatID: 0, // Больше не используем глобальный chat_id
}
// Пытаемся настроить webhook автоматически при старте, если есть base URL и bot token в БД
// Это опционально - основная регистрация происходит при сохранении токена через UI
webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "")
if webhookBaseURL != "" {
integration, err := app.getTelegramIntegration()
if err == nil && integration.BotToken != nil && *integration.BotToken != "" {
webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram"
if err := setupTelegramWebhook(*integration.BotToken, webhookURL); err != nil {
log.Printf("Warning: Failed to setup Telegram webhook at startup: %v. Webhook will be configured when user saves bot token.", err)
} else {
log.Printf("Telegram webhook configured successfully at startup: %s", webhookURL)
}
} else {
log.Printf("Telegram bot token not found in database. Webhook will be configured when user saves bot token.")
}
}
// Инициализируем БД для play-life проекта
@@ -2440,6 +2439,9 @@ func main() {
r.HandleFunc("/admin", app.adminHandler).Methods("GET")
r.HandleFunc("/admin.html", app.adminHandler).Methods("GET")
r.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
r.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS")
r.HandleFunc("/api/integrations/telegram", app.updateTelegramIntegrationHandler).Methods("POST", "OPTIONS")
r.HandleFunc("/api/integrations/todoist/webhook-url", app.getTodoistWebhookURLHandler).Methods("GET", "OPTIONS")
port := getEnv("PORT", "8080")
log.Printf("Server starting on port %s", port)
@@ -2529,13 +2531,145 @@ func roundToFourDecimals(val float64) float64 {
return float64(int(val*10000+0.5)) / 10000.0
}
// TelegramIntegration представляет запись из таблицы telegram_integrations
type TelegramIntegration struct {
ID int `json:"id"`
ChatID *string `json:"chat_id"`
BotToken *string `json:"bot_token"`
}
// getTelegramIntegration получает telegram интеграцию из БД
func (a *App) getTelegramIntegration() (*TelegramIntegration, error) {
var integration TelegramIntegration
var chatID, botToken sql.NullString
err := a.DB.QueryRow(`
SELECT id, chat_id, bot_token
FROM telegram_integrations
ORDER BY id DESC
LIMIT 1
`).Scan(&integration.ID, &chatID, &botToken)
if err == sql.ErrNoRows {
// Если записи нет, создаем новую
_, err = a.DB.Exec(`
INSERT INTO telegram_integrations (chat_id, bot_token)
VALUES (NULL, NULL)
`)
if err != nil {
return nil, fmt.Errorf("failed to create telegram integration: %w", err)
}
// Повторно получаем созданную запись
err = a.DB.QueryRow(`
SELECT id, chat_id, bot_token
FROM telegram_integrations
ORDER BY id DESC
LIMIT 1
`).Scan(&integration.ID, &chatID, &botToken)
if err != nil {
return nil, fmt.Errorf("failed to get created telegram integration: %w", err)
}
} else if err != nil {
return nil, fmt.Errorf("failed to get telegram integration: %w", err)
}
if chatID.Valid {
integration.ChatID = &chatID.String
}
if botToken.Valid {
integration.BotToken = &botToken.String
}
return &integration, nil
}
// saveTelegramBotToken сохраняет bot token в БД
func (a *App) saveTelegramBotToken(botToken string) error {
// Проверяем, есть ли уже запись
integration, err := a.getTelegramIntegration()
if err != nil {
// Если записи нет, создаем новую
_, err = a.DB.Exec(`
INSERT INTO telegram_integrations (bot_token, chat_id)
VALUES ($1, NULL)
`, botToken)
if err != nil {
return fmt.Errorf("failed to create telegram bot token: %w", err)
}
} else {
// Обновляем существующую запись
_, err = a.DB.Exec(`
UPDATE telegram_integrations
SET bot_token = $1
WHERE id = $2
`, botToken, integration.ID)
if err != nil {
return fmt.Errorf("failed to update telegram bot token: %w", err)
}
}
return nil
}
// saveTelegramChatID сохраняет chat_id в БД
func (a *App) saveTelegramChatID(chatID string) error {
// Получаем текущую интеграцию
integration, err := a.getTelegramIntegration()
if err != nil {
return fmt.Errorf("failed to get telegram integration: %w", err)
}
_, err = a.DB.Exec(`
UPDATE telegram_integrations
SET chat_id = $1
WHERE id = $2
`, chatID, integration.ID)
if err != nil {
return fmt.Errorf("failed to save telegram chat_id: %w", err)
}
return nil
}
// getTelegramBotAndChatID получает bot token и chat_id из БД и создает bot API
func (a *App) getTelegramBotAndChatID() (*tgbotapi.BotAPI, int64, error) {
integration, err := a.getTelegramIntegration()
if err != nil {
return nil, 0, err
}
if integration.BotToken == nil || *integration.BotToken == "" {
return nil, 0, nil // Bot token не настроен
}
bot, err := tgbotapi.NewBotAPI(*integration.BotToken)
if err != nil {
return nil, 0, fmt.Errorf("failed to initialize Telegram bot: %w", err)
}
var chatID int64 = 0
if integration.ChatID != nil && *integration.ChatID != "" {
chatID, err = strconv.ParseInt(*integration.ChatID, 10, 64)
if err != nil {
log.Printf("Warning: Invalid chat_id format in database: %v", err)
chatID = 0
}
}
return bot, chatID, nil
}
func (a *App) sendTelegramMessage(text string) {
log.Printf("sendTelegramMessage called with text length: %d", len(text))
log.Printf("Telegram bot status: bot=%v, chatID=%d", a.telegramBot != nil, a.telegramChatID)
if a.telegramBot == nil || a.telegramChatID == 0 {
// Получаем bot и chat_id из БД
bot, chatID, err := a.getTelegramBotAndChatID()
if err != nil {
log.Printf("WARNING: Failed to get Telegram bot from database: %v, skipping message send", err)
return
}
if bot == nil || chatID == 0 {
// Telegram не настроен, пропускаем отправку
log.Printf("WARNING: Telegram bot not initialized (bot=%v, chatID=%d), skipping message send", a.telegramBot != nil, a.telegramChatID)
log.Printf("WARNING: Telegram bot not configured (bot=%v, chatID=%d), skipping message send", bot != nil, chatID)
return
}
@@ -2545,14 +2679,14 @@ func (a *App) sendTelegramMessage(text string) {
telegramText := regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "*$1*")
log.Printf("Sending Telegram message (converted text length: %d): %s", len(telegramText), telegramText)
msg := tgbotapi.NewMessage(a.telegramChatID, telegramText)
msg := tgbotapi.NewMessage(chatID, telegramText)
msg.ParseMode = "Markdown" // Markdown (Legacy) format
_, err := a.telegramBot.Send(msg)
_, err = bot.Send(msg)
if err != nil {
log.Printf("ERROR sending Telegram message: %v", err)
} else {
log.Printf("Telegram message sent successfully to chat ID %d", a.telegramChatID)
log.Printf("Telegram message sent successfully to chat ID %d", chatID)
}
}
@@ -3966,6 +4100,22 @@ func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Сохраняем chat_id при первом сообщении
if update.Message.Chat.ID != 0 {
chatIDStr := strconv.FormatInt(update.Message.Chat.ID, 10)
integration, err := a.getTelegramIntegration()
if err == nil {
// Сохраняем chat_id, если его еще нет
if integration.ChatID == nil || *integration.ChatID == "" {
if err := a.saveTelegramChatID(chatIDStr); err != nil {
log.Printf("Warning: Failed to save chat_id: %v", err)
} else {
log.Printf("Saved chat_id from first message: %s", chatIDStr)
}
}
}
}
// Проверяем, что есть message
if update.Message.Text == "" {
log.Printf("Telegram webhook: no text in message")
@@ -4077,3 +4227,100 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(statistics)
}
// getTelegramIntegrationHandler возвращает текущую telegram интеграцию
func (a *App) getTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
integration, err := a.getTelegramIntegration()
if err != nil {
sendErrorWithCORS(w, fmt.Sprintf("Failed to get telegram integration: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(integration)
}
// TelegramIntegrationUpdateRequest представляет запрос на обновление telegram интеграции
type TelegramIntegrationUpdateRequest struct {
BotToken string `json:"bot_token"`
}
// updateTelegramIntegrationHandler обновляет bot token для telegram интеграции
func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
var req TelegramIntegrationUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.BotToken == "" {
sendErrorWithCORS(w, "bot_token is required", http.StatusBadRequest)
return
}
if err := a.saveTelegramBotToken(req.BotToken); err != nil {
sendErrorWithCORS(w, fmt.Sprintf("Failed to save bot token: %v", err), http.StatusInternalServerError)
return
}
// Настраиваем webhook автоматически при сохранении токена
webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "")
if webhookBaseURL != "" {
webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram"
if err := setupTelegramWebhook(req.BotToken, webhookURL); err != nil {
log.Printf("Warning: Failed to setup Telegram webhook: %v", err)
// Не возвращаем ошибку, так как токен уже сохранен
} else {
log.Printf("Telegram webhook configured successfully: %s", webhookURL)
}
} else {
log.Printf("Warning: WEBHOOK_BASE_URL not set. Webhook will not be configured automatically.")
}
integration, err := a.getTelegramIntegration()
if err != nil {
sendErrorWithCORS(w, fmt.Sprintf("Failed to get updated integration: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(integration)
}
// getTodoistWebhookURLHandler возвращает URL для Todoist webhook
func (a *App) getTodoistWebhookURLHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
// Получаем base URL из env
baseURL := getEnv("WEBHOOK_BASE_URL", "")
if baseURL == "" {
sendErrorWithCORS(w, "WEBHOOK_BASE_URL not configured", http.StatusInternalServerError)
return
}
webhookURL := strings.TrimRight(baseURL, "/") + "/webhook/todoist"
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"webhook_url": webhookURL,
})
}