package line import ( "context" "errors" "fmt" "net/http" "strings" "sync" "time" "github.com/line/line-bot-sdk-go/v8/linebot/messaging_api" "github.com/line/line-bot-sdk-go/v8/linebot/webhook" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) const ( lineContentEndpoint = "https://api-data.line.me/v2/bot/message/%s/content" lineReplyTokenMaxAge = 25 * time.Second // Limit request body to prevent memory exhaustion (DoS). // LINE webhook payloads are typically a few KB; 1 MiB is generous. maxWebhookBodySize = 1 << 20 // 1 MiB ) type replyTokenEntry struct { token string timestamp time.Time } // LINEChannel implements the Channel interface for LINE Official Account // using the LINE Messaging API with HTTP webhook for receiving messages // and the official LINE Bot SDK for sending messages. type LINEChannel struct { *channels.BaseChannel config *config.LINESettings client *messaging_api.MessagingApiAPI botUserID string // Bot's user ID botBasicID string // Bot's basic ID (e.g. @216ru...) botDisplayName string // Bot's display name for text-based mention detection replyTokens sync.Map // chatID -> replyTokenEntry quoteTokens sync.Map // chatID -> quoteToken (string) ctx context.Context cancel context.CancelFunc } // NewLINEChannel creates a new LINE channel instance. func NewLINEChannel( bc *config.Channel, cfg *config.LINESettings, messageBus *bus.MessageBus, ) (*LINEChannel, error) { if cfg.ChannelSecret.String() == "" || cfg.ChannelAccessToken.String() == "" { return nil, fmt.Errorf("line channel_secret and channel_access_token are required") } client, err := messaging_api.NewMessagingApiAPI( cfg.ChannelAccessToken.String(), messaging_api.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}), ) if err != nil { return nil, fmt.Errorf("failed to create LINE messaging client: %w", err) } base := channels.NewBaseChannel("line", cfg, messageBus, bc.AllowFrom, channels.WithMaxMessageLength(5000), channels.WithGroupTrigger(bc.GroupTrigger), channels.WithReasoningChannelID(bc.ReasoningChannelID), ) return &LINEChannel{ BaseChannel: base, config: cfg, client: client, }, nil } // Start initializes the LINE channel. func (c *LINEChannel) Start(ctx context.Context) error { logger.InfoC("line", "Starting LINE channel (Webhook Mode)") c.ctx, c.cancel = context.WithCancel(ctx) // Fetch bot profile to get bot's userId for mention detection info, err := c.client.WithContext(ctx).GetBotInfo() if err != nil { logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{ "error": err.Error(), }) } else { c.botUserID = info.UserId c.botBasicID = info.BasicId c.botDisplayName = info.DisplayName logger.InfoCF("line", "Bot info fetched", map[string]any{ "bot_user_id": c.botUserID, "basic_id": c.botBasicID, "display_name": c.botDisplayName, }) } c.SetRunning(true) logger.InfoC("line", "LINE channel started (Webhook Mode)") return nil } // Stop gracefully stops the LINE channel. func (c *LINEChannel) Stop(ctx context.Context) error { logger.InfoC("line", "Stopping LINE channel") if c.cancel != nil { c.cancel() } c.SetRunning(false) logger.InfoC("line", "LINE channel stopped") return nil } // WebhookPath returns the path for registering on the shared HTTP server. func (c *LINEChannel) WebhookPath() string { if c.config.WebhookPath != "" { return c.config.WebhookPath } return "/webhook/line" } // ServeHTTP implements http.Handler for the shared HTTP server. func (c *LINEChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { c.webhookHandler(w, r) } // webhookHandler handles incoming LINE webhook requests. func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Limit body size to prevent memory exhaustion (DoS). // ParseRequest reads r.Body internally via io.ReadAll; wrapping with // MaxBytesReader ensures oversized payloads are rejected before full // allocation. r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodySize) cb, err := webhook.ParseRequest(c.config.ChannelSecret.String(), r) if err != nil { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { logger.WarnC("line", "Webhook request body too large, rejected") http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge) } else if errors.Is(err, webhook.ErrInvalidSignature) { logger.WarnC("line", "Invalid webhook signature") http.Error(w, "Forbidden", http.StatusForbidden) } else { logger.ErrorCF("line", "Failed to parse webhook request", map[string]any{ "error": err.Error(), }) http.Error(w, "Bad request", http.StatusBadRequest) } return } // Return 200 immediately, process events asynchronously w.WriteHeader(http.StatusOK) for _, event := range cb.Events { go c.processEvent(event) } } func (c *LINEChannel) processEvent(event webhook.EventInterface) { msgEvent, ok := event.(webhook.MessageEvent) if !ok { logger.DebugCF("line", "Ignoring non-message event", map[string]any{ "type": event.GetType(), }) return } senderID, chatID, sourceType := c.resolveSource(msgEvent.Source) isGroup := sourceType == "group" || sourceType == "room" // Store reply token for later use if msgEvent.ReplyToken != "" { c.replyTokens.Store(chatID, replyTokenEntry{ token: msgEvent.ReplyToken, timestamp: time.Now(), }) } var content string var mediaPaths []string var messageID string var quoteToken string var isMentioned bool // Helper to register a local file with the media store storeMedia := func(localPath, filename, scope string) string { if store := c.GetMediaStore(); store != nil { ref, err := store.Store(localPath, media.MediaMeta{ Filename: filename, Source: "line", }, scope) if err == nil { return ref } } return localPath // fallback } switch msg := msgEvent.Message.(type) { case webhook.TextMessageContent: messageID = msg.Id content = msg.Text isMentioned = c.isBotMentioned(msg) // Store quote token for quoting the original message in reply if msg.QuoteToken != "" { quoteToken = msg.QuoteToken c.quoteTokens.Store(chatID, msg.QuoteToken) } // Strip bot mention from text in group chats if isGroup { content = c.stripBotMention(content, msg) } case webhook.ImageMessageContent: messageID = msg.Id if msg.QuoteToken != "" { quoteToken = msg.QuoteToken c.quoteTokens.Store(chatID, msg.QuoteToken) } if localPath := c.downloadContent(msg.Id, "image.jpg"); localPath != "" { scope := channels.BuildMediaScope("line", chatID, msg.Id) mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg", scope)) content = "[image]" } case webhook.AudioMessageContent: messageID = msg.Id if localPath := c.downloadContent(msg.Id, "audio.m4a"); localPath != "" { scope := channels.BuildMediaScope("line", chatID, msg.Id) mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a", scope)) content = "[audio]" } case webhook.VideoMessageContent: messageID = msg.Id if msg.QuoteToken != "" { quoteToken = msg.QuoteToken c.quoteTokens.Store(chatID, msg.QuoteToken) } if localPath := c.downloadContent(msg.Id, "video.mp4"); localPath != "" { scope := channels.BuildMediaScope("line", chatID, msg.Id) mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4", scope)) content = "[video]" } case webhook.FileMessageContent: messageID = msg.Id content = "[file]" case webhook.LocationMessageContent: messageID = msg.Id content = "[location]" if msg.Title != "" { content = fmt.Sprintf("[location: %s]", msg.Title) } case webhook.StickerMessageContent: messageID = msg.Id if msg.QuoteToken != "" { quoteToken = msg.QuoteToken c.quoteTokens.Store(chatID, msg.QuoteToken) } content = "[sticker]" default: logger.DebugCF("line", "Ignoring unsupported message type", map[string]any{ "type": msgEvent.Message.GetType(), }) return } if strings.TrimSpace(content) == "" { return } // In group chats, apply unified group trigger filtering if isGroup { respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) if !respond { logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{ "chat_id": chatID, }) return } content = cleaned } metadata := map[string]string{ "platform": "line", "source_type": sourceType, } logger.DebugCF("line", "Received message", map[string]any{ "sender_id": senderID, "chat_id": chatID, "message_type": msgEvent.Message.GetType(), "is_group": isGroup, "preview": utils.Truncate(content, 50), }) sender := bus.SenderInfo{ Platform: "line", PlatformID: senderID, CanonicalID: identity.BuildCanonicalID("line", senderID), } if !c.IsAllowedSender(sender) { return } inboundCtx := bus.InboundContext{ Channel: c.Name(), ChatID: chatID, ChatType: map[bool]string{true: "group", false: "direct"}[isGroup], SenderID: senderID, MessageID: messageID, Mentioned: isMentioned, Raw: metadata, } if msgEvent.ReplyToken != "" { inboundCtx.ReplyHandles = map[string]string{ "reply_token": msgEvent.ReplyToken, } if quoteToken != "" { inboundCtx.ReplyHandles["quote_token"] = quoteToken } } c.HandleInboundContext(c.ctx, chatID, content, mediaPaths, inboundCtx, sender) } // isBotMentioned checks if the bot is mentioned in the message. // It first checks the mention metadata (userId match or IsSelf), then falls back // to text-based detection using the bot's display name, since LINE may // not include userId in mentionees for Official Accounts. func (c *LINEChannel) isBotMentioned(msg webhook.TextMessageContent) bool { if msg.Mention != nil { for _, m := range msg.Mention.Mentionees { switch mentionee := m.(type) { case webhook.AllMentionee: return true case webhook.UserMentionee: if mentionee.IsSelf { return true } if c.botUserID != "" && mentionee.UserId == c.botUserID { return true } // Check if mentionee text overlaps with bot display name if c.botDisplayName != "" && mentionee.Index >= 0 && mentionee.Length > 0 { runes := []rune(msg.Text) end := int(mentionee.Index) + int(mentionee.Length) if end <= len(runes) { mentionText := string(runes[mentionee.Index:end]) if strings.Contains(mentionText, c.botDisplayName) { return true } } } } } } // Fallback: text-based detection with display name if c.botDisplayName != "" && strings.Contains(msg.Text, "@"+c.botDisplayName) { return true } return false } // stripBotMention removes the @BotName mention text from the message. func (c *LINEChannel) stripBotMention(text string, msg webhook.TextMessageContent) string { stripped := false if msg.Mention != nil { runes := []rune(text) for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- { m := msg.Mention.Mentionees[i] shouldStrip := false var index, length int32 switch mentionee := m.(type) { case webhook.UserMentionee: index = mentionee.Index length = mentionee.Length if mentionee.IsSelf { shouldStrip = true } else if c.botUserID != "" && mentionee.UserId == c.botUserID { shouldStrip = true } else if c.botDisplayName != "" && index >= 0 && length > 0 { end := int(index) + int(length) if end <= len(runes) { mentionText := string(runes[index:end]) if strings.Contains(mentionText, c.botDisplayName) { shouldStrip = true } } } case webhook.AllMentionee: // Don't strip @All mentions continue default: continue } if shouldStrip { start := int(index) end := int(index) + int(length) if start >= 0 && end <= len(runes) { runes = append(runes[:start], runes[end:]...) stripped = true } } } if stripped { return strings.TrimSpace(string(runes)) } } // Fallback: strip @DisplayName from text if c.botDisplayName != "" { text = strings.ReplaceAll(text, "@"+c.botDisplayName, "") } return strings.TrimSpace(text) } // resolveSource extracts senderID, chatID, and source type from the event source. func (c *LINEChannel) resolveSource(source webhook.SourceInterface) (senderID, chatID, sourceType string) { switch src := source.(type) { case webhook.GroupSource: return src.UserId, src.GroupId, "group" case webhook.RoomSource: return src.UserId, src.RoomId, "room" case webhook.UserSource: return src.UserId, src.UserId, "user" default: logger.WarnCF("line", "Unknown source type", map[string]any{ "type": fmt.Sprintf("%T", source), }) return "", "", "unknown" } } // Send sends a message to LINE. It first tries the Reply API (free) // using a cached reply token, then falls back to the Push API. func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { return nil, channels.ErrNotRunning } // Load and consume quote token for this chat var quoteToken string if qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok { quoteToken = qt.(string) } textMsg := messaging_api.TextMessage{ Text: msg.Content, QuoteToken: quoteToken, } // Try reply token first (free, valid for ~25 seconds) if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok { tokenEntry := entry.(replyTokenEntry) if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge { resp, _, err := c.client.WithContext(ctx).ReplyMessageWithHttpInfo(&messaging_api.ReplyMessageRequest{ ReplyToken: tokenEntry.token, Messages: []messaging_api.MessageInterface{&textMsg}, }) if resp != nil && resp.Body != nil { resp.Body.Close() } if err == nil { logger.DebugCF("line", "Message sent via Reply API", map[string]any{ "chat_id": msg.ChatID, "quoted": quoteToken != "", }) return nil, nil } logger.DebugCF("line", "Reply API failed, falling back to Push API", map[string]any{ "error": err.Error(), }) } } // Fall back to Push API resp, _, err := c.client.WithContext(ctx).PushMessageWithHttpInfo(&messaging_api.PushMessageRequest{ To: msg.ChatID, Messages: []messaging_api.MessageInterface{&textMsg}, }, "") return nil, classifySDKError(resp, err) } // SendMedia implements the channels.MediaSender interface. // LINE requires media to be accessible via public URL; since we only have local files, // we fall back to sending a text message with the filename/caption. // For full support, an external file hosting service would be needed. func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { return nil, channels.ErrNotRunning } store := c.GetMediaStore() if store == nil { return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } // LINE Messaging API requires publicly accessible URLs for media messages. // Since we only have local file paths, send caption text as fallback. for _, part := range msg.Parts { caption := part.Caption if caption == "" { caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename) } textMsg := messaging_api.TextMessage{Text: caption} resp, _, err := c.client.WithContext(ctx).PushMessageWithHttpInfo(&messaging_api.PushMessageRequest{ To: msg.ChatID, Messages: []messaging_api.MessageInterface{&textMsg}, }, "") if sdkErr := classifySDKError(resp, err); sdkErr != nil { return nil, sdkErr } } return nil, nil } // StartTyping implements channels.TypingCapable using LINE's loading animation. // // NOTE: The LINE loading animation API only works for 1:1 chats. // Group/room chat IDs (starting with "C" or "R") are detected automatically; // for these, a no-op stop function is returned without calling the API. func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { if chatID == "" { return func() {}, nil } // Group/room chats: LINE loading animation is 1:1 only. if strings.HasPrefix(chatID, "C") || strings.HasPrefix(chatID, "R") { return func() {}, nil } typingCtx, cancel := context.WithCancel(ctx) var once sync.Once stop := func() { once.Do(cancel) } // Send immediately, then refresh periodically for long-running tasks. if err := c.sendLoading(typingCtx, chatID); err != nil { stop() return stop, err } ticker := time.NewTicker(50 * time.Second) go func() { defer ticker.Stop() for { select { case <-typingCtx.Done(): return case <-ticker.C: if err := c.sendLoading(typingCtx, chatID); err != nil { logger.DebugCF("line", "Failed to refresh loading indicator", map[string]any{ "error": err.Error(), }) } } } }() return stop, nil } // classifySDKError maps an SDK HTTP response to the project's sentinel errors. func classifySDKError(resp *http.Response, err error) error { if resp != nil && resp.Body != nil { resp.Body.Close() } if err == nil { return nil } if resp != nil { return channels.ClassifySendError(resp.StatusCode, err) } return channels.ClassifyNetError(err) } // sendLoading sends a loading animation indicator to the chat. func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error { req := &messaging_api.ShowLoadingAnimationRequest{ ChatId: chatID, LoadingSeconds: 60, } resp, _, err := c.client.WithContext(ctx).ShowLoadingAnimationWithHttpInfo(req) return classifySDKError(resp, err) } // downloadContent downloads media content from the LINE content API. func (c *LINEChannel) downloadContent(messageID, filename string) string { url := fmt.Sprintf(lineContentEndpoint, messageID) return utils.DownloadFile(url, filename, utils.DownloadOptions{ LoggerPrefix: "line", ExtraHeaders: map[string]string{ "Authorization": "Bearer " + c.config.ChannelAccessToken.String(), }, }) } // VoiceCapabilities returns the voice capabilities of the channel. func (c *LINEChannel) VoiceCapabilities() channels.VoiceCapabilities { return channels.VoiceCapabilities{ASR: true, TTS: true} }