238 lines
8.4 KiB
Go
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)
|
|
} |