364 lines
14 KiB
Go
364 lines
14 KiB
Go
package telegram
|
|
|
|
// Pipeline integration for Telegram channel with gochat's message processing system.
|
|
// Reference: Chatwoot's message pipeline architecture:
|
|
// - Incoming: Telegram webhook → IncomingMessageService → Conversation + Message creation
|
|
// - Outgoing: Message creation → SendOnTelegramService → Telegram Bot API
|
|
// - Updates: Message.updated → editMessageText, Message.deleted → deleteMessage
|
|
//
|
|
// gochat's pipeline architecture (from P5/P6):
|
|
// - IncomingMessageProcessor: receives raw payloads → transforms to IncomingMessage → creates Conversation + Message
|
|
// - OutgoingMessageProcessor: receives outgoing Message → sends via channel → returns SendResult
|
|
// - Broker: routes messages between providers and pipelines
|
|
// - Event system: message.created, message.updated, message.deleted events
|
|
//
|
|
// This file provides the bridge between the Telegram provider and gochat's pipeline.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
|
|
channelpkg "github.com/gochat/gochat/internal/channel"
|
|
"github.com/gochat/gochat/internal/channel/provider"
|
|
"github.com/gochat/gochat/internal/model"
|
|
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// === Incoming Pipeline Integration ===
|
|
|
|
// IncomingProcessor bridges Telegram updates to gochat's IncomingMessageProcessor.
|
|
// Reference: Chatwoot IncomingMessageService.perform — creates contact, conversation, message
|
|
//
|
|
// Flow:
|
|
// 1. Receive Telegram Update (via webhook)
|
|
// 2. Parse into IncomingMessage (via TelegramProvider.ProcessIncomingMessage)
|
|
// 3. Create/update Contact (from TelegramUser)
|
|
// 4. Create/update Conversation (from chat_id)
|
|
// 5. Create Message (with content + attachments)
|
|
// 6. Handle special cases (edited messages, callback queries, group messages)
|
|
type IncomingProcessor struct {
|
|
provider *provider.TelegramProvider
|
|
// contactRepo ContactRepository
|
|
// conversationRepo ConversationRepository
|
|
// messageRepo MessageRepository
|
|
// channelRepo ChannelRepository
|
|
}
|
|
|
|
// NewIncomingProcessor creates an incoming message processor for Telegram.
|
|
func NewIncomingProcessor(provider *provider.TelegramProvider) *IncomingProcessor {
|
|
return &IncomingProcessor{
|
|
provider: provider,
|
|
}
|
|
}
|
|
|
|
// ProcessUpdate processes a Telegram Update through the incoming pipeline.
|
|
// This is called by the webhook handler after receiving an update.
|
|
//
|
|
// Reference: Chatwoot IncomingMessageService steps:
|
|
// 1. Find or create contact from Telegram user info
|
|
// 2. Find or create conversation (keyed on chat_id)
|
|
// 3. Create message with content/attachments
|
|
// 4. Handle edited_message updates
|
|
// 5. Handle callback_query responses
|
|
func (p *IncomingProcessor) ProcessUpdate(ctx context.Context, inbox *model.Inbox, payload map[string]interface{}) error {
|
|
// Step 1: Transform raw payload into IncomingMessage
|
|
incomingMsg, err := p.provider.ProcessIncomingMessage(ctx, inbox, payload)
|
|
if err != nil {
|
|
return fmt.Errorf("telegram incoming pipeline: transform failed: %w", err)
|
|
}
|
|
if incomingMsg == nil {
|
|
return nil // no processable content
|
|
}
|
|
|
|
// Step 2: Resolve or create Contact
|
|
// Reference: Chatwoot: find_or_create_contact using source_id (telegram user_id)
|
|
contact, err := p.resolveOrCreateContact(ctx, inbox, incomingMsg)
|
|
if err != nil {
|
|
return fmt.Errorf("telegram incoming pipeline: contact resolution failed: %w", err)
|
|
}
|
|
|
|
// Step 3: Resolve or create Conversation
|
|
// Reference: Chatwoot: find_or_create_conversation using inbox_id + contact_id + chat_id
|
|
conversation, err := p.resolveOrCreateConversation(ctx, inbox, contact, incomingMsg)
|
|
if err != nil {
|
|
return fmt.Errorf("telegram incoming pipeline: conversation resolution failed: %w", err)
|
|
}
|
|
|
|
// Step 4: Create or update Message
|
|
// Reference: Chatwoot: create_message with content + content_type + attachments
|
|
if err := p.createOrUpdateMessage(ctx, conversation, contact, incomingMsg); err != nil {
|
|
return fmt.Errorf("telegram incoming pipeline: message creation failed: %w", err)
|
|
}
|
|
|
|
// Step 5: Handle welcome message on first contact
|
|
// Reference: Chatwoot: send welcome_message if configured and this is a new contact
|
|
if contact.CreatedAt.Equal(contact.UpdatedAt) && incomingMsg.Extra["is_first_contact"] != nil {
|
|
botToken := p.getBotTokenFromInbox(inbox)
|
|
welcomeMessage := p.getWelcomeMessageFromInbox(inbox)
|
|
if botToken != "" && welcomeMessage != "" {
|
|
if err := p.provider.SendWelcomeMessage(ctx, botToken, contact.Identifier, welcomeMessage); err != nil {
|
|
applogger.L().Warn("Failed to send Telegram welcome message", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
applogger.L().Info("Telegram incoming pipeline completed",
|
|
"inbox_id", inbox.ID,
|
|
"contact_id", contact.ID,
|
|
"conversation_id", conversation.ID,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// resolveOrCreateContact finds or creates a Contact for the Telegram user.
|
|
// Reference: Chatwoot: find_or_create_contact using source_id
|
|
//
|
|
// In Chatwoot:
|
|
// contact = account.contacts.where(source_id: telegram_user_id, inbox: inbox).first_or_create
|
|
// contact.update(name: telegram_name, avatar: telegram_avatar)
|
|
func (p *IncomingProcessor) resolveOrCreateContact(ctx context.Context, inbox *model.Inbox, msg *channelpkg.IncomingMessage) (*model.Contact, error) {
|
|
// TODO: Implement with GORM repository
|
|
// In production:
|
|
// contact = contactRepo.FindBySourceID(inbox.AccountID, msg.SenderID, "telegram")
|
|
// if contact == nil:
|
|
// contact = contactRepo.Create(accountID, name, identifier=chatID, sourceID=senderID)
|
|
// contact.UpdateName(msg.SenderName)
|
|
// contact.UpdateAvatar(msg.SenderExtra["avatar_url"])
|
|
// contact.MarkAsNew() // for welcome message check
|
|
|
|
contact := &model.Contact{
|
|
AccountID: inbox.AccountID,
|
|
Name: msg.SenderName,
|
|
Identifier: msg.SenderID,
|
|
}
|
|
|
|
return contact, nil
|
|
}
|
|
|
|
// resolveOrCreateConversation finds or creates a Conversation for the chat.
|
|
// Reference: Chatwoot: find_or_create_conversation
|
|
//
|
|
// Keyed on: inbox_id + contact_id
|
|
// For group messages: additionally keyed on chat_id
|
|
// Chatwoot uses additional_attributes to store chat_id for groups
|
|
func (p *IncomingProcessor) resolveOrCreateConversation(ctx context.Context, inbox *model.Inbox, contact *model.Contact, msg *channelpkg.IncomingMessage) (*model.Conversation, error) {
|
|
// TODO: Implement with GORM repository
|
|
// In production:
|
|
// conversation = conversationRepo.FindByInboxAndContact(inbox.ID, contact.ID)
|
|
// if conversation == nil:
|
|
// conversation = conversationRepo.Create(inbox.ID, contact.ID, status="open")
|
|
// conversation.UpdateAdditionalAttributes(msg.ConversationExtra)
|
|
|
|
conversation := &model.Conversation{
|
|
InboxID: inbox.ID,
|
|
AccountID: inbox.AccountID,
|
|
ContactID: contact.ID,
|
|
Status: "open",
|
|
}
|
|
|
|
return conversation, nil
|
|
}
|
|
|
|
// createOrUpdateMessage creates a new message or updates an existing one.
|
|
// Reference: Chatwoot: creates Message with content_type, content, attachments
|
|
//
|
|
// For edited messages (edited_message update type):
|
|
// - Find existing message by source_id (telegram message_id)
|
|
// - Update content with new text
|
|
// - Mark as edited
|
|
//
|
|
// For callback queries:
|
|
// - Create a new message with the callback data as content
|
|
func (p *IncomingProcessor) createOrUpdateMessage(ctx context.Context, conversation *model.Conversation, contact *model.Contact, msg *channelpkg.IncomingMessage) error {
|
|
// TODO: Implement with GORM repository
|
|
//
|
|
// Regular message:
|
|
// message = messageRepo.Create(conversation.ID, contact.ID, msg.ContentType, msg.Content, msg.SourceID)
|
|
// for attachment in msg.Attachments:
|
|
// attachmentRepo.Create(message.ID, attachment.URL, attachment.ContentType, attachment.Filename)
|
|
//
|
|
// Edited message:
|
|
// existing = messageRepo.FindBySourceID(msg.Extra["original_message_id"])
|
|
// existing.UpdateContent(msg.Content)
|
|
// existing.MarkAsEdited()
|
|
//
|
|
// Callback query:
|
|
// message = messageRepo.Create(conversation.ID, contact.ID, "text", msg.Content, msg.SourceID)
|
|
// message.MarkAsCallbackQuery(msg.Extra["callback_data"])
|
|
|
|
return nil
|
|
}
|
|
|
|
// === Outgoing Pipeline Integration ===
|
|
|
|
// OutgoingProcessor bridges gochat's outgoing messages to Telegram Bot API.
|
|
// Reference: Chatwoot SendOnTelegramService + SendAttachmentsService
|
|
//
|
|
// Flow:
|
|
// 1. Receive outgoing Message (from agent/user)
|
|
// 2. Determine content type (text, attachment, input_select)
|
|
// 3. Send via Telegram Bot API (sendMessage, sendPhoto, sendDocument, etc.)
|
|
// 4. Store external message ID for future edit/delete operations
|
|
// 5. Return SendResult with external_id
|
|
type OutgoingProcessor struct {
|
|
provider *provider.TelegramProvider
|
|
// messageRepo MessageRepository
|
|
// contactRepo ContactRepository
|
|
// channelRepo ChannelRepository
|
|
}
|
|
|
|
// NewOutgoingProcessor creates an outgoing message processor for Telegram.
|
|
func NewOutgoingProcessor(provider *provider.TelegramProvider) *OutgoingProcessor {
|
|
return &OutgoingProcessor{
|
|
provider: provider,
|
|
}
|
|
}
|
|
|
|
// SendMessage sends an outgoing message through the Telegram channel.
|
|
// Reference: Chatwoot SendOnTelegramService.perform
|
|
//
|
|
// Steps:
|
|
// 1. Resolve bot_token from inbox's ChannelTelegram
|
|
// 2. Resolve chat_id from contact's identifier
|
|
// 3. Determine message type → select API method
|
|
// 4. Send via TelegramProvider
|
|
// 5. Update message with external_id (telegram message_id)
|
|
func (p *OutgoingProcessor) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channelpkg.SendResult, error) {
|
|
sendResult, err := p.provider.ProcessOutgoingMessage(ctx, inbox, message, contact)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("telegram outgoing pipeline: send failed: %w", err)
|
|
}
|
|
|
|
// Update message with external ID for future edit/delete
|
|
// Reference: Chatwoot stores telegram message_id as message.source_id
|
|
if sendResult.ExternalID != "" {
|
|
// TODO: messageRepo.UpdateSourceID(message.ID, sendResult.ExternalID)
|
|
chatID := ""
|
|
if sendResult.Extra != nil {
|
|
chatID, _ = sendResult.Extra["chat_id"].(string)
|
|
}
|
|
applogger.L().Info("Telegram message sent",
|
|
"message_id", message.ID,
|
|
"external_id", sendResult.ExternalID,
|
|
"chat_id", chatID,
|
|
)
|
|
}
|
|
|
|
return sendResult, nil
|
|
}
|
|
|
|
// EditMessage edits a previously sent message on Telegram.
|
|
// Reference: Chatwoot: message.updated event → editMessageText
|
|
//
|
|
// Triggered when an agent edits a message in the UI.
|
|
// Flow:
|
|
// 1. Resolve bot_token + chat_id + message telegram_id
|
|
// 2. Call editMessageText API
|
|
// 3. Update message content in DB
|
|
func (p *OutgoingProcessor) EditMessage(ctx context.Context, inbox *model.Inbox, message *model.Message) error {
|
|
botToken := p.getBotTokenFromInbox(inbox)
|
|
if botToken == "" {
|
|
return fmt.Errorf("telegram bot_token not configured for inbox %d", inbox.ID)
|
|
}
|
|
|
|
// Extract chat_id and telegram message_id from message metadata
|
|
chatID := p.getChatIDFromMessage(message)
|
|
messageID := p.getTelegramMessageID(message)
|
|
|
|
if chatID == "" || messageID == 0 {
|
|
return fmt.Errorf("missing chat_id or telegram message_id for edit operation")
|
|
}
|
|
|
|
return p.provider.EditMessageText(ctx, botToken, chatID, messageID, message.Content)
|
|
}
|
|
|
|
// DeleteMessage deletes a previously sent message on Telegram.
|
|
// Reference: Chatwoot: message.deleted event → deleteMessage
|
|
//
|
|
// Triggered when an agent deletes a message in the UI.
|
|
func (p *OutgoingProcessor) DeleteMessage(ctx context.Context, inbox *model.Inbox, message *model.Message) error {
|
|
botToken := p.getBotTokenFromInbox(inbox)
|
|
if botToken == "" {
|
|
return fmt.Errorf("telegram bot_token not configured for inbox %d", inbox.ID)
|
|
}
|
|
|
|
chatID := p.getChatIDFromMessage(message)
|
|
messageID := p.getTelegramMessageID(message)
|
|
|
|
if chatID == "" || messageID == 0 {
|
|
return fmt.Errorf("missing chat_id or telegram message_id for delete operation")
|
|
}
|
|
|
|
return p.provider.DeleteMessage(ctx, botToken, chatID, messageID)
|
|
}
|
|
|
|
// === Helper Functions ===
|
|
|
|
func (p *IncomingProcessor) getBotTokenFromInbox(inbox *model.Inbox) string {
|
|
// TODO: Implement with channel repository
|
|
return ""
|
|
}
|
|
|
|
func (p *IncomingProcessor) getWelcomeMessageFromInbox(inbox *model.Inbox) string {
|
|
// TODO: channelRepo.FindByInboxID(inbox.ID).WelcomeMessage
|
|
return ""
|
|
}
|
|
|
|
func (p *OutgoingProcessor) getBotTokenFromInbox(inbox *model.Inbox) string {
|
|
// TODO: channelRepo.FindByInboxID(inbox.ID).BotToken
|
|
return ""
|
|
}
|
|
|
|
func (p *OutgoingProcessor) getChatIDFromMessage(message *model.Message) string {
|
|
// TODO: Extract from message.SourceID or conversation additional_attributes
|
|
// In Chatwoot: conversation.additional_attributes["chat_id"]
|
|
return ""
|
|
}
|
|
|
|
func (p *OutgoingProcessor) getTelegramMessageID(message *model.Message) int64 {
|
|
// TODO: Extract from message.SourceID (stored as telegram message_id)
|
|
// Parse the source_id as int64
|
|
if message.SourceID != "" {
|
|
id, err := strconv.ParseInt(message.SourceID, 10, 64)
|
|
if err == nil {
|
|
return id
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// === Event Bridge ===
|
|
|
|
// EventBridge connects gochat's event system to Telegram operations.
|
|
// Reference: Chatwoot uses event callbacks for message lifecycle:
|
|
// - message.created → SendOnTelegramService
|
|
// - message.updated → editMessageText
|
|
// - message.deleted → deleteMessage
|
|
//
|
|
// gochat uses a similar event-driven approach via the Broker/Dispatcher.
|
|
type EventBridge struct {
|
|
outgoing *OutgoingProcessor
|
|
}
|
|
|
|
// NewEventBridge creates an event bridge for Telegram message lifecycle events.
|
|
func NewEventBridge(outgoing *OutgoingProcessor) *EventBridge {
|
|
return &EventBridge{outgoing: outgoing}
|
|
}
|
|
|
|
// OnMessageCreated handles a message.created event — sends the message via Telegram.
|
|
func (b *EventBridge) OnMessageCreated(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channelpkg.SendResult, error) {
|
|
return b.outgoing.SendMessage(ctx, inbox, message, contact)
|
|
}
|
|
|
|
// OnMessageUpdated handles a message.updated event — edits the message on Telegram.
|
|
func (b *EventBridge) OnMessageUpdated(ctx context.Context, inbox *model.Inbox, message *model.Message) error {
|
|
return b.outgoing.EditMessage(ctx, inbox, message)
|
|
}
|
|
|
|
// OnMessageDeleted handles a message.deleted event — deletes the message on Telegram.
|
|
func (b *EventBridge) OnMessageDeleted(ctx context.Context, inbox *model.Inbox, message *model.Message) error {
|
|
return b.outgoing.DeleteMessage(ctx, inbox, message)
|
|
} |