Merge pull request #92 from hobbyistlabs-coder/refactor-line-channel-modularization-6623309489161261166
Modularize LINE channel implementation
This commit is contained in:
commit
472cba0fe1
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