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

252 lines
7.9 KiB
Go

package line
// LineProvider implements channelpkg.ChannelProvider for LINE Messaging API.
// Reference: gochat's WhatsAppProvider pattern — same interface, adapted for LINE.
//
// LINE channel lifecycle:
// - OnCreate: validate channel_secret + channel_access_token, test API connectivity
// - Incoming: webhook POST → validate X-Line-Signature → parse events → IncomingProcessor
// - Outgoing: SendMessage → LINE Push Message API (or Reply Message API with reply token)
// - OnDestroy: no cleanup needed (LINE doesn't have a webhook deletion API)
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"
)
// LineProvider implements channelpkg.ChannelProvider for LINE.
type LineProvider struct {
service *LineService
repo *Repository
pipeline *IncomingProcessor
}
// NewLineProvider creates a LINE channel provider with required dependencies.
func NewLineProvider(service *LineService, repo *Repository, pipeline *IncomingProcessor) *LineProvider {
return &LineProvider{
service: service,
repo: repo,
pipeline: pipeline,
}
}
// === Channel Identity ===
func (p *LineProvider) Type() channelpkg.ChannelType { return channelpkg.ChannelLine }
func (p *LineProvider) Name() string { return "LINE" }
func (p *LineProvider) Description() string {
return "LINE Official Account messaging channel — supports text, image, video, audio, file, location, and sticker messages"
}
// === Configuration ===
func (p *LineProvider) ConfigSchema() *channelpkg.ConfigSchemaDefinition {
return &channelpkg.ConfigSchemaDefinition{
Type: "object",
Properties: map[string]channelpkg.ConfigProperty{
"channel_access_token": {
Type: "string",
Description: "Long-lived channel access token for LINE Messaging API",
Required: true,
Secret: true,
},
"channel_secret": {
Type: "string",
Description: "Channel secret for webhook signature verification (HMAC-SHA256)",
Required: true,
Secret: true,
},
},
Required: []string{"channel_access_token", "channel_secret"},
}
}
func (p *LineProvider) ValidateConfig(ctx context.Context, config channelpkg.ChannelConfig) error {
token, _ := config["channel_access_token"].(string)
secret, _ := config["channel_secret"].(string)
if token == "" {
return fmt.Errorf("channel_access_token is required")
}
if secret == "" {
return fmt.Errorf("channel_secret is required")
}
return nil
}
func (p *LineProvider) DefaultConfig() channelpkg.ChannelConfig {
return channelpkg.ChannelConfig{
"channel_access_token": "",
"channel_secret": "",
}
}
// === Lifecycle Hooks ===
func (p *LineProvider) OnCreate(ctx context.Context, inbox *model.Inbox, config channelpkg.ChannelConfig) (channelpkg.ChannelConfig, error) {
// Validate access token by calling the LINE API
token, _ := config["channel_access_token"].(string)
if err := p.service.ValidateAccessToken(ctx, token); err != nil {
applogger.L().Warnf("LINE OnCreate: access token validation failed: %v", err)
// Don't fail creation — token may become valid later
}
return config, nil
}
func (p *LineProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config channelpkg.ChannelConfig) error {
// LINE doesn't require cleanup when an inbox is destroyed
return nil
}
// === Incoming ===
func (p *LineProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channelpkg.IncomingMessage, error) {
var webhookEvent WebhookEvent
if err := json.Unmarshal(rawPayload, &webhookEvent); err != nil {
return nil, fmt.Errorf("line ProcessIncoming: failed to parse payload: %w", err)
}
if len(webhookEvent.Events) == 0 {
return nil, fmt.Errorf("line ProcessIncoming: no events in payload")
}
// Process the first event (typically one event per webhook call)
return p.pipeline.ProcessEvent(ctx, inbox, webhookEvent.Events[0])
}
// ValidateWebhookRequest verifies LINE webhook authenticity using HMAC-SHA256.
func (p *LineProvider) ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, request *channelpkg.WebhookRequest) error {
config := parseInboxConfig(inbox.ChannelConfig)
secret := configStr(config, "channel_secret", "")
if secret == "" {
return fmt.Errorf("line ValidateWebhookRequest: channel_secret not configured")
}
// The signature comes from X-Line-Signature header
signature := ""
for k, v := range request.Headers {
if k == "X-Line-Signature" {
signature = v
break
}
}
if signature == "" {
return fmt.Errorf("line ValidateWebhookRequest: missing X-Line-Signature header")
}
if !p.service.VerifySignature(secret, string(request.Body), signature) {
return fmt.Errorf("line ValidateWebhookRequest: invalid HMAC signature")
}
return nil
}
// === Messaging: Outbound ===
// SendMessage sends an outgoing message via LINE Push Message API.
func (p *LineProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channelpkg.SendResult, error) {
config := parseInboxConfig(inbox.ChannelConfig)
token := configStr(config, "channel_access_token", "")
toUserID := contact.SourceID
if toUserID == "" {
return nil, fmt.Errorf("line SendMessage: contact has no source_id")
}
// Build outbound text message
outboundMsg := OutboundMsg{
Type: "text",
Text: message.Content,
}
resp, err := p.service.PushMessage(ctx, token, toUserID, []OutboundMsg{outboundMsg})
if err != nil {
return nil, fmt.Errorf("line SendMessage: API call failed: %w", err)
}
result := &channelpkg.SendResult{
DeliveredAt: time.Now(),
}
if resp != nil {
result.Extra = channelpkg.ChannelConfig{
"status": resp.Status,
}
}
return result, nil
}
// === Contact Info ===
// GetContactProfile fetches a LINE user's profile.
func (p *LineProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*channelpkg.ContactProfile, error) {
config := parseInboxConfig(inbox.ChannelConfig)
token := configStr(config, "channel_access_token", "")
profile, err := p.service.GetUserProfile(ctx, token, contactSource)
if err != nil {
return nil, fmt.Errorf("line GetContactProfile: failed: %w", err)
}
return &channelpkg.ContactProfile{
Name: profile.DisplayName,
AvatarURL: profile.PictureURL,
Extra: channelpkg.ChannelConfig{
"status_message": profile.StatusMessage,
"language": profile.Language,
},
}, nil
}
// === Capabilities ===
func (p *LineProvider) Capabilities() channelpkg.ChannelCapabilities {
return channelpkg.ChannelCapabilities{
SupportsAttachments: true,
SupportsLocation: true,
SupportsTypingIndicator: false,
SupportsDeliveryStatus: false,
SupportsReplies: true, // LINE supports reply-to specific messages
SupportsEmojiReactions: false,
SupportsVoiceMessages: true,
SupportsVideoCalls: false,
SupportsCustomCards: true, // LINE Flex Messages
SupportsTemplates: true, // LINE template messages
SupportsEmailHeaders: false,
MaxAttachmentSize: 10 * 1024 * 1024, // 10MB
MaxTextLength: 5000,
}
}
// === Helpers ===
// parseInboxConfig converts the inbox's JSON string ChannelConfig into a ChannelConfig map.
func parseInboxConfig(configStr string) channelpkg.ChannelConfig {
if configStr == "" {
return channelpkg.ChannelConfig{}
}
var config channelpkg.ChannelConfig
if err := json.Unmarshal([]byte(configStr), &config); err != nil {
applogger.L().Warnf("LINE: failed to parse inbox channel_config JSON: %v", err)
return channelpkg.ChannelConfig{}
}
return config
}
// configStr extracts a string value from ChannelConfig with a default fallback.
func configStr(config channelpkg.ChannelConfig, key, defaultVal string) string {
if v, ok := config[key]; ok {
if s, ok := v.(string); ok && s != "" {
return s
}
}
return defaultVal
}