v2.0.3: Webhook user identification by URL token
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 42s

- Added webhook_token to telegram_integrations
- Webhooks now identify users by token in URL (/webhook/telegram/{token}, /webhook/todoist/{token})
- Webhook automatically configured for all users on backend startup
- Migration 011: Add webhook_token column
This commit is contained in:
poignatov
2026-01-01 18:38:28 +03:00
parent ad1caceda0
commit 7704de334c
4 changed files with 245 additions and 68 deletions

View File

@@ -281,6 +281,16 @@ func generateRefreshToken() (string, error) {
return base64.URLEncoding.EncodeToString(b), nil
}
// generateWebhookToken generates a unique token for webhook URL identification
func generateWebhookToken() (string, error) {
b := make([]byte, 24) // 24 bytes = 32 chars in base64
_, err := rand.Read(b)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
func (a *App) generateAccessToken(userID int) (string, error) {
claims := JWTClaims{
UserID: userID,
@@ -2500,6 +2510,10 @@ func (a *App) initAuthDB() error {
// Create new unique constraint per user for progress
a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_progress_word_user_unique ON progress(word_id, user_id)")
// Add webhook_token to telegram_integrations for URL-based user identification
a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS webhook_token VARCHAR(255)")
a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_webhook_token ON telegram_integrations(webhook_token) WHERE webhook_token IS NOT NULL")
// Clean up expired refresh tokens
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at < NOW()")
@@ -3085,20 +3099,46 @@ func main() {
jwtSecret: []byte(jwtSecret),
}
// Пытаемся настроить webhook автоматически при старте, если есть base URL и bot token в БД
// Пытаемся настроить webhook автоматически при старте для всех пользователей с bot_token
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"
log.Printf("Attempting to setup Telegram webhook at startup. WEBHOOK_BASE_URL='%s'", webhookBaseURL)
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("SUCCESS: Telegram webhook configured successfully at startup: %s", webhookURL)
}
log.Printf("Setting up Telegram webhooks for all users at startup...")
rows, err := app.DB.Query(`
SELECT user_id, bot_token, webhook_token
FROM telegram_integrations
WHERE bot_token IS NOT NULL
AND bot_token != ''
AND webhook_token IS NOT NULL
AND webhook_token != ''
AND user_id IS NOT NULL
`)
if err != nil {
log.Printf("Warning: Failed to query telegram integrations at startup: %v", err)
} else {
log.Printf("Telegram bot token not found in database. Webhook will be configured when user saves bot token.")
defer rows.Close()
configuredCount := 0
for rows.Next() {
var userID int
var botToken, webhookToken string
if err := rows.Scan(&userID, &botToken, &webhookToken); err != nil {
log.Printf("Warning: Failed to scan telegram integration: %v", err)
continue
}
webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram/" + webhookToken
log.Printf("Setting up Telegram webhook for user_id=%d: URL=%s", userID, webhookURL)
if err := setupTelegramWebhook(botToken, webhookURL); err != nil {
log.Printf("Warning: Failed to setup Telegram webhook for user_id=%d: %v", userID, err)
} else {
log.Printf("SUCCESS: Telegram webhook configured for user_id=%d: %s", userID, webhookURL)
configuredCount++
}
}
if configuredCount > 0 {
log.Printf("Telegram webhooks configured for %d user(s) at startup", configuredCount)
} else {
log.Printf("No Telegram integrations found with bot_token and webhook_token. Webhooks will be configured when users save bot tokens.")
}
}
} else {
log.Printf("WEBHOOK_BASE_URL not set. Webhook will be configured when user saves bot token.")
@@ -3137,8 +3177,8 @@ func main() {
// Webhooks - no auth (external services)
r.HandleFunc("/webhook/message/post", app.messagePostHandler).Methods("POST", "OPTIONS")
r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS")
r.HandleFunc("/webhook/telegram", app.telegramWebhookHandler).Methods("POST", "OPTIONS")
r.HandleFunc("/webhook/todoist/{token}", app.todoistWebhookHandler).Methods("POST", "OPTIONS")
r.HandleFunc("/webhook/telegram/{token}", app.telegramWebhookHandler).Methods("POST", "OPTIONS")
// Admin pages (basic access, consider adding auth later)
r.HandleFunc("/admin", app.adminHandler).Methods("GET")
@@ -3285,35 +3325,42 @@ func roundToFourDecimals(val float64) float64 {
// TelegramIntegration представляет запись из таблицы telegram_integrations
type TelegramIntegration struct {
ID int `json:"id"`
ChatID *string `json:"chat_id"`
BotToken *string `json:"bot_token"`
ID int `json:"id"`
ChatID *string `json:"chat_id"`
BotToken *string `json:"bot_token"`
WebhookToken *string `json:"webhook_token"`
}
// getTelegramIntegration получает telegram интеграцию из БД
// getTelegramIntegrationForUser gets telegram integration for specific user
func (a *App) getTelegramIntegrationForUser(userID int) (*TelegramIntegration, error) {
var integration TelegramIntegration
var chatID, botToken sql.NullString
var chatID, botToken, webhookToken sql.NullString
err := a.DB.QueryRow(`
SELECT id, chat_id, bot_token
SELECT id, chat_id, bot_token, webhook_token
FROM telegram_integrations
WHERE user_id = $1
ORDER BY id DESC
LIMIT 1
`, userID).Scan(&integration.ID, &chatID, &botToken)
`, userID).Scan(&integration.ID, &chatID, &botToken, &webhookToken)
if err == sql.ErrNoRows {
// Если записи нет, создаем новую для этого пользователя
// Если записи нет, создаем новую для этого пользователя с webhook токеном
webhookToken, err := generateWebhookToken()
if err != nil {
return nil, fmt.Errorf("failed to generate webhook token: %w", err)
}
err = a.DB.QueryRow(`
INSERT INTO telegram_integrations (chat_id, bot_token, user_id)
VALUES (NULL, NULL, $1)
INSERT INTO telegram_integrations (chat_id, bot_token, user_id, webhook_token)
VALUES (NULL, NULL, $1, $2)
RETURNING id
`, userID).Scan(&integration.ID)
`, userID, webhookToken).Scan(&integration.ID)
if err != nil {
return nil, fmt.Errorf("failed to create telegram integration: %w", err)
}
integration.WebhookToken = &webhookToken
return &integration, nil
} else if err != nil {
return nil, fmt.Errorf("failed to get telegram integration: %w", err)
@@ -3325,6 +3372,24 @@ func (a *App) getTelegramIntegrationForUser(userID int) (*TelegramIntegration, e
if botToken.Valid {
integration.BotToken = &botToken.String
}
if webhookToken.Valid {
integration.WebhookToken = &webhookToken.String
} else {
// Если токена нет, генерируем его
newToken, err := generateWebhookToken()
if err != nil {
return nil, fmt.Errorf("failed to generate webhook token: %w", err)
}
_, err = a.DB.Exec(`
UPDATE telegram_integrations
SET webhook_token = $1
WHERE id = $2
`, newToken, integration.ID)
if err != nil {
return nil, fmt.Errorf("failed to update webhook token: %w", err)
}
integration.WebhookToken = &newToken
}
return &integration, nil
}
@@ -3404,11 +3469,15 @@ func (a *App) saveTelegramBotTokenForUser(botToken string, userID int) error {
// Проверяем, есть ли уже запись для этого пользователя
integration, err := a.getTelegramIntegrationForUser(userID)
if err != nil {
// Если записи нет, создаем новую
// Если записи нет, создаем новую с webhook токеном
webhookToken, err := generateWebhookToken()
if err != nil {
return fmt.Errorf("failed to generate webhook token: %w", err)
}
_, err = a.DB.Exec(`
INSERT INTO telegram_integrations (bot_token, chat_id, user_id)
VALUES ($1, NULL, $2)
`, botToken, userID)
INSERT INTO telegram_integrations (bot_token, chat_id, user_id, webhook_token)
VALUES ($1, NULL, $2, $3)
`, botToken, userID, webhookToken)
if err != nil {
return fmt.Errorf("failed to create telegram bot token: %w", err)
}
@@ -3422,6 +3491,21 @@ func (a *App) saveTelegramBotTokenForUser(botToken string, userID int) error {
if err != nil {
return fmt.Errorf("failed to update telegram bot token: %w", err)
}
// Убеждаемся, что webhook_token есть
if integration.WebhookToken == nil || *integration.WebhookToken == "" {
webhookToken, err := generateWebhookToken()
if err != nil {
return fmt.Errorf("failed to generate webhook token: %w", err)
}
_, err = a.DB.Exec(`
UPDATE telegram_integrations
SET webhook_token = $1
WHERE id = $2
`, webhookToken, integration.ID)
if err != nil {
return fmt.Errorf("failed to update webhook token: %w", err)
}
}
}
return nil
}
@@ -3541,7 +3625,8 @@ func utf16LengthToUTF8(text string, utf16Offset, utf16Length int) int {
// processTelegramMessage обрабатывает сообщение из Telegram с использованием entities
// Логика отличается от processMessage: использует entities для определения жирного текста
// и не отправляет сообщение обратно в Telegram
func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity) (*ProcessedEntry, error) {
// userID может быть nil, если пользователь не определен
func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity, userID *int) (*ProcessedEntry, error) {
fullText = strings.TrimSpace(fullText)
// Регулярное выражение: project+/-score (без **)
@@ -3646,7 +3731,7 @@ func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity)
// Вставляем данные в БД только если есть nodes
if len(scoreNodes) > 0 {
err := a.insertMessageData(processedText, createdDate, scoreNodes, nil) // nil userID for webhook
err := a.insertMessageData(processedText, createdDate, scoreNodes, userID)
if err != nil {
log.Printf("Error inserting message data: %v", err)
return nil, fmt.Errorf("error inserting data: %w", err)
@@ -4802,12 +4887,6 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Method: %s", r.Method)
log.Printf("URL: %s", r.URL.String())
log.Printf("RemoteAddr: %s", r.RemoteAddr)
log.Printf("Headers:")
for key, values := range r.Header {
for _, value := range values {
log.Printf(" %s: %s", key, value)
}
}
if r.Method == "OPTIONS" {
log.Printf("OPTIONS request, returning OK")
@@ -4817,6 +4896,35 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) {
}
setCORSHeaders(w)
// Извлекаем токен из URL
vars := mux.Vars(r)
token := vars["token"]
if token == "" {
log.Printf("Todoist webhook: missing token in URL")
sendErrorWithCORS(w, "Missing webhook token", http.StatusBadRequest)
return
}
// Находим пользователя по токену из telegram_integrations (используем тот же механизм)
var userID int
err := a.DB.QueryRow(`
SELECT user_id FROM telegram_integrations
WHERE webhook_token = $1 AND user_id IS NOT NULL
LIMIT 1
`, token).Scan(&userID)
if err == sql.ErrNoRows {
log.Printf("Todoist webhook: invalid token: %s", token)
sendErrorWithCORS(w, "Invalid webhook token", http.StatusUnauthorized)
return
} else if err != nil {
log.Printf("Error finding user by webhook token: %v", err)
sendErrorWithCORS(w, "Internal server error", http.StatusInternalServerError)
return
}
log.Printf("Todoist webhook: token=%s, user_id=%d", token, userID)
// Читаем тело запроса для логирования
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
@@ -4925,8 +5033,9 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) {
title, len(title), description, len(description), combinedText, len(combinedText))
// Обрабатываем сообщение через существующую логику (без отправки в Telegram)
log.Printf("Calling processMessageWithoutTelegram with combined text...")
response, err := a.processMessageWithoutTelegram(combinedText, nil) // nil userID for webhook
userIDPtr := &userID
log.Printf("Calling processMessageWithoutTelegram with combined text, user_id=%d...", userID)
response, err := a.processMessageWithoutTelegram(combinedText, userIDPtr)
if err != nil {
log.Printf("ERROR processing Todoist message: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
@@ -4977,6 +5086,35 @@ func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) {
}
setCORSHeaders(w)
// Извлекаем токен из URL
vars := mux.Vars(r)
token := vars["token"]
if token == "" {
log.Printf("Telegram webhook: missing token in URL")
sendErrorWithCORS(w, "Missing webhook token", http.StatusBadRequest)
return
}
// Находим пользователя по токену
var userID int
err := a.DB.QueryRow(`
SELECT user_id FROM telegram_integrations
WHERE webhook_token = $1 AND user_id IS NOT NULL
LIMIT 1
`, token).Scan(&userID)
if err == sql.ErrNoRows {
log.Printf("Telegram webhook: invalid token: %s", token)
sendErrorWithCORS(w, "Invalid webhook token", http.StatusUnauthorized)
return
} else if err != nil {
log.Printf("Error finding user by webhook token: %v", err)
sendErrorWithCORS(w, "Internal server error", http.StatusInternalServerError)
return
}
log.Printf("Telegram webhook: token=%s, user_id=%d", token, userID)
// Парсим webhook от Telegram
var update TelegramUpdate
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
@@ -5002,31 +5140,34 @@ func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("Telegram webhook: message present, chat_id=%d", message.Chat.ID)
log.Printf("Telegram webhook: message present, chat_id=%d, user_id=%d", message.Chat.ID, userID)
// Сохраняем chat_id при первом сообщении (даже если нет текста)
// Сохраняем chat_id при первом сообщении (если еще не сохранен)
if message.Chat.ID != 0 {
chatIDStr := strconv.FormatInt(message.Chat.ID, 10)
log.Printf("Processing chat_id: %s", chatIDStr)
integration, err := a.getTelegramIntegration()
if err != nil {
log.Printf("Error getting telegram integration: %v", err)
} else {
var existingChatID sql.NullString
err := a.DB.QueryRow(`
SELECT chat_id FROM telegram_integrations
WHERE user_id = $1
LIMIT 1
`, userID).Scan(&existingChatID)
if err == nil && (!existingChatID.Valid || existingChatID.String == "") {
// Сохраняем chat_id, если его еще нет
if integration.ChatID == nil || *integration.ChatID == "" {
log.Printf("Attempting to save chat_id: %s", chatIDStr)
if err := a.saveTelegramChatID(chatIDStr); err != nil {
log.Printf("Warning: Failed to save chat_id: %v", err)
} else {
log.Printf("Successfully saved chat_id from first message: %s", chatIDStr)
}
_, err = a.DB.Exec(`
UPDATE telegram_integrations
SET chat_id = $1
WHERE user_id = $2
`, chatIDStr, userID)
if err != nil {
log.Printf("Warning: Failed to save chat_id: %v", err)
} else {
log.Printf("Chat_id already exists in database: %s", *integration.ChatID)
log.Printf("Successfully saved chat_id from first message: %s", chatIDStr)
}
}
} else {
log.Printf("Warning: message.Chat.ID is 0, cannot save chat_id")
}
userIDPtr := &userID
// Проверяем, что есть текст в сообщении
if message.Text == "" {
@@ -5044,10 +5185,10 @@ func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) {
entities = []TelegramEntity{}
}
log.Printf("Processing Telegram message: text='%s', entities count=%d", fullText, len(entities))
log.Printf("Processing Telegram message: text='%s', entities count=%d, user_id=%d", fullText, len(entities), userID)
// Обрабатываем сообщение через новую логику (с entities, без отправки обратно в Telegram)
response, err := a.processTelegramMessage(fullText, entities)
response, err := a.processTelegramMessage(fullText, entities, userIDPtr)
if err != nil {
log.Printf("Error processing Telegram message: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
@@ -5207,11 +5348,18 @@ func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Re
return
}
// Получаем обновленную интеграцию с webhook токеном
integration, err := a.getTelegramIntegrationForUser(userID)
if err != nil {
sendErrorWithCORS(w, fmt.Sprintf("Failed to get updated integration: %v", err), http.StatusInternalServerError)
return
}
// Настраиваем webhook автоматически при сохранении токена
webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "")
log.Printf("Attempting to setup Telegram webhook. WEBHOOK_BASE_URL='%s'", webhookBaseURL)
if webhookBaseURL != "" {
webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram"
if webhookBaseURL != "" && integration.WebhookToken != nil && *integration.WebhookToken != "" {
webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram/" + *integration.WebhookToken
log.Printf("Setting up Telegram webhook: URL=%s", webhookURL)
if err := setupTelegramWebhook(req.BotToken, webhookURL); err != nil {
log.Printf("ERROR: Failed to setup Telegram webhook: %v", err)
@@ -5220,13 +5368,7 @@ func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Re
log.Printf("SUCCESS: Telegram webhook configured successfully: %s", webhookURL)
}
} else {
log.Printf("WARNING: WEBHOOK_BASE_URL not set. Webhook will not be configured automatically.")
}
integration, err := a.getTelegramIntegrationForUser(userID)
if err != nil {
sendErrorWithCORS(w, fmt.Sprintf("Failed to get updated integration: %v", err), http.StatusInternalServerError)
return
log.Printf("WARNING: WEBHOOK_BASE_URL not set or webhook_token missing. Webhook will not be configured automatically.")
}
w.Header().Set("Content-Type", "application/json")
@@ -5242,6 +5384,24 @@ func (a *App) getTodoistWebhookURLHandler(w http.ResponseWriter, r *http.Request
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Получаем webhook токен для пользователя
integration, err := a.getTelegramIntegrationForUser(userID)
if err != nil {
sendErrorWithCORS(w, fmt.Sprintf("Failed to get telegram integration: %v", err), http.StatusInternalServerError)
return
}
if integration.WebhookToken == nil || *integration.WebhookToken == "" {
sendErrorWithCORS(w, "Webhook token not available", http.StatusInternalServerError)
return
}
// Получаем base URL из env
baseURL := getEnv("WEBHOOK_BASE_URL", "")
if baseURL == "" {
@@ -5249,7 +5409,7 @@ func (a *App) getTodoistWebhookURLHandler(w http.ResponseWriter, r *http.Request
return
}
webhookURL := strings.TrimRight(baseURL, "/") + "/webhook/todoist"
webhookURL := strings.TrimRight(baseURL, "/") + "/webhook/todoist/" + *integration.WebhookToken
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{

View File

@@ -0,0 +1,17 @@
-- Migration: Add webhook_token to telegram_integrations
-- This allows identifying user by webhook URL token
-- Add webhook_token column to telegram_integrations
ALTER TABLE telegram_integrations
ADD COLUMN IF NOT EXISTS webhook_token VARCHAR(255);
-- Create unique index on webhook_token for fast lookups
CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_webhook_token
ON telegram_integrations(webhook_token)
WHERE webhook_token IS NOT NULL;
-- Generate webhook tokens for existing integrations
-- This will be handled by application code, but we ensure the column exists
COMMENT ON COLUMN telegram_integrations.webhook_token IS 'Unique token for webhook URL identification (e.g., /webhook/telegram/{token})';