2026-02-20 15:25:44 +00:00
|
|
|
package line
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-04-07 15:38:55 +00:00
|
|
|
"errors"
|
2026-02-20 15:25:44 +00:00
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-04-07 15:38:55 +00:00
|
|
|
"github.com/line/line-bot-sdk-go/v8/linebot/messaging_api"
|
|
|
|
|
"github.com/line/line-bot-sdk-go/v8/linebot/webhook"
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
2026-02-22 22:56:48 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/identity"
|
2026-02-20 15:25:44 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
2026-02-22 15:27:55 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
2026-02-20 15:25:44 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
2026-04-07 15:38:55 +00:00
|
|
|
lineContentEndpoint = "https://api-data.line.me/v2/bot/message/%s/content"
|
2026-02-20 15:25:44 +00:00
|
|
|
lineReplyTokenMaxAge = 25 * time.Second
|
2026-03-12 15:55:40 +00:00
|
|
|
|
|
|
|
|
// 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
|
2026-02-20 15:25:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-07 15:38:55 +00:00
|
|
|
// and the official LINE Bot SDK for sending messages.
|
2026-02-20 15:25:44 +00:00
|
|
|
type LINEChannel struct {
|
|
|
|
|
*channels.BaseChannel
|
|
|
|
|
config config.LINEConfig
|
2026-04-07 15:38:55 +00:00
|
|
|
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)
|
2026-02-20 15:25:44 +00:00
|
|
|
ctx context.Context
|
|
|
|
|
cancel context.CancelFunc
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewLINEChannel creates a new LINE channel instance.
|
|
|
|
|
func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {
|
2026-03-27 16:03:34 +00:00
|
|
|
if cfg.ChannelSecret.String() == "" || cfg.ChannelAccessToken.String() == "" {
|
2026-02-20 15:25:44 +00:00
|
|
|
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 15:38:55 +00:00
|
|
|
client, err := messaging_api.NewMessagingApiAPI(cfg.ChannelAccessToken.String())
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create LINE messaging client: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom,
|
|
|
|
|
channels.WithMaxMessageLength(5000),
|
|
|
|
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
2026-02-26 05:24:51 +00:00
|
|
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
)
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
return &LINEChannel{
|
|
|
|
|
BaseChannel: base,
|
|
|
|
|
config: cfg,
|
2026-04-07 15:38:55 +00:00
|
|
|
client: client,
|
2026-02-20 15:25:44 +00:00
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 18:39:09 +00:00
|
|
|
// Start initializes the LINE channel.
|
2026-02-20 15:25:44 +00:00
|
|
|
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
|
2026-04-07 15:38:55 +00:00
|
|
|
info, err := c.client.WithContext(ctx).GetBotInfo()
|
|
|
|
|
if err != nil {
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
} else {
|
2026-04-07 15:38:55 +00:00
|
|
|
c.botUserID = info.UserId
|
|
|
|
|
c.botBasicID = info.BasicId
|
|
|
|
|
c.botDisplayName = info.DisplayName
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.InfoCF("line", "Bot info fetched", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 18:39:09 +00:00
|
|
|
// Stop gracefully stops the LINE channel.
|
2026-02-20 15:25:44 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 18:39:09 +00:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 15:38:55 +00:00
|
|
|
// 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)
|
2026-02-20 15:25:44 +00:00
|
|
|
|
2026-04-07 15:38:55 +00:00
|
|
|
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)
|
|
|
|
|
}
|
2026-02-20 15:25:44 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return 200 immediately, process events asynchronously
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
|
2026-04-07 15:38:55 +00:00
|
|
|
for _, event := range cb.Events {
|
2026-02-20 15:25:44 +00:00
|
|
|
go c.processEvent(event)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 15:38:55 +00:00
|
|
|
func (c *LINEChannel) processEvent(event webhook.EventInterface) {
|
|
|
|
|
msgEvent, ok := event.(webhook.MessageEvent)
|
|
|
|
|
if !ok {
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.DebugCF("line", "Ignoring non-message event", map[string]any{
|
2026-04-07 15:38:55 +00:00
|
|
|
"type": event.GetType(),
|
2026-02-20 15:25:44 +00:00
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 15:38:55 +00:00
|
|
|
senderID, chatID, sourceType := c.resolveSource(msgEvent.Source)
|
|
|
|
|
isGroup := sourceType == "group" || sourceType == "room"
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
// Store reply token for later use
|
2026-04-07 15:38:55 +00:00
|
|
|
if msgEvent.ReplyToken != "" {
|
2026-02-20 15:25:44 +00:00
|
|
|
c.replyTokens.Store(chatID, replyTokenEntry{
|
2026-04-07 15:38:55 +00:00
|
|
|
token: msgEvent.ReplyToken,
|
2026-02-20 15:25:44 +00:00
|
|
|
timestamp: time.Now(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var content string
|
|
|
|
|
var mediaPaths []string
|
2026-04-07 15:38:55 +00:00
|
|
|
var messageID string
|
|
|
|
|
var isMentioned bool
|
2026-02-22 15:27:55 +00:00
|
|
|
|
|
|
|
|
// Helper to register a local file with the media store
|
2026-04-07 15:38:55 +00:00
|
|
|
storeMedia := func(localPath, filename, scope string) string {
|
2026-02-22 15:27:55 +00:00
|
|
|
if store := c.GetMediaStore(); store != nil {
|
|
|
|
|
ref, err := store.Store(localPath, media.MediaMeta{
|
2026-04-07 15:38:55 +00:00
|
|
|
Filename: filename,
|
|
|
|
|
Source: "line",
|
2026-02-22 15:27:55 +00:00
|
|
|
}, scope)
|
|
|
|
|
if err == nil {
|
|
|
|
|
return ref
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-22 15:27:55 +00:00
|
|
|
return localPath // fallback
|
|
|
|
|
}
|
2026-02-20 15:25:44 +00:00
|
|
|
|
2026-04-07 15:38:55 +00:00
|
|
|
switch msg := msgEvent.Message.(type) {
|
|
|
|
|
case webhook.TextMessageContent:
|
|
|
|
|
messageID = msg.Id
|
2026-02-20 15:25:44 +00:00
|
|
|
content = msg.Text
|
2026-04-07 15:38:55 +00:00
|
|
|
isMentioned = c.isBotMentioned(msg)
|
|
|
|
|
// Store quote token for quoting the original message in reply
|
|
|
|
|
if msg.QuoteToken != "" {
|
|
|
|
|
c.quoteTokens.Store(chatID, msg.QuoteToken)
|
|
|
|
|
}
|
2026-02-20 15:25:44 +00:00
|
|
|
// Strip bot mention from text in group chats
|
|
|
|
|
if isGroup {
|
|
|
|
|
content = c.stripBotMention(content, msg)
|
|
|
|
|
}
|
2026-04-07 15:38:55 +00:00
|
|
|
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))
|
2026-02-20 15:25:44 +00:00
|
|
|
content = "[image]"
|
|
|
|
|
}
|
2026-04-07 15:38:55 +00:00
|
|
|
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))
|
2026-02-20 15:25:44 +00:00
|
|
|
content = "[audio]"
|
|
|
|
|
}
|
2026-04-07 15:38:55 +00:00
|
|
|
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))
|
2026-02-20 15:25:44 +00:00
|
|
|
content = "[video]"
|
|
|
|
|
}
|
2026-04-07 15:38:55 +00:00
|
|
|
case webhook.FileMessageContent:
|
|
|
|
|
messageID = msg.Id
|
2026-02-20 15:25:44 +00:00
|
|
|
content = "[file]"
|
2026-04-07 15:38:55 +00:00
|
|
|
case webhook.StickerMessageContent:
|
|
|
|
|
messageID = msg.Id
|
2026-02-20 15:25:44 +00:00
|
|
|
content = "[sticker]"
|
|
|
|
|
default:
|
2026-04-07 15:38:55 +00:00
|
|
|
logger.DebugCF("line", "Ignoring unsupported message type", map[string]any{
|
|
|
|
|
"type": msgEvent.Message.GetType(),
|
|
|
|
|
})
|
|
|
|
|
return
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(content) == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
metadata := map[string]string{
|
|
|
|
|
"platform": "line",
|
2026-04-07 15:38:55 +00:00
|
|
|
"source_type": sourceType,
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 13:57:12 +00:00
|
|
|
var peer bus.Peer
|
2026-02-20 15:25:44 +00:00
|
|
|
if isGroup {
|
2026-02-22 13:57:12 +00:00
|
|
|
peer = bus.Peer{Kind: "group", ID: chatID}
|
2026-02-20 15:25:44 +00:00
|
|
|
} else {
|
2026-02-22 13:57:12 +00:00
|
|
|
peer = bus.Peer{Kind: "direct", ID: senderID}
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.DebugCF("line", "Received message", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"sender_id": senderID,
|
|
|
|
|
"chat_id": chatID,
|
2026-04-07 15:38:55 +00:00
|
|
|
"message_type": msgEvent.Message.GetType(),
|
2026-02-20 15:25:44 +00:00
|
|
|
"is_group": isGroup,
|
|
|
|
|
"preview": utils.Truncate(content, 50),
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-22 22:56:48 +00:00
|
|
|
sender := bus.SenderInfo{
|
|
|
|
|
Platform: "line",
|
|
|
|
|
PlatformID: senderID,
|
|
|
|
|
CanonicalID: identity.BuildCanonicalID("line", senderID),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !c.IsAllowedSender(sender) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 15:38:55 +00:00
|
|
|
c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// isBotMentioned checks if the bot is mentioned in the message.
|
2026-04-07 15:38:55 +00:00
|
|
|
// It first checks the mention metadata (userId match or IsSelf), then falls back
|
2026-02-20 15:25:44 +00:00
|
|
|
// to text-based detection using the bot's display name, since LINE may
|
|
|
|
|
// not include userId in mentionees for Official Accounts.
|
2026-04-07 15:38:55 +00:00
|
|
|
func (c *LINEChannel) isBotMentioned(msg webhook.TextMessageContent) bool {
|
2026-02-20 15:25:44 +00:00
|
|
|
if msg.Mention != nil {
|
|
|
|
|
for _, m := range msg.Mention.Mentionees {
|
2026-04-07 15:38:55 +00:00
|
|
|
switch mentionee := m.(type) {
|
|
|
|
|
case webhook.AllMentionee:
|
2026-02-20 15:25:44 +00:00
|
|
|
return true
|
2026-04-07 15:38:55 +00:00
|
|
|
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 {
|
2026-02-20 15:25:44 +00:00
|
|
|
runes := []rune(msg.Text)
|
2026-04-07 15:38:55 +00:00
|
|
|
end := int(mentionee.Index) + int(mentionee.Length)
|
2026-02-20 15:25:44 +00:00
|
|
|
if end <= len(runes) {
|
2026-04-07 15:38:55 +00:00
|
|
|
mentionText := string(runes[mentionee.Index:end])
|
2026-02-20 15:25:44 +00:00
|
|
|
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.
|
2026-04-07 15:38:55 +00:00
|
|
|
func (c *LINEChannel) stripBotMention(text string, msg webhook.TextMessageContent) string {
|
2026-02-20 15:25:44 +00:00
|
|
|
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
|
2026-04-07 15:38:55 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-07 15:38:55 +00:00
|
|
|
case webhook.AllMentionee:
|
|
|
|
|
// Don't strip @All mentions
|
|
|
|
|
continue
|
|
|
|
|
default:
|
|
|
|
|
continue
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
2026-04-07 15:38:55 +00:00
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
if shouldStrip {
|
2026-04-07 15:38:55 +00:00
|
|
|
start := int(index)
|
|
|
|
|
end := int(index) + int(length)
|
2026-02-20 15:25:44 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 15:38:55 +00:00
|
|
|
// 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"
|
2026-02-20 15:25:44 +00:00
|
|
|
default:
|
2026-04-07 15:38:55 +00:00
|
|
|
logger.WarnCF("line", "Unknown source type", map[string]any{
|
|
|
|
|
"type": fmt.Sprintf("%T", source),
|
|
|
|
|
})
|
|
|
|
|
return "", "", "unknown"
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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.
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
2026-02-20 15:25:44 +00:00
|
|
|
if !c.IsRunning() {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, channels.ErrNotRunning
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Load and consume quote token for this chat
|
|
|
|
|
var quoteToken string
|
|
|
|
|
if qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok {
|
|
|
|
|
quoteToken = qt.(string)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 15:38:55 +00:00
|
|
|
textMsg := messaging_api.TextMessage{
|
|
|
|
|
Text: msg.Content,
|
|
|
|
|
QuoteToken: quoteToken,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
// 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 {
|
2026-04-07 15:38:55 +00:00
|
|
|
_, err := c.client.WithContext(ctx).ReplyMessage(&messaging_api.ReplyMessageRequest{
|
|
|
|
|
ReplyToken: tokenEntry.token,
|
|
|
|
|
Messages: []messaging_api.MessageInterface{&textMsg},
|
|
|
|
|
})
|
|
|
|
|
if err == nil {
|
2026-02-21 08:35:56 +00:00
|
|
|
logger.DebugCF("line", "Message sent via Reply API", map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"chat_id": msg.ChatID,
|
|
|
|
|
"quoted": quoteToken != "",
|
|
|
|
|
})
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, nil
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
logger.DebugC("line", "Reply API failed, falling back to Push API")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fall back to Push API
|
2026-04-07 15:38:55 +00:00
|
|
|
_, err := c.client.WithContext(ctx).PushMessage(&messaging_api.PushMessageRequest{
|
|
|
|
|
To: msg.ChatID,
|
|
|
|
|
Messages: []messaging_api.MessageInterface{&textMsg},
|
|
|
|
|
}, "")
|
|
|
|
|
return nil, err
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:10:57 +00:00
|
|
|
// 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.
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
|
2026-02-22 19:10:57 +00:00
|
|
|
if !c.IsRunning() {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, channels.ErrNotRunning
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
store := c.GetMediaStore()
|
|
|
|
|
if store == nil {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 15:38:55 +00:00
|
|
|
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 {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, err
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, nil
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:29:09 +00:00
|
|
|
// StartTyping implements channels.TypingCapable using LINE's loading animation.
|
|
|
|
|
//
|
2026-02-26 19:02:40 +00:00
|
|
|
// 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.
|
2026-02-26 11:29:09 +00:00
|
|
|
func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
|
|
|
|
if chatID == "" {
|
|
|
|
|
return func() {}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 19:02:40 +00:00
|
|
|
// Group/room chats: LINE loading animation is 1:1 only.
|
|
|
|
|
if strings.HasPrefix(chatID, "C") || strings.HasPrefix(chatID, "R") {
|
|
|
|
|
return func() {}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 11:29:09 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
// sendLoading sends a loading animation indicator to the chat.
|
2026-02-26 11:29:09 +00:00
|
|
|
func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error {
|
2026-04-07 15:38:55 +00:00
|
|
|
_, err := c.client.WithContext(ctx).ShowLoadingAnimation(&messaging_api.ShowLoadingAnimationRequest{
|
|
|
|
|
ChatId: chatID,
|
|
|
|
|
LoadingSeconds: 60,
|
|
|
|
|
})
|
|
|
|
|
return err
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 15:38:55 +00:00
|
|
|
// downloadContent downloads media content from the LINE content API.
|
2026-02-20 15:25:44 +00:00
|
|
|
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{
|
2026-03-27 16:03:34 +00:00
|
|
|
"Authorization": "Bearer " + c.config.ChannelAccessToken.String(),
|
2026-02-20 15:25:44 +00:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-04-01 04:21:21 +00:00
|
|
|
|
|
|
|
|
// VoiceCapabilities returns the voice capabilities of the channel.
|
|
|
|
|
func (c *LINEChannel) VoiceCapabilities() channels.VoiceCapabilities {
|
|
|
|
|
return channels.VoiceCapabilities{ASR: true, TTS: true}
|
|
|
|
|
}
|