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:
google-labs-jules[bot] 2026-03-23 22:27:27 +00:00
parent c31965f35e
commit ab0cbdfaf2
6 changed files with 615 additions and 563 deletions

327
pkg/channels/line/events.go Normal file
View 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
}
}

View file

@ -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,
},
})
}

View 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,
},
})
}

View 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
}

View 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"`
}

View 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)
}