Merge pull request #2413 from ex-takashima/refactor/line-sdk

refactor(line): use official LINE Bot SDK v8
This commit is contained in:
美電球 2026-05-08 14:00:00 +08:00 committed by GitHub
commit 01280eaa53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 227 additions and 305 deletions

1
go.mod
View file

@ -22,6 +22,7 @@ require (
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.3 github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.6.1 github.com/larksuite/oapi-sdk-go/v3 v3.6.1
github.com/line/line-bot-sdk-go/v8 v8.19.0
github.com/mdp/qrterminal/v3 v3.2.1 github.com/mdp/qrterminal/v3 v3.2.1
github.com/minio/selfupdate v0.6.0 github.com/minio/selfupdate v0.6.0
github.com/modelcontextprotocol/go-sdk v1.5.0 github.com/modelcontextprotocol/go-sdk v1.5.0

2
go.sum
View file

@ -179,6 +179,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.6.1 h1:vAdu+sX9yXNkKnKnYQeIv6yBkjP37Q1JEJHmMa2eCjQ= github.com/larksuite/oapi-sdk-go/v3 v3.6.1 h1:vAdu+sX9yXNkKnKnYQeIv6yBkjP37Q1JEJHmMa2eCjQ=
github.com/larksuite/oapi-sdk-go/v3 v3.6.1/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= github.com/larksuite/oapi-sdk-go/v3 v3.6.1/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/line/line-bot-sdk-go/v8 v8.19.0 h1:5FD/1SprRZ8Y0FiUI6syYiBewOs0ak2tuUBMYN0wzE4=
github.com/line/line-bot-sdk-go/v8 v8.19.0/go.mod h1:AeSRUuu7WGgveGDJb6DyKyFUOst2UB2aF6LO2cQeuXs=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=

View file

@ -1,19 +1,17 @@
package line package line
import ( import (
"bytes"
"context" "context"
"crypto/hmac" "errors"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"time" "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/bus"
"github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
@ -24,13 +22,7 @@ import (
) )
const ( const (
lineAPIBase = "https://api.line.me/v2/bot" lineContentEndpoint = "https://api-data.line.me/v2/bot/message/%s/content"
lineDataAPIBase = "https://api-data.line.me/v2/bot"
lineReplyEndpoint = lineAPIBase + "/message/reply"
linePushEndpoint = lineAPIBase + "/message/push"
lineContentEndpoint = lineDataAPIBase + "/message/%s/content"
lineBotInfoEndpoint = lineAPIBase + "/info"
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
lineReplyTokenMaxAge = 25 * time.Second lineReplyTokenMaxAge = 25 * time.Second
// Limit request body to prevent memory exhaustion (DoS). // Limit request body to prevent memory exhaustion (DoS).
@ -45,17 +37,16 @@ type replyTokenEntry struct {
// LINEChannel implements the Channel interface for LINE Official Account // LINEChannel implements the Channel interface for LINE Official Account
// using the LINE Messaging API with HTTP webhook for receiving messages // using the LINE Messaging API with HTTP webhook for receiving messages
// and REST API for sending messages. // and the official LINE Bot SDK for sending messages.
type LINEChannel struct { type LINEChannel struct {
*channels.BaseChannel *channels.BaseChannel
config *config.LINESettings config *config.LINESettings
infoClient *http.Client // for bot info lookups (short timeout) client *messaging_api.MessagingApiAPI
apiClient *http.Client // for messaging API calls botUserID string // Bot's user ID
botUserID string // Bot's user ID botBasicID string // Bot's basic ID (e.g. @216ru...)
botBasicID string // Bot's basic ID (e.g. @216ru...) botDisplayName string // Bot's display name for text-based mention detection
botDisplayName string // Bot's display name for text-based mention detection replyTokens sync.Map // chatID -> replyTokenEntry
replyTokens sync.Map // chatID -> replyTokenEntry quoteTokens sync.Map // chatID -> quoteToken (string)
quoteTokens sync.Map // chatID -> quoteToken (string)
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
} }
@ -70,6 +61,14 @@ func NewLINEChannel(
return nil, fmt.Errorf("line channel_secret and channel_access_token are required") 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, base := channels.NewBaseChannel("line", cfg, messageBus, bc.AllowFrom,
channels.WithMaxMessageLength(5000), channels.WithMaxMessageLength(5000),
channels.WithGroupTrigger(bc.GroupTrigger), channels.WithGroupTrigger(bc.GroupTrigger),
@ -79,8 +78,7 @@ func NewLINEChannel(
return &LINEChannel{ return &LINEChannel{
BaseChannel: base, BaseChannel: base,
config: cfg, config: cfg,
infoClient: &http.Client{Timeout: 10 * time.Second}, client: client,
apiClient: &http.Client{Timeout: 30 * time.Second},
}, nil }, nil
} }
@ -91,11 +89,15 @@ func (c *LINEChannel) Start(ctx context.Context) error {
c.ctx, c.cancel = context.WithCancel(ctx) c.ctx, c.cancel = context.WithCancel(ctx)
// Fetch bot profile to get bot's userId for mention detection // Fetch bot profile to get bot's userId for mention detection
if err := c.fetchBotInfo(); err != nil { info, err := c.client.WithContext(ctx).GetBotInfo()
if err != nil {
logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{ logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{
"error": err.Error(), "error": err.Error(),
}) })
} else { } else {
c.botUserID = info.UserId
c.botBasicID = info.BasicId
c.botDisplayName = info.DisplayName
logger.InfoCF("line", "Bot info fetched", map[string]any{ logger.InfoCF("line", "Bot info fetched", map[string]any{
"bot_user_id": c.botUserID, "bot_user_id": c.botUserID,
"basic_id": c.botBasicID, "basic_id": c.botBasicID,
@ -108,39 +110,6 @@ func (c *LINEChannel) Start(ctx context.Context) error {
return nil return nil
} }
// fetchBotInfo retrieves the bot's userId, basicId, and displayName from the LINE API.
func (c *LINEChannel) fetchBotInfo() error {
req, err := http.NewRequest(http.MethodGet, lineBotInfoEndpoint, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken.String())
resp, err := c.infoClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bot info API returned status %d", resp.StatusCode)
}
var info struct {
UserID string `json:"userId"`
BasicID string `json:"basicId"`
DisplayName string `json:"displayName"`
}
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return err
}
c.botUserID = info.UserID
c.botBasicID = info.BasicID
c.botDisplayName = info.DisplayName
return nil
}
// Stop gracefully stops the LINE channel. // Stop gracefully stops the LINE channel.
func (c *LINEChannel) Stop(ctx context.Context) error { func (c *LINEChannel) Stop(ctx context.Context) error {
logger.InfoC("line", "Stopping LINE channel") logger.InfoC("line", "Stopping LINE channel")
@ -174,140 +143,70 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1)) // 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 { if err != nil {
logger.ErrorCF("line", "Failed to read request body", map[string]any{ var maxBytesErr *http.MaxBytesError
"error": err.Error(), if errors.As(err, &maxBytesErr) {
}) logger.WarnC("line", "Webhook request body too large, rejected")
http.Error(w, "Bad request", http.StatusBadRequest) http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
return } else if errors.Is(err, webhook.ErrInvalidSignature) {
} logger.WarnC("line", "Invalid webhook signature")
if int64(len(body)) > maxWebhookBodySize { http.Error(w, "Forbidden", http.StatusForbidden)
logger.WarnC("line", "Webhook request body too large, rejected") } else {
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge) logger.ErrorCF("line", "Failed to parse webhook request", map[string]any{
return "error": err.Error(),
} })
http.Error(w, "Bad request", http.StatusBadRequest)
signature := r.Header.Get("X-Line-Signature") }
if !c.verifySignature(body, signature) {
logger.WarnC("line", "Invalid webhook signature")
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
var payload struct {
Events []lineEvent `json:"events"`
}
if err := json.Unmarshal(body, &payload); err != nil {
logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
return return
} }
// Return 200 immediately, process events asynchronously // Return 200 immediately, process events asynchronously
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
for _, event := range payload.Events { for _, event := range cb.Events {
go c.processEvent(event) go c.processEvent(event)
} }
} }
// verifySignature validates the X-Line-Signature using HMAC-SHA256. func (c *LINEChannel) processEvent(event webhook.EventInterface) {
func (c *LINEChannel) verifySignature(body []byte, signature string) bool { msgEvent, ok := event.(webhook.MessageEvent)
if signature == "" { if !ok {
return false
}
mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret.String()))
mac.Write(body)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
// LINE webhook event types
type lineEvent struct {
Type string `json:"type"`
ReplyToken string `json:"replyToken"`
Source lineSource `json:"source"`
Message json.RawMessage `json:"message"`
Timestamp int64 `json:"timestamp"`
}
type lineSource struct {
Type string `json:"type"` // "user", "group", "room"
UserID string `json:"userId"`
GroupID string `json:"groupId"`
RoomID string `json:"roomId"`
}
type lineMessage struct {
ID string `json:"id"`
Type string `json:"type"` // "text", "image", "video", "audio", "file", "sticker"
Text string `json:"text"`
QuoteToken string `json:"quoteToken"`
Mention *struct {
Mentionees []lineMentionee `json:"mentionees"`
} `json:"mention"`
ContentProvider struct {
Type string `json:"type"`
} `json:"contentProvider"`
}
type lineMentionee struct {
Index int `json:"index"`
Length int `json:"length"`
Type string `json:"type"` // "user", "all"
UserID string `json:"userId"`
}
func (c *LINEChannel) processEvent(event lineEvent) {
if event.Type != "message" {
logger.DebugCF("line", "Ignoring non-message event", map[string]any{ logger.DebugCF("line", "Ignoring non-message event", map[string]any{
"type": event.Type, "type": event.GetType(),
}) })
return return
} }
senderID := event.Source.UserID senderID, chatID, sourceType := c.resolveSource(msgEvent.Source)
chatID := c.resolveChatID(event.Source) isGroup := sourceType == "group" || sourceType == "room"
isGroup := event.Source.Type == "group" || event.Source.Type == "room"
var msg lineMessage
if err := json.Unmarshal(event.Message, &msg); err != nil {
logger.ErrorCF("line", "Failed to parse message", map[string]any{
"error": err.Error(),
})
return
}
// Store reply token for later use // Store reply token for later use
if event.ReplyToken != "" { if msgEvent.ReplyToken != "" {
c.replyTokens.Store(chatID, replyTokenEntry{ c.replyTokens.Store(chatID, replyTokenEntry{
token: event.ReplyToken, token: msgEvent.ReplyToken,
timestamp: time.Now(), timestamp: time.Now(),
}) })
} }
// Store quote token for quoting the original message in reply
if msg.QuoteToken != "" {
c.quoteTokens.Store(chatID, msg.QuoteToken)
}
var content string var content string
var mediaPaths []string var mediaPaths []string
var messageID string
scope := channels.BuildMediaScope("line", chatID, msg.ID) var quoteToken string
var isMentioned bool
// Helper to register a local file with the media store // Helper to register a local file with the media store
storeMedia := func(localPath, filename string) string { storeMedia := func(localPath, filename, scope string) string {
if store := c.GetMediaStore(); store != nil { if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(localPath, media.MediaMeta{ ref, err := store.Store(localPath, media.MediaMeta{
Filename: filename, Filename: filename,
Source: "line", Source: "line",
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, scope) }, scope)
if err == nil { if err == nil {
return ref return ref
@ -316,37 +215,70 @@ func (c *LINEChannel) processEvent(event lineEvent) {
return localPath // fallback return localPath // fallback
} }
switch msg.Type { switch msg := msgEvent.Message.(type) {
case "text": case webhook.TextMessageContent:
messageID = msg.Id
content = msg.Text 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 // Strip bot mention from text in group chats
if isGroup { if isGroup {
content = c.stripBotMention(content, msg) content = c.stripBotMention(content, msg)
} }
case "image": case webhook.ImageMessageContent:
localPath := c.downloadContent(msg.ID, "image.jpg") messageID = msg.Id
if localPath != "" { if msg.QuoteToken != "" {
mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg")) 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]" content = "[image]"
} }
case "audio": case webhook.AudioMessageContent:
localPath := c.downloadContent(msg.ID, "audio.m4a") messageID = msg.Id
if localPath != "" { if localPath := c.downloadContent(msg.Id, "audio.m4a"); localPath != "" {
mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a")) scope := channels.BuildMediaScope("line", chatID, msg.Id)
mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a", scope))
content = "[audio]" content = "[audio]"
} }
case "video": case webhook.VideoMessageContent:
localPath := c.downloadContent(msg.ID, "video.mp4") messageID = msg.Id
if localPath != "" { if msg.QuoteToken != "" {
mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4")) 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]" content = "[video]"
} }
case "file": case webhook.FileMessageContent:
messageID = msg.Id
content = "[file]" content = "[file]"
case "sticker": 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]" content = "[sticker]"
default: default:
content = fmt.Sprintf("[%s]", msg.Type) logger.DebugCF("line", "Ignoring unsupported message type", map[string]any{
"type": msgEvent.Message.GetType(),
})
return
} }
if strings.TrimSpace(content) == "" { if strings.TrimSpace(content) == "" {
@ -354,9 +286,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
} }
// In group chats, apply unified group trigger filtering // In group chats, apply unified group trigger filtering
isMentioned := false
if isGroup { if isGroup {
isMentioned = c.isBotMentioned(msg)
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
if !respond { if !respond {
logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{ logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{
@ -369,13 +299,13 @@ func (c *LINEChannel) processEvent(event lineEvent) {
metadata := map[string]string{ metadata := map[string]string{
"platform": "line", "platform": "line",
"source_type": event.Source.Type, "source_type": sourceType,
} }
logger.DebugCF("line", "Received message", map[string]any{ logger.DebugCF("line", "Received message", map[string]any{
"sender_id": senderID, "sender_id": senderID,
"chat_id": chatID, "chat_id": chatID,
"message_type": msg.Type, "message_type": msgEvent.Message.GetType(),
"is_group": isGroup, "is_group": isGroup,
"preview": utils.Truncate(content, 50), "preview": utils.Truncate(content, 50),
}) })
@ -395,16 +325,16 @@ func (c *LINEChannel) processEvent(event lineEvent) {
ChatID: chatID, ChatID: chatID,
ChatType: map[bool]string{true: "group", false: "direct"}[isGroup], ChatType: map[bool]string{true: "group", false: "direct"}[isGroup],
SenderID: senderID, SenderID: senderID,
MessageID: msg.ID, MessageID: messageID,
Mentioned: isMentioned, Mentioned: isMentioned,
Raw: metadata, Raw: metadata,
} }
if event.ReplyToken != "" { if msgEvent.ReplyToken != "" {
inboundCtx.ReplyHandles = map[string]string{ inboundCtx.ReplyHandles = map[string]string{
"reply_token": event.ReplyToken, "reply_token": msgEvent.ReplyToken,
} }
if msg.QuoteToken != "" { if quoteToken != "" {
inboundCtx.ReplyHandles["quote_token"] = msg.QuoteToken inboundCtx.ReplyHandles["quote_token"] = quoteToken
} }
} }
@ -412,30 +342,28 @@ func (c *LINEChannel) processEvent(event lineEvent) {
} }
// isBotMentioned checks if the bot is mentioned in the message. // isBotMentioned checks if the bot is mentioned in the message.
// It first checks the mention metadata (userId match), then falls back // 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 // to text-based detection using the bot's display name, since LINE may
// not include userId in mentionees for Official Accounts. // not include userId in mentionees for Official Accounts.
func (c *LINEChannel) isBotMentioned(msg lineMessage) bool { func (c *LINEChannel) isBotMentioned(msg webhook.TextMessageContent) bool {
// Check mention metadata
if msg.Mention != nil { if msg.Mention != nil {
for _, m := range msg.Mention.Mentionees { for _, m := range msg.Mention.Mentionees {
if m.Type == "all" { switch mentionee := m.(type) {
case webhook.AllMentionee:
return true return true
} case webhook.UserMentionee:
if c.botUserID != "" && m.UserID == c.botUserID { if mentionee.IsSelf {
return true return true
} }
} if c.botUserID != "" && mentionee.UserId == c.botUserID {
// Mention metadata exists with mentionees but bot not matched by userId. return true
// The bot IS likely mentioned (LINE includes mention struct when bot is @-ed), }
// so check if any mentionee overlaps with bot display name in text. // Check if mentionee text overlaps with bot display name
if c.botDisplayName != "" { if c.botDisplayName != "" && mentionee.Index >= 0 && mentionee.Length > 0 {
for _, m := range msg.Mention.Mentionees {
if m.Index >= 0 && m.Length > 0 {
runes := []rune(msg.Text) runes := []rune(msg.Text)
end := m.Index + m.Length end := int(mentionee.Index) + int(mentionee.Length)
if end <= len(runes) { if end <= len(runes) {
mentionText := string(runes[m.Index:end]) mentionText := string(runes[mentionee.Index:end])
if strings.Contains(mentionText, c.botDisplayName) { if strings.Contains(mentionText, c.botDisplayName) {
return true return true
} }
@ -454,30 +382,43 @@ func (c *LINEChannel) isBotMentioned(msg lineMessage) bool {
} }
// stripBotMention removes the @BotName mention text from the message. // stripBotMention removes the @BotName mention text from the message.
func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string { func (c *LINEChannel) stripBotMention(text string, msg webhook.TextMessageContent) string {
stripped := false stripped := false
// Try to strip using mention metadata indices
if msg.Mention != nil { if msg.Mention != nil {
runes := []rune(text) runes := []rune(text)
for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- { for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- {
m := msg.Mention.Mentionees[i] m := msg.Mention.Mentionees[i]
// Strip if userId matches OR if the mention text contains the bot display name
shouldStrip := false shouldStrip := false
if c.botUserID != "" && m.UserID == c.botUserID { var index, length int32
shouldStrip = true
} else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 { switch mentionee := m.(type) {
end := m.Index + m.Length case webhook.UserMentionee:
if end <= len(runes) { index = mentionee.Index
mentionText := string(runes[m.Index:end]) length = mentionee.Length
if strings.Contains(mentionText, c.botDisplayName) { if mentionee.IsSelf {
shouldStrip = true 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 { if shouldStrip {
start := m.Index start := int(index)
end := m.Index + m.Length end := int(index) + int(length)
if start >= 0 && end <= len(runes) { if start >= 0 && end <= len(runes) {
runes = append(runes[:start], runes[end:]...) runes = append(runes[:start], runes[end:]...)
stripped = true stripped = true
@ -497,16 +438,20 @@ func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string {
return strings.TrimSpace(text) return strings.TrimSpace(text)
} }
// resolveChatID determines the chat ID from the event source. // resolveSource extracts senderID, chatID, and source type from the event source.
// For group/room messages, use the group/room ID; for 1:1, use the user ID. func (c *LINEChannel) resolveSource(source webhook.SourceInterface) (senderID, chatID, sourceType string) {
func (c *LINEChannel) resolveChatID(source lineSource) string { switch src := source.(type) {
switch source.Type { case webhook.GroupSource:
case "group": return src.UserId, src.GroupId, "group"
return source.GroupID case webhook.RoomSource:
case "room": return src.UserId, src.RoomId, "room"
return source.RoomID case webhook.UserSource:
return src.UserId, src.UserId, "user"
default: default:
return source.UserID logger.WarnCF("line", "Unknown source type", map[string]any{
"type": fmt.Sprintf("%T", source),
})
return "", "", "unknown"
} }
} }
@ -523,23 +468,41 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
quoteToken = qt.(string) quoteToken = qt.(string)
} }
textMsg := messaging_api.TextMessage{
Text: msg.Content,
QuoteToken: quoteToken,
}
// Try reply token first (free, valid for ~25 seconds) // Try reply token first (free, valid for ~25 seconds)
if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok { if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok {
tokenEntry := entry.(replyTokenEntry) tokenEntry := entry.(replyTokenEntry)
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge { if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil { 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{ logger.DebugCF("line", "Message sent via Reply API", map[string]any{
"chat_id": msg.ChatID, "chat_id": msg.ChatID,
"quoted": quoteToken != "", "quoted": quoteToken != "",
}) })
return nil, nil return nil, nil
} }
logger.DebugC("line", "Reply API failed, falling back to Push API") logger.DebugCF("line", "Reply API failed, falling back to Push API", map[string]any{
"error": err.Error(),
})
} }
} }
// Fall back to Push API // Fall back to Push API
return nil, c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) 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. // SendMedia implements the channels.MediaSender interface.
@ -564,46 +527,19 @@ func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag
caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename) caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
} }
if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil { textMsg := messaging_api.TextMessage{Text: caption}
return nil, err 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 return nil, nil
} }
// buildTextMessage creates a text message object, optionally with quoteToken.
func buildTextMessage(content, quoteToken string) map[string]string {
msg := map[string]string{
"type": "text",
"text": content,
}
if quoteToken != "" {
msg["quoteToken"] = quoteToken
}
return msg
}
// sendReply sends a message using the LINE Reply API.
func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error {
payload := map[string]any{
"replyToken": replyToken,
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
}
return c.callAPI(ctx, lineReplyEndpoint, payload)
}
// sendPush sends a message using the LINE Push API.
func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error {
payload := map[string]any{
"to": to,
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
}
return c.callAPI(ctx, linePushEndpoint, payload)
}
// StartTyping implements channels.TypingCapable using LINE's loading animation. // StartTyping implements channels.TypingCapable using LINE's loading animation.
// //
// NOTE: The LINE loading animation API only works for 1:1 chats. // NOTE: The LINE loading animation API only works for 1:1 chats.
@ -649,48 +585,31 @@ func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), e
return stop, nil 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. // sendLoading sends a loading animation indicator to the chat.
func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error { func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error {
payload := map[string]any{ req := &messaging_api.ShowLoadingAnimationRequest{
"chatId": chatID, ChatId: chatID,
"loadingSeconds": 60, LoadingSeconds: 60,
} }
return c.callAPI(ctx, lineLoadingEndpoint, payload) resp, _, err := c.client.WithContext(ctx).ShowLoadingAnimationWithHttpInfo(req)
return classifySDKError(resp, err)
} }
// callAPI makes an authenticated POST request to the LINE API. // downloadContent downloads media content from the LINE content API.
func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken.String())
resp, err := c.apiClient.Do(req)
if err != nil {
return channels.ClassifyNetError(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading LINE API error response: %w", err))
}
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody)))
}
return nil
}
// downloadContent downloads media content from the LINE API.
func (c *LINEChannel) downloadContent(messageID, filename string) string { func (c *LINEChannel) downloadContent(messageID, filename string) string {
url := fmt.Sprintf(lineContentEndpoint, messageID) url := fmt.Sprintf(lineContentEndpoint, messageID)
return utils.DownloadFile(url, filename, utils.DownloadOptions{ return utils.DownloadFile(url, filename, utils.DownloadOptions{

View file

@ -11,7 +11,7 @@ import (
) )
func TestWebhookRejectsOversizedBody(t *testing.T) { func TestWebhookRejectsOversizedBody(t *testing.T) {
ch := &LINEChannel{} ch := &LINEChannel{config: &config.LINESettings{}}
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1) oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized)) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
@ -25,7 +25,7 @@ func TestWebhookRejectsOversizedBody(t *testing.T) {
} }
func TestWebhookAcceptsMaxBodySize(t *testing.T) { func TestWebhookAcceptsMaxBodySize(t *testing.T) {
ch := &LINEChannel{} ch := &LINEChannel{config: &config.LINESettings{}}
body := bytes.Repeat([]byte("A"), maxWebhookBodySize) body := bytes.Repeat([]byte("A"), maxWebhookBodySize)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
@ -40,7 +40,7 @@ func TestWebhookAcceptsMaxBodySize(t *testing.T) {
} }
func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) { func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {
ch := &LINEChannel{} ch := &LINEChannel{config: &config.LINESettings{}}
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1) oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized)) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
@ -55,7 +55,7 @@ func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {
} }
func TestWebhookRejectsNonPostMethod(t *testing.T) { func TestWebhookRejectsNonPostMethod(t *testing.T) {
ch := &LINEChannel{} ch := &LINEChannel{config: &config.LINESettings{}}
req := httptest.NewRequest(http.MethodGet, "/webhook", nil) req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()