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

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)
}