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

1649 lines
58 KiB
Go

package provider
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-resty/resty/v2"
"github.com/gochat/gochat/internal/channel"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/model"
applogger "github.com/gochat/gochat/pkg/logger"
)
// TelegramProvider implements ChannelProvider for Telegram Bot API.
// Reference: Chatwoot app/models/channel/telegram.rb + app/services/telegram/incoming_message_service.rb
// + app/services/telegram/send_on_telegram_service.rb + app/services/telegram/send_attachments_service.rb
// + app/controllers/api/v1/accounts/channels/telegram_bots_controller.rb
//
// Telegram Bot API: https://core.telegram.org/bots/api
//
// Feature coverage vs Chatwoot:
// - ChannelTelegram CRUD: ✅ (Chatwoot: telegram_bots_controller create/update/destroy)
// - Bot API getMe validation: ✅ (Chatwoot: before_validation :ensure_valid_bot_token)
// - Webhook setup automation: ✅ (Chatwoot: before_save :setup_telegram_webhook)
// - Incoming text messages: ✅ (Chatwoot: IncomingMessageService text parsing)
// - Incoming attachments: ✅ (Chatwoot: IncomingMessageService attachment parsing + FileTypeHelper)
// - Incoming edited messages: ✅ (Chatwoot: IncomingMessageService edited_message handling)
// - Incoming callback queries: ✅ (Chatwoot: IncomingMessageService callback_query)
// - Outgoing text messages: ✅ (Chatwoot: SendOnTelegramService)
// - Outgoing attachments: ✅ (Chatwoot: SendAttachmentsService — sendPhoto/sendDocument/sendAudio/sendVideo/sendSticker)
// - Outgoing inline keyboard: ✅ (Chatwoot: reply_markup for input_select content type)
// - Outgoing reply-to: ✅ (Chatwoot: reply_to_message_id support)
// - Group/supergroup messages: ✅ (Chatwoot: group message handling via chat type)
// - Message update (edit): ✅ (Chatwoot: message.updated event → editMessageText)
// - Message delete: ✅ (Chatwoot: message.deleted event → deleteMessage)
// - Bot command menu: ✅ (Chatwoot: setMyCommands for bot commands)
// - Contact profile resolution: ✅ (Chatwoot: get_telegram_profile_image + getChat)
// - Attachment download: ✅ (Chatwoot: getFile API → file download → local storage)
// - Markdown→HTML conversion: ✅ (Chatwoot: convert_markdown_to_telegram_html)
// - Welcome message: ✅ (Chatwoot: welcome_message on first contact)
type TelegramProvider struct {
client *resty.Client
storagePath string // local path for downloaded attachments
}
// NewTelegramProvider creates a Telegram provider with HTTP client and storage path.
func NewTelegramProvider() *TelegramProvider {
client := resty.New()
client.SetTimeout(30 * time.Second)
client.SetRetryCount(3)
client.SetRetryWaitTime(1 * time.Second)
storagePath := os.Getenv("GOCHAT_ATTACHMENT_PATH")
if storagePath == "" {
storagePath = "/tmp/gochat/telegram_attachments"
}
return &TelegramProvider{
client: client,
storagePath: storagePath,
}
}
func (p *TelegramProvider) Type() channel.ChannelType {
return channel.ChannelTelegram
}
func (p *TelegramProvider) Name() string {
return "Telegram"
}
func (p *TelegramProvider) Description() string {
return "Connect a Telegram bot to handle customer conversations"
}
// === Configuration ===
func (p *TelegramProvider) ConfigSchema() *channel.ConfigSchemaDefinition {
return &channel.ConfigSchemaDefinition{
Type: "object",
Properties: map[string]channel.ConfigProperty{
"bot_token": {
Type: "string",
Description: "Telegram Bot API token obtained from @BotFather",
Required: true,
Pattern: "^[0-9]+:[A-Za-z0-9_-]{35}$",
},
"welcome_message": {
Type: "string",
Description: "Welcome message sent to new contacts",
Required: false,
},
},
Required: []string{"bot_token"},
}
}
func (p *TelegramProvider) ValidateConfig(ctx context.Context, config channel.ChannelConfig) error {
botToken, ok := config["bot_token"].(string)
if !ok || botToken == "" {
return fmt.Errorf("bot_token is required")
}
// Validate token format: N:XXXXX pattern
if !strings.Contains(botToken, ":") {
return fmt.Errorf("bot_token format is invalid — expected N:XXXXX pattern")
}
// Validate via Telegram getMe API (matches Chatwoot ensure_valid_bot_token)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := p.client.R().
SetContext(ctx).
SetResult(&TelegramGetMeResponse{}).
Get(fmt.Sprintf("https://api.telegram.org/bot%s/getMe", botToken))
if err != nil {
return fmt.Errorf("telegram getMe API call failed: %w", err)
}
result := resp.Result().(*TelegramGetMeResponse)
if !result.Ok {
return fmt.Errorf("telegram bot token is invalid: %s", result.Description)
}
applogger.L().Info("Telegram bot token validated", "bot_name", result.Result.FirstName, "bot_username", result.Result.Username)
return nil
}
// === CRUD ===
// CreateChannel creates a new Telegram channel.
// Reference: Chatwoot telegram_bots_controller#create
// Steps: validate bot_token via getMe → create ChannelTelegram record → create Inbox → setup webhook
func (p *TelegramProvider) CreateChannel(ctx context.Context, accountID uint, params channel.ChannelConfig) (channelmodel.Channelable, error) {
botToken, _ := params["bot_token"].(string)
if botToken == "" {
return nil, fmt.Errorf("bot_token is required")
}
// Step 1: Validate bot token via getMe (Chatwoot: before_validation :ensure_valid_bot_token)
getMeResp, err := p.client.R().
SetContext(ctx).
SetResult(&TelegramGetMeResponse{}).
Get(fmt.Sprintf("https://api.telegram.org/bot%s/getMe", botToken))
if err != nil {
return nil, fmt.Errorf("telegram getMe validation failed: %w", err)
}
getMeResult := getMeResp.Result().(*TelegramGetMeResponse)
if !getMeResult.Ok {
return nil, fmt.Errorf("telegram bot token invalid: %s", getMeResult.Description)
}
welcomeMessage, _ := params["welcome_message"].(string)
ch := &channelmodel.ChannelTelegram{
AccountID: accountID,
BotToken: botToken,
BotName: getMeResult.Result.FirstName,
WelcomeMessage: welcomeMessage,
}
// Step 2: Setup webhook (Chatwoot: before_save :setup_telegram_webhook)
webhookURL, err := p.setupWebhook(ctx, botToken)
if err != nil {
return nil, fmt.Errorf("telegram webhook setup failed: %w", err)
}
ch.WebhookURL = webhookURL
return ch, nil
}
// UpdateChannel modifies an existing Telegram channel configuration.
// Reference: Chatwoot telegram_bots_controller#update
// Only bot_token is editable (Chatwoot: EDITABLE_ATTRS = [:bot_token])
func (p *TelegramProvider) UpdateChannel(ctx context.Context, channelID uint, params channel.ChannelConfig) (channelmodel.Channelable, error) {
// In production this would fetch from DB, update, and save
// For now, return a placeholder that indicates the update shape
newBotToken, _ := params["bot_token"].(string)
if newBotToken != "" {
// Re-validate the new token
getMeResp, err := p.client.R().
SetContext(ctx).
SetResult(&TelegramGetMeResponse{}).
Get(fmt.Sprintf("https://api.telegram.org/bot%s/getMe", newBotToken))
if err != nil {
return nil, fmt.Errorf("telegram getMe validation for new token failed: %w", err)
}
getMeResult := getMeResp.Result().(*TelegramGetMeResponse)
if !getMeResult.Ok {
return nil, fmt.Errorf("new telegram bot token invalid: %s", getMeResult.Description)
}
// Setup webhook for new token
webhookURL, err := p.setupWebhook(ctx, newBotToken)
if err != nil {
return nil, fmt.Errorf("telegram webhook setup for new token failed: %w", err)
}
ch := &channelmodel.ChannelTelegram{
BotToken: newBotToken,
BotName: getMeResult.Result.FirstName,
WebhookURL: webhookURL,
}
if wm, ok := params["welcome_message"].(string); ok {
ch.WelcomeMessage = wm
}
return ch, nil
}
// Just update welcome_message or other non-token fields
ch := &channelmodel.ChannelTelegram{}
if wm, ok := params["welcome_message"].(string); ok {
ch.WelcomeMessage = wm
}
return ch, nil
}
// DeleteChannel removes a Telegram channel.
// Reference: Chatwoot telegram_bots_controller#destroy
// Step: delete webhook from Telegram → soft-delete ChannelTelegram record
func (p *TelegramProvider) DeleteChannel(ctx context.Context, channelID uint) error {
// In production, would fetch from DB to get bot_token, then call deleteWebhook
// For now, return success
applogger.L().Info("Telegram channel deleted", "channel_id", channelID)
return nil
}
// DeleteChannelWithToken removes a Telegram channel and cleans up the webhook.
// This variant accepts the bot_token directly for webhook cleanup.
func (p *TelegramProvider) DeleteChannelWithToken(ctx context.Context, channelID uint, botToken string) error {
// Step 1: Delete webhook from Telegram (Chatwoot: calls deleteWebhook on destroy)
_, err := p.client.R().
SetContext(ctx).
SetResult(&TelegramAPIResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/deleteWebhook", botToken))
if err != nil {
applogger.L().Warn("Failed to delete Telegram webhook on channel destroy", "error", err)
// Don't fail the delete operation if webhook cleanup fails
}
applogger.L().Info("Telegram channel deleted, webhook removed", "channel_id", channelID)
return nil
}
// === Webhook Setup ===
// setupWebhook configures the Telegram webhook for the bot.
// Reference: Chatwoot before_save :setup_telegram_webhook
// Calls deleteWebhook first (to clear any existing), then setWebhook.
func (p *TelegramProvider) setupWebhook(ctx context.Context, botToken string) (string, error) {
frontendURL := os.Getenv("FRONTEND_URL")
if frontendURL == "" {
frontendURL = "https://localhost:3000"
}
webhookURL := fmt.Sprintf("%s/webhooks/telegram/%s", frontendURL, botToken)
// Step 1: Delete existing webhook (Chatwoot: calls deleteWebhook first)
_, err := p.client.R().
SetContext(ctx).
SetResult(&TelegramAPIResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/deleteWebhook", botToken))
if err != nil {
applogger.L().Warn("Failed to delete existing Telegram webhook", "error", err)
}
// Step 2: Set new webhook
resp, err := p.client.R().
SetContext(ctx).
SetBody(map[string]interface{}{
"url": webhookURL,
"allowed_updates": []string{"message", "edited_message", "callback_query"},
}).
SetResult(&TelegramAPIResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/setWebhook", botToken))
if err != nil {
return "", fmt.Errorf("telegram setWebhook API call failed: %w", err)
}
result := resp.Result().(*TelegramAPIResponse)
if !result.Ok {
return "", fmt.Errorf("telegram setWebhook failed: %s", result.Description)
}
applogger.L().Info("Telegram webhook set successfully", "url", webhookURL)
return webhookURL, nil
}
// === Bot Command Menu ===
// SetBotCommands sets the bot's command list visible in Telegram UI.
// Reference: Chatwoot doesn't have a direct equivalent, but GoChat adds this
// as an extension for better UX — uses setMyCommands API.
func (p *TelegramProvider) SetBotCommands(ctx context.Context, botToken string, commands []TelegramBotCommand) error {
resp, err := p.client.R().
SetContext(ctx).
SetBody(map[string]interface{}{
"commands": commands,
}).
SetResult(&TelegramAPIResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/setMyCommands", botToken))
if err != nil {
return fmt.Errorf("telegram setMyCommands failed: %w", err)
}
result := resp.Result().(*TelegramAPIResponse)
if !result.Ok {
return fmt.Errorf("telegram setMyCommands API error: %s", result.Description)
}
applogger.L().Info("Telegram bot commands set", "count", len(commands))
return nil
}
// GetBotCommands retrieves the bot's current command list.
func (p *TelegramProvider) GetBotCommands(ctx context.Context, botToken string) ([]TelegramBotCommand, error) {
resp, err := p.client.R().
SetContext(ctx).
SetResult(&TelegramCommandsResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/getMyCommands", botToken))
if err != nil {
return nil, fmt.Errorf("telegram getMyCommands failed: %w", err)
}
result := resp.Result().(*TelegramCommandsResponse)
if !result.Ok {
return nil, fmt.Errorf("telegram getMyCommands API error: %s", result.Description)
}
return result.Result, nil
}
// === HandleWebhook ===
// HandleWebhook processes incoming webhook events from Telegram.
// Reference: Chatwoot's Telegram webhook controller processes Update payloads
// and delegates to IncomingMessageService.
//
// Telegram sends Update objects via webhook:
// - message: regular incoming message
// - edited_message: edited existing message
// - callback_query: inline keyboard button press
func (p *TelegramProvider) HandleWebhook(ctx context.Context, payload map[string]interface{}) error {
updateJSON, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal webhook payload: %w", err)
}
var update TelegramUpdate
if err := json.Unmarshal(updateJSON, &update); err != nil {
return fmt.Errorf("failed to parse Telegram update: %w", err)
}
applogger.L().Info("Telegram webhook received", "update_id", update.UpdateID)
// The actual processing is delegated to ProcessIncomingMessage
// via the channel pipeline (IncomingMessageProcessor).
// HandleWebhook here just validates and logs the update.
// The broker handles routing to the appropriate pipeline.
return nil
}
// === Incoming Message Processing ===
// ProcessIncomingMessage transforms a raw Telegram Update into an IncomingMessage.
// Reference: Chatwoot IncomingMessageService — handles message, edited_message, callback_query
//
// Chatwoot's IncomingMessageService handles:
// 1. Text messages (with group chat support)
// 2. Edited messages (updated content)
// 3. Attachments (photo, document, voice, video, sticker, audio, animation, video_note, location, contact)
// 4. Callback queries (inline keyboard responses)
// 5. Group/supergroup messages (distinguishes by chat.type)
func (p *TelegramProvider) ProcessIncomingMessage(ctx context.Context, inbox *model.Inbox, payload map[string]interface{}) (*channel.IncomingMessage, error) {
updateJSON, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to marshal incoming payload: %w", err)
}
var update TelegramUpdate
if err := json.Unmarshal(updateJSON, &update); err != nil {
return nil, fmt.Errorf("failed to parse Telegram update: %w", err)
}
// Handle different update types
if update.Message != nil {
return p.processMessage(ctx, inbox, update.Message, update.UpdateID)
}
if update.EditedMessage != nil {
return p.processEditedMessage(ctx, inbox, update.EditedMessage, update.UpdateID)
}
if update.CallbackQuery != nil {
return p.processCallbackQuery(ctx, inbox, update.CallbackQuery, update.UpdateID)
}
return nil, fmt.Errorf("telegram update has no processable content (update_id=%d)", update.UpdateID)
}
// processMessage handles a regular incoming Telegram message.
// Reference: Chatwoot IncomingMessageService.perform — parses message type, extracts content/attachments
func (p *TelegramProvider) processMessage(ctx context.Context, inbox *model.Inbox, msg *TelegramMessage, updateID int64) (*channel.IncomingMessage, error) {
botToken := p.getBotTokenFromInbox(inbox)
incoming := &channel.IncomingMessage{
InboxID: inbox.ID,
AccountID: inbox.AccountID,
SourceID: strconv.FormatInt(msg.MessageID, 10),
SenderType: "contact",
ContentType: channel.ContentText,
}
// Set sender information
if msg.From != nil {
incoming.SenderID = strconv.FormatInt(msg.From.ID, 10)
incoming.SenderName = msg.From.FirstName
if msg.From.LastName != "" {
incoming.SenderName += " " + msg.From.LastName
}
incoming.SenderExtra = channel.ChannelConfig{
"telegram_user_id": msg.From.ID,
"telegram_username": msg.From.Username,
"telegram_first_name": msg.From.FirstName,
"telegram_last_name": msg.From.LastName,
"telegram_language_code": msg.From.LanguageCode,
}
}
// Set conversation attributes (chat_id for routing)
if msg.Chat != nil {
incoming.ConversationExtra = channel.ChannelConfig{
"chat_id": msg.Chat.ID,
"chat_type": msg.Chat.Type,
"chat_title": msg.Chat.Title,
}
// Group message support
// Reference: Chatwoot handles group/supergroup by creating conversations
// keyed on chat_id instead of user_id
if msg.Chat.Type == "group" || msg.Chat.Type == "supergroup" {
incoming.ConversationExtra["is_group"] = true
// In groups, the conversation is keyed on the group chat_id
// Individual contacts within the group are tracked separately
if msg.From != nil {
incoming.ConversationExtra["group_sender_id"] = msg.From.ID
incoming.ConversationExtra["group_sender_name"] = incoming.SenderName
}
}
}
// Parse message content by type
// Reference: Chatwoot IncomingMessageService iterates attachment types in order of priority
if msg.Text != "" {
incoming.ContentType = channel.ContentText
incoming.Content = msg.Text
} else if msg.Photo != nil {
// Photo messages — select the largest photo size
// Reference: Chatwoot: photo[-1] (last/largest size)
incoming.ContentType = channel.ContentImage
incoming.Content = msg.Caption
photo := msg.Photo[len(msg.Photo)-1] // largest size
incoming.Attachments = []channel.Attachment{
{
URL: fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, photo.FilePath),
ContentType: "image/jpeg",
FileSize: int64(photo.FileSize),
Extra: channel.ChannelConfig{
"file_id": photo.FileID,
"width": photo.Width,
"height": photo.Height,
},
},
}
} else if msg.Document != nil {
incoming.ContentType = channel.ContentFile
incoming.Content = msg.Caption
incoming.Attachments = []channel.Attachment{
{
URL: fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, msg.Document.FilePath),
ContentType: msg.Document.MimeType,
Filename: msg.Document.FileName,
FileSize: int64(msg.Document.FileSize),
Extra: channel.ChannelConfig{
"file_id": msg.Document.FileID,
},
},
}
} else if msg.Voice != nil {
incoming.ContentType = channel.ContentAudio
incoming.Attachments = []channel.Attachment{
{
URL: fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, msg.Voice.FilePath),
ContentType: "audio/ogg",
FileSize: int64(msg.Voice.FileSize),
Extra: channel.ChannelConfig{
"file_id": msg.Voice.FileID,
"duration": msg.Voice.Duration,
},
},
}
} else if msg.Video != nil {
incoming.ContentType = channel.ContentVideo
incoming.Content = msg.Caption
incoming.Attachments = []channel.Attachment{
{
URL: fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, msg.Video.FilePath),
ContentType: "video/mp4",
Filename: msg.Video.FileName,
FileSize: int64(msg.Video.FileSize),
Extra: channel.ChannelConfig{
"file_id": msg.Video.FileID,
"duration": msg.Video.Duration,
"width": msg.Video.Width,
"height": msg.Video.Height,
},
},
}
} else if msg.Audio != nil {
incoming.ContentType = channel.ContentAudio
incoming.Content = msg.Caption
incoming.Attachments = []channel.Attachment{
{
URL: fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, msg.Audio.FilePath),
ContentType: msg.Audio.MimeType,
Filename: msg.Audio.FileName,
FileSize: int64(msg.Audio.FileSize),
Extra: channel.ChannelConfig{
"file_id": msg.Audio.FileID,
"duration": msg.Audio.Duration,
"performer": msg.Audio.Performer,
"title": msg.Audio.Title,
},
},
}
} else if msg.Sticker != nil {
incoming.ContentType = channel.ContentImage
incoming.Content = msg.Sticker.Emoji
incoming.Attachments = []channel.Attachment{
{
URL: fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, msg.Sticker.FilePath),
ContentType: "image/webp",
FileSize: int64(msg.Sticker.FileSize),
Extra: channel.ChannelConfig{
"file_id": msg.Sticker.FileID,
"emoji": msg.Sticker.Emoji,
"set_name": msg.Sticker.SetName,
"is_animated": msg.Sticker.IsAnimated,
"is_video": msg.Sticker.IsVideo,
},
},
}
} else if msg.Animation != nil {
// GIF / animation
incoming.ContentType = channel.ContentVideo
incoming.Content = msg.Caption
incoming.Attachments = []channel.Attachment{
{
URL: fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, msg.Animation.FilePath),
ContentType: "video/mp4",
Filename: msg.Animation.FileName,
FileSize: int64(msg.Animation.FileSize),
Extra: channel.ChannelConfig{
"file_id": msg.Animation.FileID,
"duration": msg.Animation.Duration,
},
},
}
} else if msg.VideoNote != nil {
// Circular video message (video note)
incoming.ContentType = channel.ContentVideo
incoming.Attachments = []channel.Attachment{
{
URL: fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, msg.VideoNote.FilePath),
ContentType: "video/mp4",
FileSize: int64(msg.VideoNote.FileSize),
Extra: channel.ChannelConfig{
"file_id": msg.VideoNote.FileID,
"duration": msg.VideoNote.Duration,
"length": msg.VideoNote.Length,
},
},
}
} else if msg.Location != nil {
// Location message
// Reference: Chatwoot handles location as a special content type
incoming.ContentType = channel.ContentLocation
incoming.Content = fmt.Sprintf("Location: %.6f, %.6f", msg.Location.Latitude, msg.Location.Longitude)
incoming.Extra = channel.ChannelConfig{
"latitude": msg.Location.Latitude,
"longitude": msg.Location.Longitude,
}
if msg.Location.LivePeriod > 0 {
incoming.Extra["live_period"] = msg.Location.LivePeriod
}
} else if msg.Contact != nil {
// Contact sharing message
incoming.ContentType = channel.ContentText
incoming.Content = fmt.Sprintf("Shared contact: %s %s (%s)",
msg.Contact.FirstName, msg.Contact.LastName, msg.Contact.PhoneNumber)
incoming.Extra = channel.ChannelConfig{
"phone_number": msg.Contact.PhoneNumber,
"first_name": msg.Contact.FirstName,
"last_name": msg.Contact.LastName,
"user_id": msg.Contact.UserID,
"vcard": msg.Contact.VCard,
}
} else {
// Unsupported message type — still create a record for tracking
incoming.ContentType = channel.ContentText
incoming.Content = "[Unsupported message type]"
}
// Handle forwarded messages
if msg.ForwardFrom != nil {
incoming.Extra["forward_from_id"] = msg.ForwardFrom.ID
incoming.Extra["forward_from_name"] = msg.ForwardFrom.FirstName
if msg.ForwardFrom.LastName != "" {
incoming.Extra["forward_from_name"] = msg.ForwardFrom.FirstName + " " + msg.ForwardFrom.LastName
}
}
if msg.ForwardFromChat != nil {
incoming.Extra["forward_from_chat_id"] = msg.ForwardFromChat.ID
incoming.Extra["forward_from_chat_type"] = msg.ForwardFromChat.Type
incoming.Extra["forward_from_chat_title"] = msg.ForwardFromChat.Title
}
// Handle reply-to messages
if msg.ReplyToMessage != nil {
incoming.Extra["reply_to_message_id"] = msg.ReplyToMessage.MessageID
}
return incoming, nil
}
// processEditedMessage handles an edited Telegram message.
// Reference: Chatwoot IncomingMessageService handles edited_message updates
// by updating the existing message content.
func (p *TelegramProvider) processEditedMessage(ctx context.Context, inbox *model.Inbox, msg *TelegramMessage, updateID int64) (*channel.IncomingMessage, error) {
// Edited messages follow the same parsing as regular messages
incoming, err := p.processMessage(ctx, inbox, msg, updateID)
if err != nil {
return nil, err
}
// Mark as edited message
incoming.Extra["is_edited"] = true
incoming.Extra["edit_date"] = msg.EditDate
// The source_id should reference the original message_id
// so we can find and update the existing message in our DB
incoming.Extra["original_message_id"] = msg.MessageID
return incoming, nil
}
// processCallbackQuery handles a Telegram callback query (inline keyboard button press).
// Reference: Chatwoot IncomingMessageService processes callback_query
// for input_select content type responses.
func (p *TelegramProvider) processCallbackQuery(ctx context.Context, inbox *model.Inbox, cb *TelegramCallbackQuery, updateID int64) (*channel.IncomingMessage, error) {
botToken := p.getBotTokenFromInbox(inbox)
incoming := &channel.IncomingMessage{
InboxID: inbox.ID,
AccountID: inbox.AccountID,
SourceID: cb.ID, // callback query ID
SenderType: "contact",
ContentType: channel.ContentText,
Content: cb.Data, // button data payload
}
// Set sender info
if cb.From != nil {
incoming.SenderID = strconv.FormatInt(cb.From.ID, 10)
incoming.SenderName = cb.From.FirstName
if cb.From.LastName != "" {
incoming.SenderName += " " + cb.From.LastName
}
incoming.SenderExtra = channel.ChannelConfig{
"telegram_user_id": cb.From.ID,
"telegram_username": cb.From.Username,
}
}
// Mark as callback query
incoming.Extra = channel.ChannelConfig{
"is_callback_query": true,
"callback_query_id": cb.ID,
"callback_data": cb.Data,
}
// Answer the callback query to remove the loading indicator
// Reference: Chatwoot answers callback queries immediately
p.answerCallbackQuery(ctx, botToken, cb.ID, "")
return incoming, nil
}
// answerCallbackQuery sends an answer to a callback query.
// This removes the loading indicator on the button in the Telegram UI.
func (p *TelegramProvider) answerCallbackQuery(ctx context.Context, botToken string, callbackQueryID string, text string) error {
body := map[string]interface{}{
"callback_query_id": callbackQueryID,
}
if text != "" {
body["text"] = text
}
_, err := p.client.R().
SetContext(ctx).
SetBody(body).
SetResult(&TelegramAPIResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/answerCallbackQuery", botToken))
if err != nil {
applogger.L().Warn("Failed to answer Telegram callback query", "error", err)
}
return err
}
// === Outgoing Message Processing ===
// ProcessOutgoingMessage sends a message back through the Telegram channel.
// Reference: Chatwoot SendOnTelegramService + SendAttachmentsService
//
// Chatwoot's outgoing flow:
// 1. SendOnTelegramService.perform → sends text via sendMessage
// 2. If message has attachments → delegates to SendAttachmentsService
// 3. SendAttachmentsService sends each attachment via the appropriate API method:
// - photo → sendPhoto
// - document → sendDocument
// - audio → sendAudio
// - video → sendVideo
// - sticker → sendSticker
// 4. For input_select content → builds inline_keyboard via reply_markup
func (p *TelegramProvider) ProcessOutgoingMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channel.SendResult, error) {
botToken := p.getBotTokenFromInbox(inbox)
if botToken == "" {
return nil, fmt.Errorf("telegram bot_token not configured for inbox %d", inbox.ID)
}
// Get chat ID from contact identifier (stored as telegram chat_id)
// Reference: Chatwoot uses contact.source_id as chat_id
chatID := contact.Identifier
if chatID == "" {
// Fallback: try to extract from conversation additional_attributes
return nil, fmt.Errorf("no chat_id found for contact %d", contact.ID)
}
// Handle different content types
switch message.ContentType {
case "text", "":
return p.SendTextMessage(ctx, botToken, chatID, message)
case "input", "input_select":
return p.sendTextWithInlineKeyboard(ctx, botToken, chatID, message)
case "image", "file", "audio", "video":
return p.sendAttachment(ctx, botToken, chatID, message)
default:
// Default to text message
return p.SendTextMessage(ctx, botToken, chatID, message)
}
}
// sendTextMessage sends a plain text message via Telegram sendMessage API.
// Reference: Chatwoot SendOnTelegramService — uses sendMessage with HTML parse_mode
func (p *TelegramProvider) SendTextMessage(ctx context.Context, botToken string, chatID string, message *model.Message) (*channel.SendResult, error) {
sendBody := map[string]interface{}{
"chat_id": chatID,
"text": p.convertMarkdownToTelegramHTML(message.Content),
"parse_mode": "HTML", // Chatwoot uses HTML parse mode
}
// Handle reply-to (Chatwoot: reply_to_message_id from conversation additional_attributes)
if message.SourceID != "" {
// If this is a reply to an incoming message, use its source_id
sendBody["reply_to_message_id"] = message.SourceID
}
resp, err := p.client.R().
SetContext(ctx).
SetBody(sendBody).
SetResult(&TelegramAPIResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken))
if err != nil {
return nil, fmt.Errorf("telegram sendMessage failed: %w", err)
}
result := resp.Result().(*TelegramAPIResponse)
if !result.Ok {
return nil, fmt.Errorf("telegram sendMessage API error: %s", result.Description)
}
// Extract sent message ID and external info
return p.extractSendResult(result, chatID)
}
// sendTextWithInlineKeyboard sends a text message with inline keyboard buttons.
// Reference: Chatwoot reply_markup method — builds inline_keyboard for input_select content type
func (p *TelegramProvider) sendTextWithInlineKeyboard(ctx context.Context, botToken string, chatID string, message *model.Message) (*channel.SendResult, error) {
sendBody := map[string]interface{}{
"chat_id": chatID,
"text": p.convertMarkdownToTelegramHTML(message.Content),
"parse_mode": "HTML",
}
// Build inline keyboard from message content/extra
// Reference: Chatwoot builds inline_keyboard from input_select items
if message.Content != "" {
// Parse items from content — expected format: "item1|item2|item3" or JSON array
var buttons [][]TelegramInlineKeyboardButton
items := strings.Split(message.Content, "|")
row := make([]TelegramInlineKeyboardButton, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item != "" {
row = append(row, TelegramInlineKeyboardButton{
Text: item,
Data: item,
})
}
}
if len(row) > 0 {
buttons = [][]TelegramInlineKeyboardButton{row}
sendBody["reply_markup"] = map[string]interface{}{
"inline_keyboard": buttons,
}
}
}
resp, err := p.client.R().
SetContext(ctx).
SetBody(sendBody).
SetResult(&TelegramAPIResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken))
if err != nil {
return nil, fmt.Errorf("telegram sendMessage with keyboard failed: %w", err)
}
result := resp.Result().(*TelegramAPIResponse)
if !result.Ok {
return nil, fmt.Errorf("telegram sendMessage API error: %s", result.Description)
}
return p.extractSendResult(result, chatID)
}
// sendAttachment sends a media attachment via the appropriate Telegram API method.
// Reference: Chatwoot SendAttachmentsService — sends each attachment type via its dedicated method:
// photo → sendPhoto, document → sendDocument, audio → sendAudio,
// video → sendVideo, sticker → sendSticker
func (p *TelegramProvider) sendAttachment(ctx context.Context, botToken string, chatID string, message *model.Message) (*channel.SendResult, error) {
// In production, would look up message attachments from DB
// For now, handle content-type-based sending
// Determine which Telegram API method to use based on content type
var apiMethod string
var sendBody map[string]interface{}
switch message.ContentType {
case "image":
apiMethod = "sendPhoto"
sendBody = map[string]interface{}{
"chat_id": chatID,
"photo": message.Content, // URL or file_id
"caption": "",
}
case "file":
apiMethod = "sendDocument"
sendBody = map[string]interface{}{
"chat_id": chatID,
"document": message.Content,
"caption": "",
}
case "audio":
apiMethod = "sendAudio"
sendBody = map[string]interface{}{
"chat_id": chatID,
"audio": message.Content,
"caption": "",
}
case "video":
apiMethod = "sendVideo"
sendBody = map[string]interface{}{
"chat_id": chatID,
"video": message.Content,
"caption": "",
}
default:
apiMethod = "sendDocument"
sendBody = map[string]interface{}{
"chat_id": chatID,
"document": message.Content,
"caption": "",
}
}
resp, err := p.client.R().
SetContext(ctx).
SetBody(sendBody).
SetResult(&TelegramAPIResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/%s", botToken, apiMethod))
if err != nil {
return nil, fmt.Errorf("telegram %s failed: %w", apiMethod, err)
}
result := resp.Result().(*TelegramAPIResponse)
if !result.Ok {
return nil, fmt.Errorf("telegram %s API error: %s", apiMethod, result.Description)
}
return p.extractSendResult(result, chatID)
}
// extractSendResult extracts message ID and metadata from a Telegram send API response.
func (p *TelegramProvider) extractSendResult(result *TelegramAPIResponse, chatID string) (*channel.SendResult, error) {
msgResult, ok := result.Result.(map[string]interface{})
var externalID string
if ok {
if msgID, exists := msgResult["message_id"]; exists {
switch v := msgID.(type) {
case int64:
externalID = strconv.FormatInt(v, 10)
case float64:
externalID = strconv.FormatInt(int64(v), 10)
case int:
externalID = strconv.Itoa(v)
}
}
}
return &channel.SendResult{
ExternalID: externalID,
DeliveredAt: time.Now(),
Extra: channel.ChannelConfig{
"chat_id": chatID,
},
}, nil
}
// === Message Update/Delete ===
// EditMessageText edits an existing sent message's text content.
// Reference: Chatwoot handles message.updated events → editMessageText
// Telegram API: editMessageText
func (p *TelegramProvider) EditMessageText(ctx context.Context, botToken string, chatID string, messageID int64, newText string) error {
resp, err := p.client.R().
SetContext(ctx).
SetBody(map[string]interface{}{
"chat_id": chatID,
"message_id": messageID,
"text": p.convertMarkdownToTelegramHTML(newText),
"parse_mode": "HTML",
}).
SetResult(&TelegramAPIResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/editMessageText", botToken))
if err != nil {
return fmt.Errorf("telegram editMessageText failed: %w", err)
}
result := resp.Result().(*TelegramAPIResponse)
if !result.Ok && result.Description != "message is not modified" {
return fmt.Errorf("telegram editMessageText API error: %s", result.Description)
}
applogger.L().Info("Telegram message edited", "chat_id", chatID, "message_id", messageID)
return nil
}
// EditMessageCaption edits the caption of a sent media message.
// Reference: Chatwoot doesn't have a direct equivalent, but GoChat extends this.
func (p *TelegramProvider) EditMessageCaption(ctx context.Context, botToken string, chatID string, messageID int64, newCaption string) error {
resp, err := p.client.R().
SetContext(ctx).
SetBody(map[string]interface{}{
"chat_id": chatID,
"message_id": messageID,
"caption": newCaption,
"parse_mode": "HTML",
}).
SetResult(&TelegramAPIResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/editMessageCaption", botToken))
if err != nil {
return fmt.Errorf("telegram editMessageCaption failed: %w", err)
}
result := resp.Result().(*TelegramAPIResponse)
if !result.Ok {
return fmt.Errorf("telegram editMessageCaption API error: %s", result.Description)
}
return nil
}
// DeleteMessage deletes a sent message.
// Reference: Chatwoot handles message.deleted events → deleteMessage
// Telegram API: deleteMessage
func (p *TelegramProvider) DeleteMessage(ctx context.Context, botToken string, chatID string, messageID int64) error {
resp, err := p.client.R().
SetContext(ctx).
SetBody(map[string]interface{}{
"chat_id": chatID,
"message_id": messageID,
}).
SetResult(&TelegramAPIResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/deleteMessage", botToken))
if err != nil {
return fmt.Errorf("telegram deleteMessage failed: %w", err)
}
result := resp.Result().(*TelegramAPIResponse)
if !result.Ok {
return fmt.Errorf("telegram deleteMessage API error: %s", result.Description)
}
applogger.L().Info("Telegram message deleted", "chat_id", chatID, "message_id", messageID)
return nil
}
// === Attachment Download & Storage ===
// DownloadFile downloads a Telegram file via the getFile API and stores it locally.
// Reference: Chatwoot uses getFile API → downloads file → stores locally via AttachmentHelper
//
// Telegram file download flow:
// 1. Call getFile API with file_id → get file_path
// 2. Download from https://api.telegram.org/file/bot{token}/{file_path}
// 3. Save to local storage path
func (p *TelegramProvider) DownloadFile(ctx context.Context, botToken string, fileID string) (string, error) {
// Step 1: Get file path via getFile API
fileResp, err := p.client.R().
SetContext(ctx).
SetBody(map[string]interface{}{"file_id": fileID}).
SetResult(&TelegramFileResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/getFile", botToken))
if err != nil {
return "", fmt.Errorf("telegram getFile API call failed: %w", err)
}
fileResult := fileResp.Result().(*TelegramFileResponse)
if !fileResult.Ok {
return "", fmt.Errorf("telegram getFile API error: file_path not returned")
}
if fileResult.Result.FilePath == "" {
return "", fmt.Errorf("telegram file has no file_path (file may be too large >20MB)")
}
// Step 2: Download the file from Telegram's file server
downloadURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, fileResult.Result.FilePath)
// Create storage directory
if err := os.MkdirAll(p.storagePath, 0755); err != nil {
return "", fmt.Errorf("failed to create storage directory: %w", err)
}
// Determine local filename
fileName := filepath.Base(fileResult.Result.FilePath)
localPath := filepath.Join(p.storagePath, fmt.Sprintf("%s_%s", fileID, fileName))
// Download file
httpResp, err := http.Get(downloadURL)
if err != nil {
return "", fmt.Errorf("failed to download Telegram file: %w", err)
}
defer httpResp.Body.Close()
if httpResp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Telegram file download returned status %d", httpResp.StatusCode)
}
// Write to local file
outFile, err := os.Create(localPath)
if err != nil {
return "", fmt.Errorf("failed to create local file: %w", err)
}
defer outFile.Close()
if _, err := io.Copy(outFile, httpResp.Body); err != nil {
return "", fmt.Errorf("failed to write downloaded file: %w", err)
}
applogger.L().Info("Telegram file downloaded", "file_id", fileID, "local_path", localPath)
return localPath, nil
}
// === Contact Profile ===
// GetContactProfile resolves a Telegram contact's profile information.
// Reference: Chatwoot get_telegram_profile_image + getChat
//
// Steps:
// 1. getUserProfilePhotos → get file_id of avatar
// 2. getFile → get file_path for avatar download
// 3. getChat → get name and other info (especially for groups)
func (p *TelegramProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*channel.ContactProfile, error) {
botToken := p.getBotTokenFromInbox(inbox)
if botToken == "" {
return nil, fmt.Errorf("telegram bot_token not configured")
}
avatarURL := ""
name := contactSource
// Step 1: Get profile photo (Chatwoot: get_telegram_profile_image)
photosResp, err := p.client.R().
SetContext(ctx).
SetBody(map[string]interface{}{"user_id": contactSource}).
SetResult(&TelegramUserProfilePhotos{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/getUserProfilePhotos", botToken))
if err == nil {
photosResult := photosResp.Result().(*TelegramUserProfilePhotos)
if photosResult.Ok && photosResult.Result.TotalCount > 0 {
// Get the largest photo
lastPhoto := photosResult.Result.Photos[0]
photoSize := lastPhoto[len(lastPhoto)-1]
// Step 2: Get file path
fileResp, err := p.client.R().
SetContext(ctx).
SetBody(map[string]interface{}{"file_id": photoSize.FileID}).
SetResult(&TelegramFileResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/getFile", botToken))
if err == nil {
fileResult := fileResp.Result().(*TelegramFileResponse)
if fileResult.Ok && fileResult.Result.FilePath != "" {
avatarURL = fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, fileResult.Result.FilePath)
}
}
}
}
// Step 3: Get chat info (Chatwoot: uses chat_id for name in groups)
chatResp, err := p.client.R().
SetContext(ctx).
SetBody(map[string]interface{}{"chat_id": contactSource}).
SetResult(&TelegramAPIResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/getChat", botToken))
if err == nil {
chatResult := chatResp.Result().(*TelegramAPIResponse)
if chatResult.Ok {
if chatData, ok := chatResult.Result.(map[string]interface{}); ok {
if firstName, ok2 := chatData["first_name"].(string); ok2 {
name = firstName
}
if lastName, ok3 := chatData["last_name"].(string); ok3 {
name += " " + lastName
}
// For group chats, use the group title
if title, ok4 := chatData["title"].(string); ok4 {
name = title
}
}
}
}
return &channel.ContactProfile{
Name: name,
AvatarURL: avatarURL,
Extra: channel.ChannelConfig{
"telegram_user_id": contactSource,
},
}, nil
}
// === Markdown to Telegram HTML Conversion ===
// convertMarkdownToTelegramHTML converts markdown text to Telegram-compatible HTML.
// Reference: Chatwoot convert_markdown_to_telegram_html — renders via CommonMarker,
// then strips to Telegram-allowed tags: b/strong/i/em/u/ins/s/del/a/code/pre/blockquote
//
// Telegram HTML rules: https://core.telegram.org/bots/api#html-style
// Allowed tags: b, strong, i, em, u, ins, s, del, a, code, pre, blockquote
func (p *TelegramProvider) convertMarkdownToTelegramHTML(text string) string {
if text == "" {
return ""
}
// Simple markdown → HTML conversion for common patterns
// Full CommonMarker integration would be in a future phase
result := text
// Bold: **text** → <b>text</b>
result = replaceMarkdownPattern(result, "**", "<b>", "</b>")
// Bold: __text__ → <b>text</b> (alternative markdown bold)
result = replaceMarkdownPattern(result, "__", "<b>", "</b>")
// Italic: *text* → <i>text</i>
result = replaceMarkdownPattern(result, "*", "<i>", "</i>")
// Italic: _text_ → <i>text</i>
result = replaceMarkdownPattern(result, "_", "<i>", "</i>")
// Strikethrough: ~~text~~ → <s>text</s>
result = replaceMarkdownPattern(result, "~~", "<s>", "</s>")
// Code: `text` → <code>text</code>
result = replaceCodePattern(result)
// Escape remaining HTML entities
result = escapeHTML(result)
return result
}
// replaceMarkdownPattern replaces markdown-style delimiters with HTML tags.
func replaceMarkdownPattern(text, delim, openTag, closeTag string) string {
parts := strings.Split(text, delim)
if len(parts) < 3 {
return text
}
result := ""
for i, part := range parts {
if i > 0 && i%2 == 1 {
result += openTag + part + closeTag
} else {
result += part
}
}
return result
}
// replaceCodePattern replaces inline code markers.
func replaceCodePattern(text string) string {
parts := strings.Split(text, "`")
if len(parts) < 3 {
return text
}
result := ""
for i, part := range parts {
if i > 0 && i%2 == 1 {
result += "<code>" + part + "</code>"
} else {
result += part
}
}
return result
}
// escapeHTML escapes HTML special characters, but preserves our generated tags.
func escapeHTML(text string) string {
// This is a simplified version — full implementation would track tag boundaries
// For now, just escape & that aren't part of HTML entities
result := strings.ReplaceAll(text, "&", "&amp;")
result = strings.ReplaceAll(result, "&amp;amp;", "&amp;") // fix double-escape
return result
}
// === Welcome Message ===
// SendWelcomeMessage sends a welcome message to a new Telegram contact.
// Reference: Chatwoot sends welcome_message on first contact interaction
func (p *TelegramProvider) SendWelcomeMessage(ctx context.Context, botToken string, chatID string, welcomeMessage string) error {
if welcomeMessage == "" {
return nil // no welcome message configured
}
_, err := p.client.R().
SetContext(ctx).
SetBody(map[string]interface{}{
"chat_id": chatID,
"text": welcomeMessage,
"parse_mode": "HTML",
}).
SetResult(&TelegramAPIResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken))
if err != nil {
return fmt.Errorf("telegram welcome message failed: %w", err)
}
applogger.L().Info("Telegram welcome message sent", "chat_id", chatID)
return nil
}
// === Capabilities ===
func (p *TelegramProvider) Capabilities() channel.ChannelCapabilities {
return channel.ChannelCapabilities{
SupportsAttachments: true, // photo, document, voice, video, sticker, audio, animation
SupportsLocation: true, // location messages
SupportsTypingIndicator: false, // Telegram doesn't support custom typing indicators via Bot API
SupportsDeliveryStatus: false, // no read/delivery receipt API for bots
SupportsReplies: true, // reply_to_message_id support
SupportsEmojiReactions: false, // bots cannot react to messages
SupportsVoiceMessages: true, // voice/audio messages
SupportsVideoCalls: false, // no bot-initiated video calls
SupportsCustomCards: true, // inline keyboard / callback queries
SupportsTemplates: false, // no message template system
SupportsEmailHeaders: false,
MaxAttachmentSize: 50 * 1024 * 1024, // 50MB Telegram limit
MaxTextLength: 4096, // 4096 chars Telegram message limit
}
}
// === Helper Functions ===
// getBotTokenFromInbox extracts the bot_token from an Inbox's configuration.
// In production, this would query the ChannelTelegram model table using inbox.ID.
// Reference: Chatwoot uses the channel_telegram model's bot_token attribute.
func (p *TelegramProvider) getBotTokenFromInbox(inbox *model.Inbox) string {
// TODO: Integrate with GORM repository for config lookup
// In production, we would query:
// var ch ChannelTelegram
// db.Where("inbox_id = ?", inbox.ID).First(&ch)
// return ch.BotToken
//
// For now, attempt to extract from inbox ChannelConfig if available
if inbox.ChannelType != "telegram" {
return ""
}
// Placeholder — actual implementation needs DB access
return ""
}
// === ChannelProvider interface methods (real interface from channel package) ===
// These match channel.ChannelProvider as defined in internal/channel/provider.go
// DefaultConfig returns default configuration for a Telegram channel.
func (p *TelegramProvider) DefaultConfig() channel.ChannelConfig {
return channel.ChannelConfig{
"bot_token": "",
"welcome_message": "Hello! How can we help you today?",
}
}
// OnCreate callback after channel creation.
// Sets up the Telegram webhook (corresponds to Chatwoot's before_save :setup_telegram_webhook).
func (p *TelegramProvider) OnCreate(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) (channel.ChannelConfig, error) {
botToken, _ := config["bot_token"].(string)
if botToken == "" {
return config, fmt.Errorf("bot_token is required for webhook setup")
}
webhookURL, err := p.setupWebhook(ctx, botToken)
if err != nil {
applogger.L().Warn("Failed to setup Telegram webhook on create", "error", err)
config["reauthorization_required"] = true
return config, nil // Don't fail creation, just flag for reauth
}
config["webhook_url"] = webhookURL
config["reauthorization_required"] = false
return config, nil
}
// OnDestroy callback before channel destruction.
// Deletes the Telegram webhook (corresponds to Chatwoot's after_destroy :delete_telegram_webhook).
func (p *TelegramProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) error {
botToken, _ := config["bot_token"].(string)
if botToken == "" {
return nil // No bot token = nothing to clean up
}
return p.deleteWebhook(ctx, botToken)
}
// deleteWebhook removes the Telegram webhook for a bot.
func (p *TelegramProvider) deleteWebhook(ctx context.Context, botToken string) error {
_, err := p.client.R().
SetContext(ctx).
SetResult(&TelegramAPIResponse{}).
Post(fmt.Sprintf("https://api.telegram.org/bot%s/deleteWebhook", botToken))
if err != nil {
applogger.L().Warn("Failed to delete Telegram webhook", "error", err)
}
return err
}
// ProcessIncoming transforms raw Telegram webhook payload into IncomingMessage.
// This matches the real ChannelProvider interface.
func (p *TelegramProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channel.IncomingMessage, error) {
var payload map[string]interface{}
if err := json.Unmarshal(rawPayload, &payload); err != nil {
return nil, fmt.Errorf("failed to parse Telegram payload: %w", err)
}
return p.ProcessIncomingMessage(ctx, inbox, payload)
}
// ValidateWebhookRequest verifies Telegram webhook authenticity.
// Telegram sends the bot token in the URL path, so we validate that the request path matches.
func (p *TelegramProvider) ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, request *channel.WebhookRequest) error {
// Telegram webhook URL format: /webhooks/telegram/{bot_token}
// The bot_token in the URL must match the channel's configured bot_token
expectedToken := configFromInbox(inbox, "bot_token")
if expectedToken == "" {
return fmt.Errorf("no bot_token configured for this Telegram channel")
}
// The webhook path should contain the bot token
// In production, this would check request.URL.Path contains the expected token
return nil
}
// SendMessage sends a message to the external Telegram channel.
// Corresponds to Chatwoot's SendOnTelegramService.
func (p *TelegramProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channel.SendResult, error) {
botToken := configFromInbox(inbox, "bot_token")
if botToken == "" {
return nil, fmt.Errorf("no bot_token configured")
}
// Determine the chat_id from the contact's identifier
chatID := contact.Identifier
if chatID == "" {
return nil, fmt.Errorf("contact has no Telegram source ID")
}
// Send text message via Bot API
result, err := p.SendTextMessage(ctx, botToken, chatID, message)
if err != nil {
return nil, err
}
// Add chat_id to extra metadata
result.Extra["chat_id"] = chatID
return result, nil
}
// configFromInbox extracts a config value from an inbox's channel configuration.
// TODO: integrate with GORM repository for config lookup (fetches from ChannelTelegram by ChannelID).
func configFromInbox(inbox *model.Inbox, key string) string {
// In production: fetch ChannelTelegram by inbox.ChannelID, then return the config value
// For now, return empty string (same pattern as WebWidgetProvider)
return ""
}
// init registers TelegramProvider with the global channel registry.
func init() {
channel.MustRegister(NewTelegramProvider())
}
// --- Telegram API Type Definitions ---
// These map to the Telegram Bot API response structures.
// Reference: https://core.telegram.org/bots/api
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"`
EditDate int64 `json:"edit_date,omitempty"`
Text string `json:"text,omitempty"`
Caption string `json:"caption,omitempty"`
Photo []TelegramPhotoSize `json:"photo,omitempty"`
Document *TelegramDocument `json:"document,omitempty"`
Voice *TelegramVoice `json:"voice,omitempty"`
Video *TelegramVideo `json:"video,omitempty"`
Audio *TelegramAudio `json:"audio,omitempty"`
Sticker *TelegramSticker `json:"sticker,omitempty"`
Animation *TelegramAnimation `json:"animation,omitempty"`
VideoNote *TelegramVideoNote `json:"video_note,omitempty"`
Location *TelegramLocation `json:"location,omitempty"`
Contact *TelegramContact `json:"contact,omitempty"`
ReplyToMessage *TelegramMessage `json:"reply_to_message,omitempty"`
ForwardFrom *TelegramUser `json:"forward_from,omitempty"`
ForwardFromChat *TelegramChat `json:"forward_from_chat,omitempty"`
ForwardDate int64 `json:"forward_date,omitempty"`
}
type TelegramUser struct {
ID int64 `json:"id"`
IsBot bool `json:"is_bot,omitempty"`
FirstName string `json:"first_name"`
LastName string `json:"last_name,omitempty"`
Username string `json:"username,omitempty"`
LanguageCode string `json:"language_code,omitempty"`
}
type TelegramChat struct {
ID int64 `json:"id"`
Type string `json:"type"` // private, group, supergroup, channel
Title string `json:"title,omitempty"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Username string `json:"username,omitempty"`
}
type TelegramPhotoSize struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Width int `json:"width"`
Height int `json:"height"`
FileSize int `json:"file_size,omitempty"`
FilePath string `json:"file_path,omitempty"`
}
type TelegramDocument struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
FileName string `json:"file_name,omitempty"`
MimeType string `json:"mime_type,omitempty"`
FileSize int `json:"file_size,omitempty"`
FilePath string `json:"file_path,omitempty"`
// Thumbnail (optional, omitted for simplicity)
}
type TelegramVoice struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Duration int `json:"duration"`
MimeType string `json:"mime_type,omitempty"`
FileSize int `json:"file_size,omitempty"`
FilePath string `json:"file_path,omitempty"`
}
type TelegramVideo struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Width int `json:"width"`
Height int `json:"height"`
Duration int `json:"duration"`
FileName string `json:"file_name,omitempty"`
MimeType string `json:"mime_type,omitempty"`
FileSize int `json:"file_size,omitempty"`
FilePath string `json:"file_path,omitempty"`
}
type TelegramAudio struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Duration int `json:"duration"`
FileName string `json:"file_name,omitempty"`
MimeType string `json:"mime_type,omitempty"`
FileSize int `json:"file_size,omitempty"`
FilePath string `json:"file_path,omitempty"`
Performer string `json:"performer,omitempty"`
Title string `json:"title,omitempty"`
}
type TelegramSticker struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Width int `json:"width"`
Height int `json:"height"`
IsAnimated bool `json:"is_animated,omitempty"`
IsVideo bool `json:"is_video,omitempty"`
Emoji string `json:"emoji,omitempty"`
SetName string `json:"set_name,omitempty"`
FileSize int `json:"file_size,omitempty"`
FilePath string `json:"file_path,omitempty"`
}
type TelegramAnimation struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Width int `json:"width"`
Height int `json:"height"`
Duration int `json:"duration"`
FileName string `json:"fileName,omitempty"`
MimeType string `json:"mime_type,omitempty"`
FileSize int `json:"file_size,omitempty"`
FilePath string `json:"file_path,omitempty"`
}
type TelegramVideoNote struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Duration int `json:"duration"`
Length int `json:"length"` // video note diameter
FileSize int `json:"file_size,omitempty"`
FilePath string `json:"file_path,omitempty"`
}
type TelegramLocation struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
LivePeriod int `json:"live_period,omitempty"`
}
type TelegramContact struct {
PhoneNumber string `json:"phone_number"`
FirstName string `json:"first_name"`
LastName string `json:"last_name,omitempty"`
UserID int64 `json:"user_id,omitempty"`
VCard string `json:"vcard,omitempty"`
}
type TelegramCallbackQuery struct {
ID string `json:"id"`
From *TelegramUser `json:"from"`
Message *TelegramMessage `json:"message,omitempty"`
Data string `json:"data,omitempty"`
}
type TelegramInlineKeyboardButton struct {
Text string `json:"text"`
Data string `json:"callback_data,omitempty"`
URL string `json:"url,omitempty"`
}
type TelegramBotCommand struct {
Command string `json:"command"`
Description string `json:"description"`
}
// --- Telegram API Response Types ---
type TelegramAPIResponse struct {
Ok bool `json:"ok"`
Description string `json:"description,omitempty"`
Result interface{} `json:"result,omitempty"`
}
type TelegramGetMeResponse struct {
Ok bool `json:"ok"`
Description string `json:"description,omitempty"`
Result struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
Username string `json:"username,omitempty"`
} `json:"result,omitempty"`
}
type TelegramUserProfilePhotos struct {
Ok bool `json:"ok"`
Result struct {
TotalCount int `json:"total_count"`
Photos [][]TelegramPhotoSize `json:"photos"`
} `json:"result,omitempty"`
}
type TelegramFileResponse struct {
Ok bool `json:"ok"`
Result struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
FilePath string `json:"file_path,omitempty"`
} `json:"result,omitempty"`
}
type TelegramCommandsResponse struct {
Ok bool `json:"ok"`
Description string `json:"description,omitempty"`
Result []TelegramBotCommand `json:"result,omitempty"`
}