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** → text result = replaceMarkdownPattern(result, "**", "", "") // Bold: __text__ → text (alternative markdown bold) result = replaceMarkdownPattern(result, "__", "", "") // Italic: *text* → text result = replaceMarkdownPattern(result, "*", "", "") // Italic: _text_ → text result = replaceMarkdownPattern(result, "_", "", "") // Strikethrough: ~~text~~ → text result = replaceMarkdownPattern(result, "~~", "", "") // Code: `text` → text 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 += "" + part + "" } 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, "&", "&") result = strings.ReplaceAll(result, "&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"` }