package line // Pipeline integration for LINE channel with gochat's message processing system. // Reference: gochat's Telegram pipeline architecture — same pattern adapted for LINE. // // Flow: // - Incoming: LINE webhook → IncomingProcessor → resolveOrCreateContact → resolveOrCreateConversation → createOrUpdateMessage // - Outgoing: EventBridge.OnMessageCreated → OutgoingProcessor.SendMessage → LINE Messaging API // // Reference: Chatwoot IncomingMessageService.perform — creates contact, conversation, message // + LINE Messaging API webhook events: message, follow, unfollow, postback import ( "context" "encoding/json" "fmt" "time" channelpkg "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" ) // IncomingProcessor bridges LINE webhook events to gochat's internal message system. type IncomingProcessor struct { service *LineService } // NewIncomingProcessor creates a LINE incoming message processor. func NewIncomingProcessor(service *LineService) *IncomingProcessor { return &IncomingProcessor{service: service} } // ProcessEvent transforms a LINE webhook event into a gochat IncomingMessage. func (p *IncomingProcessor) ProcessEvent(ctx context.Context, inbox *model.Inbox, event EventObject) (*channelpkg.IncomingMessage, error) { sourceID := resolveSourceID(event.Source) if sourceID == "" { return nil, fmt.Errorf("line ProcessEvent: no source ID in event") } // Process by event type switch event.Type { case "message": return p.processMessage(ctx, inbox, event, sourceID) case "follow": // Contact followed the LINE Official Account — no message to create, // just resolve/create the contact for future conversations applogger.L().Infof("LINE ProcessEvent: user %s followed Official Account (inbox %d)", sourceID, inbox.ID) return nil, nil case "unfollow": applogger.L().Infof("LINE ProcessEvent: user %s unfollowed Official Account (inbox %d)", sourceID, inbox.ID) return nil, nil case "postback": return p.processPostback(ctx, inbox, event, sourceID) default: applogger.L().Debugf("LINE ProcessEvent: unhandled event type %s", event.Type) return nil, nil } } // processMessage transforms a LINE message event into an IncomingMessage. func (p *IncomingProcessor) processMessage(ctx context.Context, inbox *model.Inbox, event EventObject, sourceID string) (*channelpkg.IncomingMessage, error) { // Parse the message content based on type var msgData map[string]interface{} if err := json.Unmarshal(event.Message, &msgData); err != nil { return nil, fmt.Errorf("line processMessage: failed to parse message JSON: %w", err) } msgType, _ := msgData["type"].(string) msgID, _ := msgData["id"].(string) incomingMsg := &channelpkg.IncomingMessage{ ChannelType: channelpkg.ChannelLine, SourceID: msgID, SenderID: sourceID, SenderType: channelpkg.SenderContact, InboxID: inbox.ID, AccountID: inbox.AccountID, ReceivedAt: time.Now(), Extra: channelpkg.ChannelConfig{}, } // Store reply token for potential outbound responses if event.ReplyToken != "" { incomingMsg.Extra["reply_token"] = event.ReplyToken } switch msgType { case "text": text, _ := msgData["text"].(string) incomingMsg.Content = text incomingMsg.ContentType = channelpkg.ContentText case "image": originalURL, _ := msgData["originalContentUrl"].(string) incomingMsg.Content = originalURL incomingMsg.ContentType = channelpkg.ContentImage incomingMsg.Attachments = []channelpkg.Attachment{ {URL: originalURL, ContentType: "image"}, } case "video": originalURL, _ := msgData["originalContentUrl"].(string) incomingMsg.Content = originalURL incomingMsg.ContentType = channelpkg.ContentVideo incomingMsg.Attachments = []channelpkg.Attachment{ {URL: originalURL, ContentType: "video"}, } case "audio": originalURL, _ := msgData["originalContentUrl"].(string) duration, _ := msgData["duration"].(float64) incomingMsg.Content = fmt.Sprintf("Audio message (%.0f seconds)", duration) incomingMsg.ContentType = channelpkg.ContentAudio incomingMsg.Attachments = []channelpkg.Attachment{ {URL: originalURL, ContentType: "audio"}, } case "file": filename, _ := msgData["fileName"].(string) fileURL, _ := msgData["originalContentUrl"].(string) fileSize, _ := msgData["fileSize"].(float64) incomingMsg.Content = filename incomingMsg.ContentType = channelpkg.ContentFile incomingMsg.Attachments = []channelpkg.Attachment{ {URL: fileURL, ContentType: "file", Filename: filename, FileSize: int64(fileSize)}, } case "location": title, _ := msgData["title"].(string) address, _ := msgData["address"].(string) lat, _ := msgData["latitude"].(float64) lon, _ := msgData["longitude"].(float64) incomingMsg.Content = fmt.Sprintf("%s - %s (%.6f, %.6f)", title, address, lat, lon) incomingMsg.ContentType = channelpkg.ContentLocation incomingMsg.Extra["latitude"] = lat incomingMsg.Extra["longitude"] = lon case "sticker": stickerID, _ := msgData["stickerId"].(string) packageID, _ := msgData["packageId"].(string) incomingMsg.Content = fmt.Sprintf("Sticker (package: %s, sticker: %s)", packageID, stickerID) incomingMsg.ContentType = channelpkg.ContentText // treat sticker as text for simplicity default: applogger.L().Debugf("LINE processMessage: unhandled message type %s", msgType) incomingMsg.Content = fmt.Sprintf("[Unsupported message type: %s]", msgType) incomingMsg.ContentType = channelpkg.ContentText } return incomingMsg, nil } // processPostback transforms a LINE postback event into an IncomingMessage. func (p *IncomingProcessor) processPostback(ctx context.Context, inbox *model.Inbox, event EventObject, sourceID string) (*channelpkg.IncomingMessage, error) { data := "" if event.Postback != nil { data = event.Postback.Data } return &channelpkg.IncomingMessage{ ChannelType: channelpkg.ChannelLine, SourceID: fmt.Sprintf("postback_%s_%d", sourceID, time.Now().UnixNano()), SenderID: sourceID, SenderType: channelpkg.SenderContact, Content: data, ContentType: channelpkg.ContentText, InboxID: inbox.ID, AccountID: inbox.AccountID, ReceivedAt: time.Now(), Extra: channelpkg.ChannelConfig{}, }, nil } // resolveSourceID extracts the best source identifier from LINE event source. func resolveSourceID(source EventSource) string { // LINE source types: "user", "group", "room" // UserID is the primary source for 1:1 conversations if source.UserID != "" { return source.UserID } if source.GroupID != "" { return source.GroupID } if source.RoomID != "" { return source.RoomID } return "" } // OutgoingProcessor handles sending messages from gochat to LINE. type OutgoingProcessor struct { service *LineService } // NewOutgoingProcessor creates a LINE outgoing message processor. func NewOutgoingProcessor(service *LineService) *OutgoingProcessor { return &OutgoingProcessor{service: service} } // SendMessage sends a message to a LINE user via the Messaging API. func (p *OutgoingProcessor) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channelpkg.SendResult, error) { // Resolve LINE user ID from contact lineUserID := contact.SourceID if lineUserID == "" { return nil, fmt.Errorf("line SendMessage: contact has no source_id") } config := parseInboxConfig(inbox.ChannelConfig) token := configStr(config, "channel_access_token", "") msgs := []OutboundMsg{ {Type: "text", Text: message.Content}, } resp, err := p.service.PushMessage(ctx, token, lineUserID, msgs) if err != nil { return nil, fmt.Errorf("line SendMessage: push message failed: %w", err) } result := &channelpkg.SendResult{ DeliveredAt: time.Now(), } if resp != nil { result.Extra = channelpkg.ChannelConfig{ "status": "sent", } } return result, nil } // EventBridge connects gochat's pub/sub events to LINE's outgoing processor. type EventBridge struct { outgoing *OutgoingProcessor } // NewEventBridge creates a LINE event bridge. func NewEventBridge(outgoing *OutgoingProcessor) *EventBridge { return &EventBridge{outgoing: outgoing} } // OnMessageCreated handles gochat's message.created events for LINE. 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) }