package line // LineService provides high-level operations for the LINE channel. // Reference: LINE Messaging API — https://developers.line.biz/en/docs/messaging-api/ // // This service coordinates: // - Sending messages via LINE Messaging API (push + reply) // - Fetching user profiles from LINE // - Validating channel access tokens // - Managing channel configuration // // Design: Follows the same service pattern as TikTokService and WhatsAppService. import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "fmt" "time" "github.com/go-resty/resty/v2" applogger "github.com/gochat/gochat/pkg/logger" ) // LineService provides LINE Messaging API operations. type LineService struct { repo *Repository client *resty.Client } // NewLineService creates a LINE service instance. func NewLineService(repo *Repository) *LineService { client := resty.New() client.SetTimeout(30 * time.Second) return &LineService{ repo: repo, client: client, } } // === Message Sending === // ReplyMessage sends a reply using a webhook event's reply token. // Reply tokens expire after 30 seconds — must respond quickly. func (s *LineService) ReplyMessage(ctx context.Context, channelAccessToken, replyToken string, messages []OutboundMsg) error { url := "https://api.line.me/v2/bot/message/reply" resp, err := s.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+channelAccessToken). SetHeader("Content-Type", "application/json"). SetBody(ReplyMessageRequest{ ReplyToken: replyToken, Messages: messages, }). Post(url) if err != nil { return fmt.Errorf("line ReplyMessage: API call failed: %w", err) } if resp.StatusCode() != 200 { return fmt.Errorf("line ReplyMessage: API returned status %d: %s", resp.StatusCode(), resp.String()) } applogger.L().Debugf("LINE ReplyMessage sent: replyToken=%s", replyToken) return nil } // PushMessage sends a proactive message to a LINE user. func (s *LineService) PushMessage(ctx context.Context, channelAccessToken, to string, messages []OutboundMsg) (*PushMessageResponse, error) { url := "https://api.line.me/v2/bot/message/push" var result PushMessageResponse resp, err := s.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+channelAccessToken). SetHeader("Content-Type", "application/json"). SetBody(PushMessageRequest{ To: to, Messages: messages, }). SetResult(&result). Post(url) if err != nil { return nil, fmt.Errorf("line PushMessage: API call failed: %w", err) } if resp.StatusCode() != 200 { return nil, fmt.Errorf("line PushMessage: API returned status %d: %s", resp.StatusCode(), resp.String()) } applogger.L().Debugf("LINE PushMessage sent: to=%s", to) return &result, nil } // === Profile === // GetUserProfile fetches a LINE user's profile information. func (s *LineService) GetUserProfile(ctx context.Context, channelAccessToken, userID string) (*UserProfile, error) { url := fmt.Sprintf("https://api.line.me/v2/bot/profile/%s", userID) var profile UserProfile resp, err := s.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+channelAccessToken). SetResult(&profile). Get(url) if err != nil { return nil, fmt.Errorf("line GetUserProfile: API call failed: %w", err) } if resp.StatusCode() != 200 { return nil, fmt.Errorf("line GetUserProfile: API returned status %d", resp.StatusCode()) } return &profile, nil } // === Webhook Verification === // VerifySignature validates the X-Line-Signature HMAC-SHA256 header. // LINE signs every webhook request with the channel secret. func (s *LineService) VerifySignature(channelSecret, requestBody, signature string) bool { mac := hmac.New(sha256.New, []byte(channelSecret)) mac.Write([]byte(requestBody)) expectedSig := base64.StdEncoding.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expectedSig), []byte(signature)) } // === Token Management === // ValidateAccessToken verifies a LINE channel access token. func (s *LineService) ValidateAccessToken(ctx context.Context, channelAccessToken string) error { url := "https://api.line.me/v2/bot/info" resp, err := s.client.R(). SetContext(ctx). SetHeader("Authorization", "Bearer "+channelAccessToken). Get(url) if err != nil { return fmt.Errorf("line ValidateAccessToken: API call failed: %w", err) } if resp.StatusCode() != 200 { return fmt.Errorf("line ValidateAccessToken: invalid token (status %d)", resp.StatusCode()) } return nil } // === Configuration === // UpdateChannel updates LINE channel configuration fields. func (s *LineService) UpdateChannel(ctx context.Context, channelID uint, updates map[string]interface{}) error { return s.repo.UpdateFields(ctx, channelID, updates) } // MarkReauthorizationRequired flags a channel needing re-authentication. func (s *LineService) MarkReauthorizationRequired(ctx context.Context, channelID uint) error { return s.UpdateChannel(ctx, channelID, map[string]interface{}{ "reauthorization_required": true, }) }