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

238 lines
8.4 KiB
Go

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