mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
refactor(line): use official LINE Bot SDK v8
Replace hand-rolled HTTP/HMAC/JSON code (~270 lines) with the official line-bot-sdk-go v8, reducing maintenance burden and eliminating potential bugs in signature verification, request construction, and response parsing. This continues the work started in #500 by @xiaket, addressing all review feedback and rebasing onto current main. Changes: - Replace bytes/crypto/json/io imports with line-bot-sdk-go/v8 - Use webhook.ParseRequest for body reading + signature verification - Use messaging_api.MessagingApiAPI for ReplyMessage/PushMessage/ShowLoadingAnimation/GetBotInfo - Type-switch on webhook.MessageEvent message types (TextMessageContent, ImageMessageContent, etc.) instead of JSON unmarshalling - Type-switch on webhook.SourceInterface (UserSource/GroupSource/RoomSource) - Type-switch on webhook.Mentionee (UserMentionee/AllMentionee) Review feedback addressed (from #500): - Use WithContext(ctx) on all SDK calls to preserve cancellation/timeout - Fix variable shadowing of isMentioned (declared at function scope) - Remove reflect-based message ID extraction (use type switch + msg.Id) - Use mentionee.IsSelf for cleaner bot mention detection - Preserve body size security check via http.MaxBytesReader before webhook.ParseRequest (compatible with #1413) All existing tests pass without modification.
This commit is contained in:
@@ -24,6 +24,7 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/h2non/filetype v1.1.3
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/line/line-bot-sdk-go/v8 v8.19.0
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/minio/selfupdate v0.6.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.1
|
||||
|
||||
@@ -175,6 +175,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/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/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/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
|
||||
+171
-295
@@ -1,19 +1,17 @@
|
||||
package line
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"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"
|
||||
@@ -24,13 +22,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
lineAPIBase = "https://api.line.me/v2/bot"
|
||||
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"
|
||||
lineContentEndpoint = "https://api-data.line.me/v2/bot/message/%s/content"
|
||||
lineReplyTokenMaxAge = 25 * time.Second
|
||||
|
||||
// Limit request body to prevent memory exhaustion (DoS).
|
||||
@@ -45,17 +37,16 @@ type replyTokenEntry struct {
|
||||
|
||||
// LINEChannel implements the Channel interface for LINE Official Account
|
||||
// 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 {
|
||||
*channels.BaseChannel
|
||||
config config.LINEConfig
|
||||
infoClient *http.Client // for bot info lookups (short timeout)
|
||||
apiClient *http.Client // for messaging API calls
|
||||
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)
|
||||
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
|
||||
}
|
||||
@@ -66,6 +57,11 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha
|
||||
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
|
||||
}
|
||||
|
||||
client, err := messaging_api.NewMessagingApiAPI(cfg.ChannelAccessToken.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create LINE messaging client: %w", err)
|
||||
}
|
||||
|
||||
base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom,
|
||||
channels.WithMaxMessageLength(5000),
|
||||
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||
@@ -75,8 +71,7 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha
|
||||
return &LINEChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
infoClient: &http.Client{Timeout: 10 * time.Second},
|
||||
apiClient: &http.Client{Timeout: 30 * time.Second},
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -87,11 +82,15 @@ func (c *LINEChannel) Start(ctx context.Context) error {
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
// 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{
|
||||
"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,
|
||||
@@ -104,39 +103,6 @@ func (c *LINEChannel) Start(ctx context.Context) error {
|
||||
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.
|
||||
func (c *LINEChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("line", "Stopping LINE channel")
|
||||
@@ -170,140 +136,69 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
logger.ErrorCF("line", "Failed to read request body", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if int64(len(body)) > maxWebhookBodySize {
|
||||
logger.WarnC("line", "Webhook request body too large, rejected")
|
||||
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
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 payload.Events {
|
||||
for _, event := range cb.Events {
|
||||
go c.processEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// verifySignature validates the X-Line-Signature using HMAC-SHA256.
|
||||
func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
|
||||
if signature == "" {
|
||||
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" {
|
||||
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.Type,
|
||||
"type": event.GetType(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
senderID := event.Source.UserID
|
||||
chatID := c.resolveChatID(event.Source)
|
||||
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
|
||||
}
|
||||
senderID, chatID, sourceType := c.resolveSource(msgEvent.Source)
|
||||
isGroup := sourceType == "group" || sourceType == "room"
|
||||
|
||||
// Store reply token for later use
|
||||
if event.ReplyToken != "" {
|
||||
if msgEvent.ReplyToken != "" {
|
||||
c.replyTokens.Store(chatID, replyTokenEntry{
|
||||
token: event.ReplyToken,
|
||||
token: msgEvent.ReplyToken,
|
||||
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 mediaPaths []string
|
||||
|
||||
scope := channels.BuildMediaScope("line", chatID, msg.ID)
|
||||
var messageID string
|
||||
var isMentioned bool
|
||||
|
||||
// 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 {
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: filename,
|
||||
Source: "line",
|
||||
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
||||
Filename: filename,
|
||||
Source: "line",
|
||||
}, scope)
|
||||
if err == nil {
|
||||
return ref
|
||||
@@ -312,37 +207,51 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||
return localPath // fallback
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "text":
|
||||
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 != "" {
|
||||
c.quoteTokens.Store(chatID, msg.QuoteToken)
|
||||
}
|
||||
// Strip bot mention from text in group chats
|
||||
if isGroup {
|
||||
content = c.stripBotMention(content, msg)
|
||||
}
|
||||
case "image":
|
||||
localPath := c.downloadContent(msg.ID, "image.jpg")
|
||||
if localPath != "" {
|
||||
mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg"))
|
||||
case webhook.ImageMessageContent:
|
||||
messageID = msg.Id
|
||||
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 "audio":
|
||||
localPath := c.downloadContent(msg.ID, "audio.m4a")
|
||||
if localPath != "" {
|
||||
mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a"))
|
||||
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 "video":
|
||||
localPath := c.downloadContent(msg.ID, "video.mp4")
|
||||
if localPath != "" {
|
||||
mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4"))
|
||||
case webhook.VideoMessageContent:
|
||||
messageID = msg.Id
|
||||
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 "file":
|
||||
case webhook.FileMessageContent:
|
||||
messageID = msg.Id
|
||||
content = "[file]"
|
||||
case "sticker":
|
||||
case webhook.StickerMessageContent:
|
||||
messageID = msg.Id
|
||||
content = "[sticker]"
|
||||
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) == "" {
|
||||
@@ -351,7 +260,6 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||
|
||||
// In group chats, apply unified group trigger filtering
|
||||
if isGroup {
|
||||
isMentioned := c.isBotMentioned(msg)
|
||||
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
||||
if !respond {
|
||||
logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{
|
||||
@@ -364,7 +272,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||
|
||||
metadata := map[string]string{
|
||||
"platform": "line",
|
||||
"source_type": event.Source.Type,
|
||||
"source_type": sourceType,
|
||||
}
|
||||
|
||||
var peer bus.Peer
|
||||
@@ -377,7 +285,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||
logger.DebugCF("line", "Received message", map[string]any{
|
||||
"sender_id": senderID,
|
||||
"chat_id": chatID,
|
||||
"message_type": msg.Type,
|
||||
"message_type": msgEvent.Message.GetType(),
|
||||
"is_group": isGroup,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
@@ -392,34 +300,32 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender)
|
||||
c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
|
||||
}
|
||||
|
||||
// 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
|
||||
// not include userId in mentionees for Official Accounts.
|
||||
func (c *LINEChannel) isBotMentioned(msg lineMessage) bool {
|
||||
// Check mention metadata
|
||||
func (c *LINEChannel) isBotMentioned(msg webhook.TextMessageContent) bool {
|
||||
if msg.Mention != nil {
|
||||
for _, m := range msg.Mention.Mentionees {
|
||||
if m.Type == "all" {
|
||||
switch mentionee := m.(type) {
|
||||
case webhook.AllMentionee:
|
||||
return true
|
||||
}
|
||||
if c.botUserID != "" && m.UserID == c.botUserID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Mention metadata exists with mentionees but bot not matched by userId.
|
||||
// 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.
|
||||
if c.botDisplayName != "" {
|
||||
for _, m := range msg.Mention.Mentionees {
|
||||
if m.Index >= 0 && m.Length > 0 {
|
||||
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 := m.Index + m.Length
|
||||
end := int(mentionee.Index) + int(mentionee.Length)
|
||||
if end <= len(runes) {
|
||||
mentionText := string(runes[m.Index:end])
|
||||
mentionText := string(runes[mentionee.Index:end])
|
||||
if strings.Contains(mentionText, c.botDisplayName) {
|
||||
return true
|
||||
}
|
||||
@@ -438,30 +344,43 @@ func (c *LINEChannel) isBotMentioned(msg lineMessage) bool {
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// Try to strip using mention metadata indices
|
||||
if msg.Mention != nil {
|
||||
runes := []rune(text)
|
||||
for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- {
|
||||
m := msg.Mention.Mentionees[i]
|
||||
// Strip if userId matches OR if the mention text contains the bot display name
|
||||
shouldStrip := false
|
||||
if c.botUserID != "" && m.UserID == c.botUserID {
|
||||
shouldStrip = true
|
||||
} else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 {
|
||||
end := m.Index + m.Length
|
||||
if end <= len(runes) {
|
||||
mentionText := string(runes[m.Index:end])
|
||||
if strings.Contains(mentionText, c.botDisplayName) {
|
||||
shouldStrip = true
|
||||
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 := m.Index
|
||||
end := m.Index + m.Length
|
||||
start := int(index)
|
||||
end := int(index) + int(length)
|
||||
if start >= 0 && end <= len(runes) {
|
||||
runes = append(runes[:start], runes[end:]...)
|
||||
stripped = true
|
||||
@@ -481,16 +400,20 @@ func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// resolveChatID determines the chat ID from the event source.
|
||||
// For group/room messages, use the group/room ID; for 1:1, use the user ID.
|
||||
func (c *LINEChannel) resolveChatID(source lineSource) string {
|
||||
switch source.Type {
|
||||
case "group":
|
||||
return source.GroupID
|
||||
case "room":
|
||||
return source.RoomID
|
||||
// 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:
|
||||
return source.UserID
|
||||
logger.WarnCF("line", "Unknown source type", map[string]any{
|
||||
"type": fmt.Sprintf("%T", source),
|
||||
})
|
||||
return "", "", "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,11 +430,20 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
|
||||
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 {
|
||||
if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil {
|
||||
_, err := c.client.WithContext(ctx).ReplyMessage(&messaging_api.ReplyMessageRequest{
|
||||
ReplyToken: tokenEntry.token,
|
||||
Messages: []messaging_api.MessageInterface{&textMsg},
|
||||
})
|
||||
if err == nil {
|
||||
logger.DebugCF("line", "Message sent via Reply API", map[string]any{
|
||||
"chat_id": msg.ChatID,
|
||||
"quoted": quoteToken != "",
|
||||
@@ -523,7 +455,11 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
|
||||
}
|
||||
|
||||
// Fall back to Push API
|
||||
return nil, c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken)
|
||||
_, err := c.client.WithContext(ctx).PushMessage(&messaging_api.PushMessageRequest{
|
||||
To: msg.ChatID,
|
||||
Messages: []messaging_api.MessageInterface{&textMsg},
|
||||
}, "")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// SendMedia implements the channels.MediaSender interface.
|
||||
@@ -548,7 +484,11 @@ func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag
|
||||
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}
|
||||
if _, err := c.client.WithContext(ctx).PushMessage(&messaging_api.PushMessageRequest{
|
||||
To: msg.ChatID,
|
||||
Messages: []messaging_api.MessageInterface{&textMsg},
|
||||
}, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -556,38 +496,6 @@ func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag
|
||||
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.
|
||||
//
|
||||
// NOTE: The LINE loading animation API only works for 1:1 chats.
|
||||
@@ -635,46 +543,14 @@ func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), e
|
||||
|
||||
// sendLoading sends a loading animation indicator to the chat.
|
||||
func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error {
|
||||
payload := map[string]any{
|
||||
"chatId": chatID,
|
||||
"loadingSeconds": 60,
|
||||
}
|
||||
return c.callAPI(ctx, lineLoadingEndpoint, payload)
|
||||
_, err := c.client.WithContext(ctx).ShowLoadingAnimation(&messaging_api.ShowLoadingAnimationRequest{
|
||||
ChatId: chatID,
|
||||
LoadingSeconds: 60,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// callAPI makes an authenticated POST request to the LINE 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.
|
||||
// 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{
|
||||
|
||||
Reference in New Issue
Block a user