541 lines
16 KiB
Go
541 lines
16 KiB
Go
package telegram
|
|
|
|
// TelegramService provides high-level operations for the Telegram channel.
|
|
// Reference: Chatwoot app/services/telegram_service.rb — wraps channel model methods
|
|
//
|
|
// This service coordinates between:
|
|
// - ChannelTelegram model (CRUD)
|
|
// - TelegramProvider (Bot API calls)
|
|
// - IncomingMessageProcessor (pipeline integration)
|
|
// - OutgoingMessageProcessor (pipeline integration)
|
|
//
|
|
// Design: Service pattern follows Chatwoot's service layer where each channel
|
|
// has a service module that wraps channel-specific business logic.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
|
|
|
"github.com/go-resty/resty/v2"
|
|
"gorm.io/gorm"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// TelegramService handles Telegram channel business logic.
|
|
type TelegramService struct {
|
|
client *resty.Client
|
|
db *gorm.DB
|
|
repo *Repository
|
|
}
|
|
|
|
// NewTelegramService creates a new Telegram service with GORM DB and repository.
|
|
func NewTelegramService(repo *Repository) *TelegramService {
|
|
client := resty.New()
|
|
client.SetTimeout(30 * time.Second)
|
|
client.SetRetryCount(3)
|
|
client.SetRetryWaitTime(1 * time.Second)
|
|
return &TelegramService{
|
|
client: client,
|
|
db: repo.db,
|
|
repo: repo,
|
|
}
|
|
}
|
|
|
|
// === CRUD Operations ===
|
|
|
|
// CreateChannel creates a new Telegram channel.
|
|
// Reference: Chatwoot TelegramBotsController#create
|
|
// Flow: validate bot_token → create ChannelTelegram → setup webhook → create Inbox
|
|
func (s *TelegramService) CreateChannel(ctx context.Context, accountID uint, botToken string, welcomeMessage string) (*channelmodel.ChannelTelegram, error) {
|
|
// Step 1: Validate bot token via getMe (Chatwoot: ensure_valid_bot_token)
|
|
botName, err := s.validateBotToken(ctx, botToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bot token validation failed: %w", err)
|
|
}
|
|
|
|
ch := &channelmodel.ChannelTelegram{
|
|
AccountID: accountID,
|
|
BotToken: botToken,
|
|
BotName: botName,
|
|
WelcomeMessage: welcomeMessage,
|
|
}
|
|
|
|
// Step 2: Setup webhook (Chatwoot: setup_telegram_webhook)
|
|
webhookURL, err := s.setupWebhook(ctx, botToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("webhook setup failed: %w", err)
|
|
}
|
|
ch.WebhookURL = webhookURL
|
|
|
|
applogger.L().Info("Telegram channel created", "account_id", accountID, "bot_name", botName)
|
|
return ch, nil
|
|
}
|
|
|
|
// UpdateChannel updates an existing Telegram channel.
|
|
// Reference: Chatwoot TelegramBotsController#update
|
|
// Only bot_token and welcome_message are editable (Chatwoot: EDITABLE_ATTRS = [:bot_token])
|
|
func (s *TelegramService) UpdateChannel(ctx context.Context, channelID uint, params map[string]interface{}) (*channelmodel.ChannelTelegram, error) {
|
|
// Fetch existing ChannelTelegram from DB
|
|
ch, err := s.repo.FindByID(ctx, channelID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("channel not found: %w", err)
|
|
}
|
|
|
|
newBotToken, _ := params["bot_token"].(string)
|
|
|
|
if newBotToken != "" && newBotToken != ch.BotToken {
|
|
// Re-validate and re-setup webhook if bot_token changed
|
|
botName, err := s.validateBotToken(ctx, newBotToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new bot token validation failed: %w", err)
|
|
}
|
|
|
|
webhookURL, err := s.setupWebhook(ctx, newBotToken)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("webhook setup for new token failed: %w", err)
|
|
}
|
|
|
|
ch.BotName = botName
|
|
ch.BotToken = newBotToken
|
|
ch.WebhookURL = webhookURL
|
|
}
|
|
|
|
if wm, ok := params["welcome_message"].(string); ok {
|
|
ch.WelcomeMessage = wm
|
|
}
|
|
|
|
// Persist to DB via repository
|
|
if err := s.repo.Update(ctx, ch); err != nil {
|
|
return nil, fmt.Errorf("failed to update channel: %w", err)
|
|
}
|
|
|
|
return ch, nil
|
|
}
|
|
|
|
// DeleteChannel removes a Telegram channel and cleans up the webhook.
|
|
// Reference: Chatwoot TelegramBotsController#destroy
|
|
func (s *TelegramService) DeleteChannel(ctx context.Context, channelID uint, botToken string) error {
|
|
// Delete webhook from Telegram
|
|
_, err := s.client.R().
|
|
SetContext(ctx).
|
|
SetResult(&TelegramAPIResponse{}).
|
|
Post(fmt.Sprintf("https://api.telegram.org/bot%s/deleteWebhook?drop_pending_updates=true", botToken))
|
|
if err != nil {
|
|
applogger.L().Warn("Failed to delete Telegram webhook on destroy", "error", err)
|
|
}
|
|
|
|
// Delete channel record from DB
|
|
if err := s.repo.Delete(ctx, channelID); err != nil {
|
|
applogger.L().Warn("Failed to delete ChannelTelegram from DB", "error", err)
|
|
return fmt.Errorf("failed to delete channel: %w", err)
|
|
}
|
|
|
|
applogger.L().Info("Telegram channel destroyed", "channel_id", channelID)
|
|
return nil
|
|
}
|
|
|
|
// === Webhook Setup ===
|
|
|
|
// setupWebhook configures the Telegram webhook for the bot.
|
|
// Reference: Chatwoot before_save :setup_telegram_webhook
|
|
func (s *TelegramService) setupWebhook(ctx context.Context, botToken string) (string, error) {
|
|
frontendURL := "https://localhost:3000" // TODO: from config
|
|
webhookURL := fmt.Sprintf("%s/webhooks/telegram/%s", frontendURL, botToken)
|
|
|
|
// Delete existing webhook first (Chatwoot: deleteWebhook before setWebhook)
|
|
_, _ = s.client.R().
|
|
SetContext(ctx).
|
|
SetResult(&TelegramAPIResponse{}).
|
|
Post(fmt.Sprintf("https://api.telegram.org/bot%s/deleteWebhook", botToken))
|
|
|
|
// Set new webhook
|
|
resp, err := s.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("setWebhook API call failed: %w", err)
|
|
}
|
|
|
|
result := resp.Result().(*TelegramAPIResponse)
|
|
if !result.Ok {
|
|
return "", fmt.Errorf("setWebhook API error: %s", result.Description)
|
|
}
|
|
|
|
return webhookURL, nil
|
|
}
|
|
|
|
// === Bot Token Validation ===
|
|
|
|
// validateBotToken validates a Telegram bot token via the getMe API.
|
|
// Reference: Chatwoot ensure_valid_bot_token callback
|
|
func (s *TelegramService) validateBotToken(ctx context.Context, botToken string) (string, error) {
|
|
resp, err := s.client.R().
|
|
SetContext(ctx).
|
|
SetResult(&TelegramGetMeResponse{}).
|
|
Get(fmt.Sprintf("https://api.telegram.org/bot%s/getMe", botToken))
|
|
if err != nil {
|
|
return "", fmt.Errorf("getMe API call failed: %w", err)
|
|
}
|
|
|
|
result := resp.Result().(*TelegramGetMeResponse)
|
|
if !result.Ok {
|
|
return "", fmt.Errorf("bot token invalid: %s", result.Description)
|
|
}
|
|
|
|
return result.Result.FirstName, nil
|
|
}
|
|
|
|
// === Bot Commands ===
|
|
|
|
// SetBotCommands sets the bot's command list visible in Telegram UI.
|
|
// Uses setMyCommands API — not present in Chatwoot, GoChat extension.
|
|
func (s *TelegramService) SetBotCommands(ctx context.Context, botToken string, commands []BotCommand) error {
|
|
resp, err := s.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("setMyCommands failed: %w", err)
|
|
}
|
|
|
|
result := resp.Result().(*TelegramAPIResponse)
|
|
if !result.Ok {
|
|
return fmt.Errorf("setMyCommands API error: %s", result.Description)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DefaultBotCommands returns a sensible default command list for customer support bots.
|
|
func DefaultBotCommands() []BotCommand {
|
|
return []BotCommand{
|
|
{Command: "help", Description: "Get help and support"},
|
|
{Command: "start", Description: "Start a conversation"},
|
|
{Command: "status", Description: "Check conversation status"},
|
|
}
|
|
}
|
|
|
|
// === Contact Profile ===
|
|
|
|
// GetProfileImage downloads a Telegram user's profile image.
|
|
// Reference: Chatwoot get_telegram_profile_image
|
|
func (s *TelegramService) GetProfileImage(ctx context.Context, botToken string, userID string) (string, error) {
|
|
// getUserProfilePhotos → getFile → construct URL
|
|
photosResp, err := s.client.R().
|
|
SetContext(ctx).
|
|
SetBody(map[string]interface{}{"user_id": userID}).
|
|
SetResult(&TelegramUserProfilePhotos{}).
|
|
Post(fmt.Sprintf("https://api.telegram.org/bot%s/getUserProfilePhotos", botToken))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
photos := photosResp.Result().(*TelegramUserProfilePhotos)
|
|
if !photos.Ok || photos.Result.TotalCount == 0 {
|
|
return "", nil // no profile photo
|
|
}
|
|
|
|
// Get largest photo size
|
|
lastPhoto := photos.Result.Photos[0]
|
|
photo := lastPhoto[len(lastPhoto)-1]
|
|
|
|
// Get file path via getFile
|
|
fileResp, err := s.client.R().
|
|
SetContext(ctx).
|
|
SetBody(map[string]interface{}{"file_id": photo.FileID}).
|
|
SetResult(&TelegramFileResponse{}).
|
|
Post(fmt.Sprintf("https://api.telegram.org/bot%s/getFile", botToken))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
fileResult := fileResp.Result().(*TelegramFileResponse)
|
|
if !fileResult.Ok || fileResult.Result.FilePath == "" {
|
|
return "", nil
|
|
}
|
|
|
|
return fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", botToken, fileResult.Result.FilePath), nil
|
|
}
|
|
|
|
// === Message Operations ===
|
|
|
|
// SendMessage sends a text message to a Telegram chat.
|
|
// Reference: Chatwoot SendOnTelegramService
|
|
func (s *TelegramService) SendMessage(ctx context.Context, botToken string, chatID string, text string, parseMode string) (int64, error) {
|
|
if parseMode == "" {
|
|
parseMode = "HTML"
|
|
}
|
|
|
|
resp, err := s.client.R().
|
|
SetContext(ctx).
|
|
SetBody(map[string]interface{}{
|
|
"chat_id": chatID,
|
|
"text": text,
|
|
"parse_mode": parseMode,
|
|
}).
|
|
SetResult(&TelegramAPIResponse{}).
|
|
Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("sendMessage failed: %w", err)
|
|
}
|
|
|
|
result := resp.Result().(*TelegramAPIResponse)
|
|
if !result.Ok {
|
|
return 0, fmt.Errorf("sendMessage API error: %s", result.Description)
|
|
}
|
|
|
|
// Extract message_id from result
|
|
if msgData, ok := result.Result.(map[string]interface{}); ok {
|
|
if msgID, ok2 := msgData["message_id"]; ok2 {
|
|
switch v := msgID.(type) {
|
|
case int64:
|
|
return v, nil
|
|
case float64:
|
|
return int64(v), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0, nil
|
|
}
|
|
|
|
// EditMessage edits a previously sent message.
|
|
// Reference: Chatwoot handles message.updated → editMessageText
|
|
func (s *TelegramService) EditMessage(ctx context.Context, botToken string, chatID string, messageID int64, newText string) error {
|
|
resp, err := s.client.R().
|
|
SetContext(ctx).
|
|
SetBody(map[string]interface{}{
|
|
"chat_id": chatID,
|
|
"message_id": messageID,
|
|
"text": newText,
|
|
"parse_mode": "HTML",
|
|
}).
|
|
SetResult(&TelegramAPIResponse{}).
|
|
Post(fmt.Sprintf("https://api.telegram.org/bot%s/editMessageText", botToken))
|
|
if err != nil {
|
|
return fmt.Errorf("editMessageText failed: %w", err)
|
|
}
|
|
|
|
result := resp.Result().(*TelegramAPIResponse)
|
|
if !result.Ok && result.Description != "message is not modified" {
|
|
return fmt.Errorf("editMessageText API error: %s", result.Description)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteMessage deletes a previously sent message.
|
|
// Reference: Chatwoot handles message.deleted → deleteMessage
|
|
func (s *TelegramService) DeleteMessage(ctx context.Context, botToken string, chatID string, messageID int64) error {
|
|
resp, err := s.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("deleteMessage failed: %w", err)
|
|
}
|
|
|
|
result := resp.Result().(*TelegramAPIResponse)
|
|
if !result.Ok {
|
|
return fmt.Errorf("deleteMessage API error: %s", result.Description)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// === Attachment Handling ===
|
|
|
|
// SendPhoto sends a photo to a Telegram chat.
|
|
// Reference: Chatwoot SendAttachmentsService.sendPhoto
|
|
func (s *TelegramService) SendPhoto(ctx context.Context, botToken string, chatID string, photoURL string, caption string) (int64, error) {
|
|
resp, err := s.client.R().
|
|
SetContext(ctx).
|
|
SetBody(map[string]interface{}{
|
|
"chat_id": chatID,
|
|
"photo": photoURL,
|
|
"caption": caption,
|
|
}).
|
|
SetResult(&TelegramAPIResponse{}).
|
|
Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendPhoto", botToken))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("sendPhoto failed: %w", err)
|
|
}
|
|
|
|
result := resp.Result().(*TelegramAPIResponse)
|
|
if !result.Ok {
|
|
return 0, fmt.Errorf("sendPhoto API error: %s", result.Description)
|
|
}
|
|
|
|
return extractMessageID(result), nil
|
|
}
|
|
|
|
// SendDocument sends a document/file to a Telegram chat.
|
|
// Reference: Chatwoot SendAttachmentsService.sendDocument
|
|
func (s *TelegramService) SendDocument(ctx context.Context, botToken string, chatID string, documentURL string, caption string) (int64, error) {
|
|
resp, err := s.client.R().
|
|
SetContext(ctx).
|
|
SetBody(map[string]interface{}{
|
|
"chat_id": chatID,
|
|
"document": documentURL,
|
|
"caption": caption,
|
|
}).
|
|
SetResult(&TelegramAPIResponse{}).
|
|
Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendDocument", botToken))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("sendDocument failed: %w", err)
|
|
}
|
|
|
|
result := resp.Result().(*TelegramAPIResponse)
|
|
if !result.Ok {
|
|
return 0, fmt.Errorf("sendDocument API error: %s", result.Description)
|
|
}
|
|
|
|
return extractMessageID(result), nil
|
|
}
|
|
|
|
// SendAudio sends an audio file to a Telegram chat.
|
|
// Reference: Chatwoot SendAttachmentsService.sendAudio
|
|
func (s *TelegramService) SendAudio(ctx context.Context, botToken string, chatID string, audioURL string, caption string) (int64, error) {
|
|
resp, err := s.client.R().
|
|
SetContext(ctx).
|
|
SetBody(map[string]interface{}{
|
|
"chat_id": chatID,
|
|
"audio": audioURL,
|
|
"caption": caption,
|
|
}).
|
|
SetResult(&TelegramAPIResponse{}).
|
|
Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendAudio", botToken))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("sendAudio failed: %w", err)
|
|
}
|
|
|
|
result := resp.Result().(*TelegramAPIResponse)
|
|
if !result.Ok {
|
|
return 0, fmt.Errorf("sendAudio API error: %s", result.Description)
|
|
}
|
|
|
|
return extractMessageID(result), nil
|
|
}
|
|
|
|
// SendVideo sends a video to a Telegram chat.
|
|
// Reference: Chatwoot SendAttachmentsService.sendVideo
|
|
func (s *TelegramService) SendVideo(ctx context.Context, botToken string, chatID string, videoURL string, caption string) (int64, error) {
|
|
resp, err := s.client.R().
|
|
SetContext(ctx).
|
|
SetBody(map[string]interface{}{
|
|
"chat_id": chatID,
|
|
"video": videoURL,
|
|
"caption": caption,
|
|
}).
|
|
SetResult(&TelegramAPIResponse{}).
|
|
Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendVideo", botToken))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("sendVideo failed: %w", err)
|
|
}
|
|
|
|
result := resp.Result().(*TelegramAPIResponse)
|
|
if !result.Ok {
|
|
return 0, fmt.Errorf("sendVideo API error: %s", result.Description)
|
|
}
|
|
|
|
return extractMessageID(result), nil
|
|
}
|
|
|
|
// SendSticker sends a sticker to a Telegram chat.
|
|
// Reference: Chatwoot doesn't have sendSticker but GoChat extends this
|
|
func (s *TelegramService) SendSticker(ctx context.Context, botToken string, chatID string, stickerID string) (int64, error) {
|
|
resp, err := s.client.R().
|
|
SetContext(ctx).
|
|
SetBody(map[string]interface{}{
|
|
"chat_id": chatID,
|
|
"sticker": stickerID,
|
|
}).
|
|
SetResult(&TelegramAPIResponse{}).
|
|
Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendSticker", botToken))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("sendSticker failed: %w", err)
|
|
}
|
|
|
|
result := resp.Result().(*TelegramAPIResponse)
|
|
if !result.Ok {
|
|
return 0, fmt.Errorf("sendSticker API error: %s", result.Description)
|
|
}
|
|
|
|
return extractMessageID(result), nil
|
|
}
|
|
|
|
// === Helper Functions ===
|
|
|
|
func extractMessageID(result *TelegramAPIResponse) int64 {
|
|
if msgData, ok := result.Result.(map[string]interface{}); ok {
|
|
if msgID, ok2 := msgData["message_id"]; ok2 {
|
|
switch v := msgID.(type) {
|
|
case int64:
|
|
return v
|
|
case float64:
|
|
return int64(v)
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// === Type Definitions ===
|
|
|
|
type BotCommand struct {
|
|
Command string `json:"command"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
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 [][]PhotoSize `json:"photos"`
|
|
} `json:"result,omitempty"`
|
|
}
|
|
|
|
type PhotoSize struct {
|
|
FileID string `json:"file_id"`
|
|
FileSize int `json:"file_size,omitempty"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
}
|
|
|
|
type TelegramFileResponse struct {
|
|
Ok bool `json:"ok"`
|
|
Result struct {
|
|
FilePath string `json:"file_path,omitempty"`
|
|
} `json:"result,omitempty"`
|
|
} |