refactor(line): modularize LINE channel implementation
Broke up the large `pkg/channels/line/line.go` file into a collection of organized files, each serving a specific set of idempotent functions: - `line.go`: Core struct and lifecycle methods - `types.go`: Constants and internal structs - `events.go`: Webhook and event handling - `messages.go`: Outbound messaging logic - `media.go`: Media handling - `typing.go`: Typing indicator logic This improvement aligns the LINE channel package with the repository's modular architecture standard seen in other channel implementations (e.g., Telegram, Discord). All existing tests passed after the refactoring. Co-authored-by: hobbyistlabs-coder <267281733+hobbyistlabs-coder@users.noreply.github.com>
This commit is contained in:
parent
c31965f35e
commit
ab0cbdfaf2
6 changed files with 615 additions and 563 deletions
327
pkg/channels/line/events.go
Normal file
327
pkg/channels/line/events.go
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
package line
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jane/pkg/bus"
|
||||||
|
"jane/pkg/channels"
|
||||||
|
"jane/pkg/identity"
|
||||||
|
"jane/pkg/logger"
|
||||||
|
"jane/pkg/media"
|
||||||
|
"jane/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WebhookPath returns the path for registering on the shared HTTP server.
|
||||||
|
func (c *LINEChannel) WebhookPath() string {
|
||||||
|
if c.config.WebhookPath != "" {
|
||||||
|
return c.config.WebhookPath
|
||||||
|
}
|
||||||
|
return "/webhook/line"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements http.Handler for the shared HTTP server.
|
||||||
|
func (c *LINEChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c.webhookHandler(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookHandler handles incoming LINE webhook requests.
|
||||||
|
func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return 200 immediately, process events asynchronously
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
for _, event := range payload.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))
|
||||||
|
mac.Write(body)
|
||||||
|
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
|
||||||
|
return hmac.Equal([]byte(expected), []byte(signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
|
if event.Type != "message" {
|
||||||
|
logger.DebugCF("line", "Ignoring non-message event", map[string]any{
|
||||||
|
"type": event.Type,
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store reply token for later use
|
||||||
|
if event.ReplyToken != "" {
|
||||||
|
c.replyTokens.Store(chatID, replyTokenEntry{
|
||||||
|
token: event.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)
|
||||||
|
|
||||||
|
// Helper to register a local file with the media store
|
||||||
|
storeMedia := func(localPath, filename string) string {
|
||||||
|
if store := c.GetMediaStore(); store != nil {
|
||||||
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
|
Filename: filename,
|
||||||
|
Source: "line",
|
||||||
|
}, scope)
|
||||||
|
if err == nil {
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return localPath // fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.Type {
|
||||||
|
case "text":
|
||||||
|
content = msg.Text
|
||||||
|
// 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"))
|
||||||
|
content = "[image]"
|
||||||
|
}
|
||||||
|
case "audio":
|
||||||
|
localPath := c.downloadContent(msg.ID, "audio.m4a")
|
||||||
|
if localPath != "" {
|
||||||
|
mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a"))
|
||||||
|
content = "[audio]"
|
||||||
|
}
|
||||||
|
case "video":
|
||||||
|
localPath := c.downloadContent(msg.ID, "video.mp4")
|
||||||
|
if localPath != "" {
|
||||||
|
mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4"))
|
||||||
|
content = "[video]"
|
||||||
|
}
|
||||||
|
case "file":
|
||||||
|
content = "[file]"
|
||||||
|
case "sticker":
|
||||||
|
content = "[sticker]"
|
||||||
|
default:
|
||||||
|
content = fmt.Sprintf("[%s]", msg.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{
|
||||||
|
"chat_id": chatID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content = cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := map[string]string{
|
||||||
|
"platform": "line",
|
||||||
|
"source_type": event.Source.Type,
|
||||||
|
}
|
||||||
|
|
||||||
|
var peer bus.Peer
|
||||||
|
if isGroup {
|
||||||
|
peer = bus.Peer{Kind: "group", ID: chatID}
|
||||||
|
} else {
|
||||||
|
peer = bus.Peer{Kind: "direct", ID: senderID}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("line", "Received message", map[string]any{
|
||||||
|
"sender_id": senderID,
|
||||||
|
"chat_id": chatID,
|
||||||
|
"message_type": msg.Type,
|
||||||
|
"is_group": isGroup,
|
||||||
|
"preview": utils.Truncate(content, 50),
|
||||||
|
})
|
||||||
|
|
||||||
|
sender := bus.SenderInfo{
|
||||||
|
Platform: "line",
|
||||||
|
PlatformID: senderID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("line", senderID),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(c.ctx, peer, msg.ID, 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
|
||||||
|
// 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
|
||||||
|
if msg.Mention != nil {
|
||||||
|
for _, m := range msg.Mention.Mentionees {
|
||||||
|
if m.Type == "all" {
|
||||||
|
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 {
|
||||||
|
runes := []rune(msg.Text)
|
||||||
|
end := m.Index + m.Length
|
||||||
|
if end <= len(runes) {
|
||||||
|
mentionText := string(runes[m.Index:end])
|
||||||
|
if strings.Contains(mentionText, c.botDisplayName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: text-based detection with display name
|
||||||
|
if c.botDisplayName != "" && strings.Contains(msg.Text, "@"+c.botDisplayName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripBotMention removes the @BotName mention text from the message.
|
||||||
|
func (c *LINEChannel) stripBotMention(text string, msg lineMessage) 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if shouldStrip {
|
||||||
|
start := m.Index
|
||||||
|
end := m.Index + m.Length
|
||||||
|
if start >= 0 && end <= len(runes) {
|
||||||
|
runes = append(runes[:start], runes[end:]...)
|
||||||
|
stripped = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stripped {
|
||||||
|
return strings.TrimSpace(string(runes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: strip @DisplayName from text
|
||||||
|
if c.botDisplayName != "" {
|
||||||
|
text = strings.ReplaceAll(text, "@"+c.botDisplayName, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
default:
|
||||||
|
return source.UserID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,48 +1,19 @@
|
||||||
package line
|
package line
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"jane/pkg/bus"
|
"jane/pkg/bus"
|
||||||
"jane/pkg/channels"
|
"jane/pkg/channels"
|
||||||
"jane/pkg/config"
|
"jane/pkg/config"
|
||||||
"jane/pkg/identity"
|
|
||||||
"jane/pkg/logger"
|
"jane/pkg/logger"
|
||||||
"jane/pkg/media"
|
|
||||||
"jane/pkg/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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"
|
|
||||||
lineReplyTokenMaxAge = 25 * time.Second
|
|
||||||
|
|
||||||
// Limit request body to prevent memory exhaustion (DoS).
|
|
||||||
// LINE webhook payloads are typically a few KB; 1 MiB is generous.
|
|
||||||
maxWebhookBodySize = 1 << 20 // 1 MiB
|
|
||||||
)
|
|
||||||
|
|
||||||
type replyTokenEntry struct {
|
|
||||||
token string
|
|
||||||
timestamp time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// LINEChannel implements the Channel interface for LINE Official Account
|
// 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 REST API for sending messages.
|
||||||
|
|
@ -149,537 +120,3 @@ func (c *LINEChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("line", "LINE channel stopped")
|
logger.InfoC("line", "LINE channel stopped")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebhookPath returns the path for registering on the shared HTTP server.
|
|
||||||
func (c *LINEChannel) WebhookPath() string {
|
|
||||||
if c.config.WebhookPath != "" {
|
|
||||||
return c.config.WebhookPath
|
|
||||||
}
|
|
||||||
return "/webhook/line"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeHTTP implements http.Handler for the shared HTTP server.
|
|
||||||
func (c *LINEChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
c.webhookHandler(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// webhookHandler handles incoming LINE webhook requests.
|
|
||||||
func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
|
|
||||||
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)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return 200 immediately, process events asynchronously
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
|
|
||||||
for _, event := range payload.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))
|
|
||||||
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{
|
|
||||||
"type": event.Type,
|
|
||||||
})
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store reply token for later use
|
|
||||||
if event.ReplyToken != "" {
|
|
||||||
c.replyTokens.Store(chatID, replyTokenEntry{
|
|
||||||
token: event.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)
|
|
||||||
|
|
||||||
// Helper to register a local file with the media store
|
|
||||||
storeMedia := func(localPath, filename string) string {
|
|
||||||
if store := c.GetMediaStore(); store != nil {
|
|
||||||
ref, err := store.Store(localPath, media.MediaMeta{
|
|
||||||
Filename: filename,
|
|
||||||
Source: "line",
|
|
||||||
}, scope)
|
|
||||||
if err == nil {
|
|
||||||
return ref
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return localPath // fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
switch msg.Type {
|
|
||||||
case "text":
|
|
||||||
content = msg.Text
|
|
||||||
// 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"))
|
|
||||||
content = "[image]"
|
|
||||||
}
|
|
||||||
case "audio":
|
|
||||||
localPath := c.downloadContent(msg.ID, "audio.m4a")
|
|
||||||
if localPath != "" {
|
|
||||||
mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a"))
|
|
||||||
content = "[audio]"
|
|
||||||
}
|
|
||||||
case "video":
|
|
||||||
localPath := c.downloadContent(msg.ID, "video.mp4")
|
|
||||||
if localPath != "" {
|
|
||||||
mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4"))
|
|
||||||
content = "[video]"
|
|
||||||
}
|
|
||||||
case "file":
|
|
||||||
content = "[file]"
|
|
||||||
case "sticker":
|
|
||||||
content = "[sticker]"
|
|
||||||
default:
|
|
||||||
content = fmt.Sprintf("[%s]", msg.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(content) == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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{
|
|
||||||
"chat_id": chatID,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
content = cleaned
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := map[string]string{
|
|
||||||
"platform": "line",
|
|
||||||
"source_type": event.Source.Type,
|
|
||||||
}
|
|
||||||
|
|
||||||
var peer bus.Peer
|
|
||||||
if isGroup {
|
|
||||||
peer = bus.Peer{Kind: "group", ID: chatID}
|
|
||||||
} else {
|
|
||||||
peer = bus.Peer{Kind: "direct", ID: senderID}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.DebugCF("line", "Received message", map[string]any{
|
|
||||||
"sender_id": senderID,
|
|
||||||
"chat_id": chatID,
|
|
||||||
"message_type": msg.Type,
|
|
||||||
"is_group": isGroup,
|
|
||||||
"preview": utils.Truncate(content, 50),
|
|
||||||
})
|
|
||||||
|
|
||||||
sender := bus.SenderInfo{
|
|
||||||
Platform: "line",
|
|
||||||
PlatformID: senderID,
|
|
||||||
CanonicalID: identity.BuildCanonicalID("line", senderID),
|
|
||||||
}
|
|
||||||
|
|
||||||
if !c.IsAllowedSender(sender) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HandleMessage(c.ctx, peer, msg.ID, 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
|
|
||||||
// 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
|
|
||||||
if msg.Mention != nil {
|
|
||||||
for _, m := range msg.Mention.Mentionees {
|
|
||||||
if m.Type == "all" {
|
|
||||||
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 {
|
|
||||||
runes := []rune(msg.Text)
|
|
||||||
end := m.Index + m.Length
|
|
||||||
if end <= len(runes) {
|
|
||||||
mentionText := string(runes[m.Index:end])
|
|
||||||
if strings.Contains(mentionText, c.botDisplayName) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: text-based detection with display name
|
|
||||||
if c.botDisplayName != "" && strings.Contains(msg.Text, "@"+c.botDisplayName) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripBotMention removes the @BotName mention text from the message.
|
|
||||||
func (c *LINEChannel) stripBotMention(text string, msg lineMessage) 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if shouldStrip {
|
|
||||||
start := m.Index
|
|
||||||
end := m.Index + m.Length
|
|
||||||
if start >= 0 && end <= len(runes) {
|
|
||||||
runes = append(runes[:start], runes[end:]...)
|
|
||||||
stripped = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if stripped {
|
|
||||||
return strings.TrimSpace(string(runes))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: strip @DisplayName from text
|
|
||||||
if c.botDisplayName != "" {
|
|
||||||
text = strings.ReplaceAll(text, "@"+c.botDisplayName, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
default:
|
|
||||||
return source.UserID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send sends a message to LINE. It first tries the Reply API (free)
|
|
||||||
// using a cached reply token, then falls back to the Push API.
|
|
||||||
func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
|
||||||
if !c.IsRunning() {
|
|
||||||
return channels.ErrNotRunning
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load and consume quote token for this chat
|
|
||||||
var quoteToken string
|
|
||||||
if qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok {
|
|
||||||
quoteToken = qt.(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
logger.DebugCF("line", "Message sent via Reply API", map[string]any{
|
|
||||||
"chat_id": msg.ChatID,
|
|
||||||
"quoted": quoteToken != "",
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
logger.DebugC("line", "Reply API failed, falling back to Push API")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to Push API
|
|
||||||
return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendMedia implements the channels.MediaSender interface.
|
|
||||||
// LINE requires media to be accessible via public URL; since we only have local files,
|
|
||||||
// we fall back to sending a text message with the filename/caption.
|
|
||||||
// For full support, an external file hosting service would be needed.
|
|
||||||
func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
|
||||||
if !c.IsRunning() {
|
|
||||||
return channels.ErrNotRunning
|
|
||||||
}
|
|
||||||
|
|
||||||
store := c.GetMediaStore()
|
|
||||||
if store == nil {
|
|
||||||
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LINE Messaging API requires publicly accessible URLs for media messages.
|
|
||||||
// Since we only have local file paths, send caption text as fallback.
|
|
||||||
for _, part := range msg.Parts {
|
|
||||||
caption := part.Caption
|
|
||||||
if caption == "" {
|
|
||||||
caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 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.
|
|
||||||
// Group/room chat IDs (starting with "C" or "R") are detected automatically;
|
|
||||||
// for these, a no-op stop function is returned without calling the API.
|
|
||||||
func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
|
||||||
if chatID == "" {
|
|
||||||
return func() {}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group/room chats: LINE loading animation is 1:1 only.
|
|
||||||
if strings.HasPrefix(chatID, "C") || strings.HasPrefix(chatID, "R") {
|
|
||||||
return func() {}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
typingCtx, cancel := context.WithCancel(ctx)
|
|
||||||
var once sync.Once
|
|
||||||
stop := func() { once.Do(cancel) }
|
|
||||||
|
|
||||||
// Send immediately, then refresh periodically for long-running tasks.
|
|
||||||
if err := c.sendLoading(typingCtx, chatID); err != nil {
|
|
||||||
stop()
|
|
||||||
return stop, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ticker := time.NewTicker(50 * time.Second)
|
|
||||||
go func() {
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-typingCtx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
if err := c.sendLoading(typingCtx, chatID); err != nil {
|
|
||||||
logger.DebugCF("line", "Failed to refresh loading indicator", map[string]any{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return stop, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
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 {
|
|
||||||
url := fmt.Sprintf(lineContentEndpoint, messageID)
|
|
||||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
|
||||||
LoggerPrefix: "line",
|
|
||||||
ExtraHeaders: map[string]string{
|
|
||||||
"Authorization": "Bearer " + c.config.ChannelAccessToken,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
51
pkg/channels/line/media.go
Normal file
51
pkg/channels/line/media.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package line
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"jane/pkg/bus"
|
||||||
|
"jane/pkg/channels"
|
||||||
|
"jane/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SendMedia implements the channels.MediaSender interface.
|
||||||
|
// LINE requires media to be accessible via public URL; since we only have local files,
|
||||||
|
// we fall back to sending a text message with the filename/caption.
|
||||||
|
// For full support, an external file hosting service would be needed.
|
||||||
|
func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
store := c.GetMediaStore()
|
||||||
|
if store == nil {
|
||||||
|
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LINE Messaging API requires publicly accessible URLs for media messages.
|
||||||
|
// Since we only have local file paths, send caption text as fallback.
|
||||||
|
for _, part := range msg.Parts {
|
||||||
|
caption := part.Caption
|
||||||
|
if caption == "" {
|
||||||
|
caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadContent downloads media content from the LINE API.
|
||||||
|
func (c *LINEChannel) downloadContent(messageID, filename string) string {
|
||||||
|
url := fmt.Sprintf(lineContentEndpoint, messageID)
|
||||||
|
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||||
|
LoggerPrefix: "line",
|
||||||
|
ExtraHeaders: map[string]string{
|
||||||
|
"Authorization": "Bearer " + c.config.ChannelAccessToken,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
111
pkg/channels/line/messages.go
Normal file
111
pkg/channels/line/messages.go
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
package line
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jane/pkg/bus"
|
||||||
|
"jane/pkg/channels"
|
||||||
|
"jane/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send sends a message to LINE. It first tries the Reply API (free)
|
||||||
|
// using a cached reply token, then falls back to the Push API.
|
||||||
|
func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and consume quote token for this chat
|
||||||
|
var quoteToken string
|
||||||
|
if qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok {
|
||||||
|
quoteToken = qt.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
logger.DebugCF("line", "Message sent via Reply API", map[string]any{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"quoted": quoteToken != "",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
logger.DebugC("line", "Reply API failed, falling back to Push API")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Push API
|
||||||
|
return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
62
pkg/channels/line/types.go
Normal file
62
pkg/channels/line/types.go
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
package line
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"
|
||||||
|
lineReplyTokenMaxAge = 25 * time.Second
|
||||||
|
|
||||||
|
// Limit request body to prevent memory exhaustion (DoS).
|
||||||
|
// LINE webhook payloads are typically a few KB; 1 MiB is generous.
|
||||||
|
maxWebhookBodySize = 1 << 20 // 1 MiB
|
||||||
|
)
|
||||||
|
|
||||||
|
type replyTokenEntry struct {
|
||||||
|
token string
|
||||||
|
timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
64
pkg/channels/line/typing.go
Normal file
64
pkg/channels/line/typing.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package line
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"jane/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartTyping implements channels.TypingCapable using LINE's loading animation.
|
||||||
|
//
|
||||||
|
// NOTE: The LINE loading animation API only works for 1:1 chats.
|
||||||
|
// Group/room chat IDs (starting with "C" or "R") are detected automatically;
|
||||||
|
// for these, a no-op stop function is returned without calling the API.
|
||||||
|
func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||||
|
if chatID == "" {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group/room chats: LINE loading animation is 1:1 only.
|
||||||
|
if strings.HasPrefix(chatID, "C") || strings.HasPrefix(chatID, "R") {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
typingCtx, cancel := context.WithCancel(ctx)
|
||||||
|
var once sync.Once
|
||||||
|
stop := func() { once.Do(cancel) }
|
||||||
|
|
||||||
|
// Send immediately, then refresh periodically for long-running tasks.
|
||||||
|
if err := c.sendLoading(typingCtx, chatID); err != nil {
|
||||||
|
stop()
|
||||||
|
return stop, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(50 * time.Second)
|
||||||
|
go func() {
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-typingCtx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := c.sendLoading(typingCtx, chatID); err != nil {
|
||||||
|
logger.DebugCF("line", "Failed to refresh loading indicator", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return stop, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue