Files
gochat/internal/channel/telegram/webhook_handler.go
T
2026-06-04 15:44:48 +08:00

265 lines
8.5 KiB
Go

package telegram
// TelegramWebhookHandler processes incoming Telegram webhook HTTP requests.
// Reference: Chatwoot's webhook controller for Telegram:
// - app/controllers/api/v1/accounts/channels/telegram_bots_controller.rb (CRUD)
// - Telegram webhook receives Update payloads at /webhooks/telegram/{bot_token}
//
// The handler:
// 1. Receives raw Update JSON from Telegram's webhook POST
// 2. Validates the update structure
// 3. Routes to appropriate processing (message, edited_message, callback_query)
// 4. Returns 200 OK to Telegram (required to prevent retries)
//
// Telegram webhook requirements:
// - Must respond within 10 seconds
// - Must return 200 OK even on processing errors (async processing recommended)
// - Updates are sent as JSON POST to the webhook URL
// - Supported update types: message, edited_message, callback_query
import (
"encoding/json"
"io"
"net/http"
channelprovider "github.com/gochat/gochat/internal/channel/provider"
"github.com/gochat/gochat/internal/model"
applogger "github.com/gochat/gochat/pkg/logger"
)
// WebhookHandler processes Telegram webhook requests.
type WebhookHandler struct {
provider *channelprovider.TelegramProvider
// db would be injected here for inbox lookup
// inboxRepo InboxRepository
}
// NewWebhookHandler creates a Telegram webhook handler.
func NewWebhookHandler(provider *channelprovider.TelegramProvider) *WebhookHandler {
return &WebhookHandler{
provider: provider,
}
}
// HandleWebhookRequest processes an incoming Telegram webhook HTTP request.
// This is the Gin HTTP handler function.
//
// URL pattern: /webhooks/telegram/:bot_token
// Method: POST
// Content-Type: application/json
//
// Reference: Chatwoot routes Telegram webhooks at:
// post '/webhooks/telegram/:bot_token' => 'telegram_bots#process_message'
//
// Flow:
// 1. Parse bot_token from URL path → lookup Inbox + ChannelTelegram
// 2. Read Update JSON from request body
// 3. Dispatch to appropriate processing pipeline
// 4. Return 200 OK immediately (Telegram requires fast response)
func (h *WebhookHandler) HandleWebhookRequest(w http.ResponseWriter, r *http.Request) {
// Step 1: Extract bot_token from URL path
// In Gin: c.Param("bot_token")
botToken := extractBotTokenFromPath(r.URL.Path)
if botToken == "" {
applogger.L().Warn("Telegram webhook: missing bot_token in path")
w.WriteHeader(http.StatusOK) // still return 200 to prevent Telegram retries
return
}
// Step 2: Read request body
body, err := io.ReadAll(r.Body)
if err != nil {
applogger.L().Error("Telegram webhook: failed to read body", "error", err)
w.WriteHeader(http.StatusOK)
return
}
defer r.Body.Close()
// Step 3: Parse the Telegram Update
var update TelegramUpdate
if err := json.Unmarshal(body, &update); err != nil {
applogger.L().Error("Telegram webhook: failed to parse update", "error", err)
w.WriteHeader(http.StatusOK)
return
}
applogger.L().Info("Telegram webhook received",
"bot_token_prefix", maskBotToken(botToken),
"update_id", update.UpdateID,
)
// Step 4: Look up Inbox for this bot_token
// In production: h.inboxRepo.FindByBotToken(botToken)
inbox, err := h.lookupInbox(botToken)
if err != nil {
applogger.L().Error("Telegram webhook: inbox lookup failed", "error", err)
w.WriteHeader(http.StatusOK)
return
}
// Step 5: Dispatch update for async processing
// Reference: Chatwoot processes updates asynchronously via IncomingMessageService
// We should process asynchronously to respond within 10 seconds
payload := map[string]interface{}{}
if err := json.Unmarshal(body, &payload); err != nil {
applogger.L().Error("Telegram webhook: failed to re-parse payload", "error", err)
w.WriteHeader(http.StatusOK)
return
}
// Process the incoming message via the provider pipeline
incomingMsg, err := h.provider.ProcessIncomingMessage(r.Context(), inbox, payload)
if err != nil {
applogger.L().Error("Telegram webhook: message processing failed",
"update_id", update.UpdateID,
"error", err,
)
} else if incomingMsg != nil {
// Enqueue for downstream pipeline processing
// Reference: Chatwoot enqueues to the message pipeline after parsing
applogger.L().Info("Telegram webhook: message processed",
"inbox_id", incomingMsg.InboxID,
"source_id", incomingMsg.SourceID,
"content_type", incomingMsg.ContentType,
)
// TODO: Push to message broker/dispatcher for persistence + notification
}
// Step 6: Always return 200 OK to Telegram
// Telegram retries webhook delivery if response is not 200
// Reference: https://core.telegram.org/bots/api#making-requests
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
// HandleCallbackQuery processes a Telegram callback query specially.
// Reference: Chatwoot handles callback_query for input_select responses
// where the user clicks an inline keyboard button.
func (h *WebhookHandler) HandleCallbackQuery(w http.ResponseWriter, r *http.Request) {
// Same flow as HandleWebhookRequest but specialized for callback queries
// The main handler already handles both types
h.HandleWebhookRequest(w, r)
}
// === Helper Functions ===
// extractBotTokenFromPath extracts the bot_token from the webhook URL path.
// URL pattern: /webhooks/telegram/{bot_token}
func extractBotTokenFromPath(path string) string {
// Expected: /webhooks/telegram/123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
parts := splitPath(path)
if len(parts) >= 4 && parts[2] == "telegram" {
return parts[3]
}
return ""
}
// splitPath splits a URL path into segments.
func splitPath(path string) []string {
result := []string{}
for _, seg := range splitString(path, "/") {
if seg != "" {
result = append(result, seg)
}
}
return result
}
// splitString splits a string by a delimiter.
func splitString(s, delim string) []string {
result := []string{}
start := 0
for i := 0; i < len(s); i++ {
if s[i:i+1] == delim {
result = append(result, s[start:i])
start = i + 1
}
}
result = append(result, s[start:])
return result
}
// maskBotToken masks a bot token for safe logging (show first 8 chars only).
func maskBotToken(token string) string {
if len(token) > 8 {
return token[:8] + "..."
}
return token
}
// lookupInbox finds the Inbox associated with a Telegram bot token.
// In production: queries GORM for Inbox with channel_type=telegram matching the bot_token.
// Reference: Chatwoot: Inbox.find_by(channel: telegram, bot_token: token)
func (h *WebhookHandler) lookupInbox(botToken string) (*model.Inbox, error) {
// TODO: Implement with GORM repository
// Placeholder — production would use:
// var inbox model.Inbox
// var ch channelmodel.ChannelTelegram
// db.Where("bot_token = ?", botToken).First(&ch)
// db.Where("id = ?", ch.InboxID).First(&inbox)
//
// For now, return a placeholder inbox
inbox := &model.Inbox{
ChannelType: "telegram",
}
return inbox, nil
}
// === Gin Integration ===
// RegisterWebhookRoutes registers Telegram webhook routes with a Gin router.
// Reference: Chatwoot: post '/webhooks/telegram/:bot_token'
//
// Usage:
// router.POST("/webhooks/telegram/:bot_token", handler.GinHandler())
func (h *WebhookHandler) GinHandler() func(interface{}) {
// Returns a Gin handler function
// In production:
// return func(c *gin.Context) {
// botToken := c.Param("bot_token")
// ... process update ...
// }
return func(c interface{}) {
// Placeholder for Gin integration
applogger.L().Info("Telegram Gin webhook handler called")
}
}
// === Telegram Update Type (for webhook handler) ===
type TelegramUpdate struct {
UpdateID int64 `json:"update_id"`
Message *TelegramMessage `json:"message,omitempty"`
EditedMessage *TelegramMessage `json:"edited_message,omitempty"`
CallbackQuery *TelegramCallbackQuery `json:"callback_query,omitempty"`
}
type TelegramMessage struct {
MessageID int64 `json:"message_id"`
From *TelegramUser `json:"from,omitempty"`
Chat *TelegramChat `json:"chat"`
Date int64 `json:"date"`
Text string `json:"text,omitempty"`
}
type TelegramUser struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name,omitempty"`
Username string `json:"username,omitempty"`
}
type TelegramChat struct {
ID int64 `json:"id"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
}
type TelegramCallbackQuery struct {
ID string `json:"id"`
From *TelegramUser `json:"from"`
Message *TelegramMessage `json:"message,omitempty"`
Data string `json:"data,omitempty"`
}