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 }