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

165 lines
4.9 KiB
Go

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