Files
2026-06-02 23:14:41 +08:00

323 lines
8.0 KiB
Go

package line
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/chenhg5/cc-connect/core"
"github.com/line/line-bot-sdk-go/v8/linebot/messaging_api"
"github.com/line/line-bot-sdk-go/v8/linebot/webhook"
)
func init() {
core.RegisterPlatform("line", New)
}
// replyContext stores the user/group ID for push messages.
// We use PushMessage instead of ReplyMessage because reply tokens
// expire in ~1 minute, which is too short for AI agent processing.
type replyContext struct {
targetID string
targetType string // "user" or "group" or "room"
}
type Platform struct {
channelSecret string
channelToken string
allowFrom string
port string
callbackPath string
bot *messaging_api.MessagingApiAPI
server *http.Server
handler core.MessageHandler
userNameCache sync.Map // userID -> display name
groupNameCache sync.Map // groupID -> group name
}
func New(opts map[string]any) (core.Platform, error) {
secret, _ := opts["channel_secret"].(string)
token, _ := opts["channel_token"].(string)
allowFrom, _ := opts["allow_from"].(string)
if secret == "" || token == "" {
return nil, fmt.Errorf("line: channel_secret and channel_token are required")
}
port, _ := opts["port"].(string)
if port == "" {
port = "8080"
}
path, _ := opts["callback_path"].(string)
if path == "" {
path = "/callback"
}
core.CheckAllowFrom("line", allowFrom)
return &Platform{
channelSecret: secret,
channelToken: token,
allowFrom: allowFrom,
port: port,
callbackPath: path,
}, nil
}
func (p *Platform) Name() string { return "line" }
func (p *Platform) Start(handler core.MessageHandler) error {
p.handler = handler
bot, err := messaging_api.NewMessagingApiAPI(p.channelToken)
if err != nil {
return fmt.Errorf("line: create api client: %w", err)
}
p.bot = bot
mux := http.NewServeMux()
mux.HandleFunc(p.callbackPath, p.webhookHandler)
p.server = &http.Server{
Addr: ":" + p.port,
Handler: mux,
}
go func() {
slog.Info("line: webhook server listening", "port", p.port, "path", p.callbackPath)
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("line: server error", "error", err)
}
}()
return nil
}
func (p *Platform) webhookHandler(w http.ResponseWriter, r *http.Request) {
cb, err := webhook.ParseRequest(p.channelSecret, r)
if err != nil {
slog.Error("line: parse webhook failed", "error", err)
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
for _, event := range cb.Events {
e, ok := event.(webhook.MessageEvent)
if !ok {
continue
}
if e.Timestamp > 0 {
msgTime := time.Unix(e.Timestamp/1000, (e.Timestamp%1000)*int64(time.Millisecond))
if core.IsOldMessage(msgTime) {
slog.Debug("line: ignoring old message after restart", "timestamp", e.Timestamp)
continue
}
}
targetID, targetType, userID := extractSource(e.Source)
if !core.AllowList(p.allowFrom, userID) {
slog.Debug("line: message from unauthorized user", "user", userID)
continue
}
sessionKey := fmt.Sprintf("line:%s", targetID)
rctx := replyContext{targetID: targetID, targetType: targetType}
chatName := ""
if targetType == "group" {
chatName = p.resolveGroupName(targetID)
}
switch m := e.Message.(type) {
case webhook.TextMessageContent:
slog.Debug("line: message received", "user", userID, "text_len", len(m.Text))
p.handler(p, &core.Message{
SessionKey: sessionKey, Platform: "line",
MessageID: m.Id,
UserID: userID, UserName: p.resolveUserName(userID),
ChatName: chatName,
Content: m.Text, ReplyCtx: rctx,
})
case webhook.ImageMessageContent:
slog.Debug("line: image received", "user", userID)
imgData, err := p.downloadContent(m.Id)
if err != nil {
slog.Error("line: download image failed", "error", err)
continue
}
p.handler(p, &core.Message{
SessionKey: sessionKey, Platform: "line",
MessageID: m.Id,
UserID: userID, UserName: p.resolveUserName(userID),
ChatName: chatName,
Images: []core.ImageAttachment{{MimeType: "image/jpeg", Data: imgData}},
ReplyCtx: rctx,
})
case webhook.AudioMessageContent:
slog.Debug("line: audio received", "user", userID)
audioData, err := p.downloadContent(m.Id)
if err != nil {
slog.Error("line: download audio failed", "error", err)
continue
}
dur := 0
if m.Duration > 0 {
dur = int(m.Duration / 1000)
}
p.handler(p, &core.Message{
SessionKey: sessionKey, Platform: "line",
MessageID: m.Id,
UserID: userID, UserName: p.resolveUserName(userID),
ChatName: chatName,
Audio: &core.AudioAttachment{
MimeType: "audio/m4a",
Data: audioData,
Format: "m4a",
Duration: dur,
},
ReplyCtx: rctx,
})
default:
slog.Debug("line: ignoring unsupported message type")
}
}
}
func (p *Platform) resolveUserName(userID string) string {
if cached, ok := p.userNameCache.Load(userID); ok {
return cached.(string)
}
profile, err := p.bot.GetProfile(userID)
if err != nil {
slog.Debug("line: resolve user name failed", "user", userID, "error", err)
return userID
}
name := profile.DisplayName
if name == "" {
name = userID
}
p.userNameCache.Store(userID, name)
return name
}
func (p *Platform) resolveGroupName(groupID string) string {
if cached, ok := p.groupNameCache.Load(groupID); ok {
return cached.(string)
}
summary, err := p.bot.GetGroupSummary(groupID)
if err != nil {
slog.Debug("line: resolve group name failed", "group_id", groupID, "error", err)
return groupID
}
name := summary.GroupName
if name == "" {
return groupID
}
p.groupNameCache.Store(groupID, name)
return name
}
func extractSource(src webhook.SourceInterface) (targetID, targetType, userID string) {
switch s := src.(type) {
case webhook.UserSource:
return s.UserId, "user", s.UserId
case webhook.GroupSource:
return s.GroupId, "group", s.UserId
case webhook.RoomSource:
return s.RoomId, "room", s.UserId
default:
return "unknown", "unknown", "unknown"
}
}
func (p *Platform) downloadContent(messageID string) ([]byte, error) {
url := fmt.Sprintf("https://api-data.line.me/v2/bot/message/%s/content", messageID)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+p.channelToken)
resp, err := core.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func (p *Platform) Reply(ctx context.Context, rctx any, content string) error {
rc, ok := rctx.(replyContext)
if !ok {
return fmt.Errorf("line: invalid reply context type %T", rctx)
}
if content == "" {
return nil
}
content = core.StripMarkdown(content)
// LINE text message limit is 5000 characters
messages := splitMessage(content, 5000)
for _, text := range messages {
_, err := p.bot.PushMessage(
&messaging_api.PushMessageRequest{
To: rc.targetID,
Messages: []messaging_api.MessageInterface{
messaging_api.TextMessage{
Text: text,
},
},
}, "",
)
if err != nil {
return fmt.Errorf("line: push message: %w", err)
}
}
return nil
}
// Send sends a new message (same as Reply for LINE)
func (p *Platform) Send(ctx context.Context, rctx any, content string) error {
return p.Reply(ctx, rctx, content)
}
func splitMessage(s string, maxLen int) []string {
if len(s) <= maxLen {
return []string{s}
}
var parts []string
runes := []rune(s)
for len(runes) > 0 {
end := maxLen
if end > len(runes) {
end = len(runes)
}
parts = append(parts, string(runes[:end]))
runes = runes[end:]
}
return parts
}
func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) {
// line:{targetID} (user or group)
parts := strings.SplitN(sessionKey, ":", 2)
if len(parts) < 2 || parts[0] != "line" {
return nil, fmt.Errorf("line: invalid session key %q", sessionKey)
}
return replyContext{targetID: parts[1], targetType: "user"}, nil
}
func (p *Platform) Stop() error {
if p.server != nil {
return p.server.Shutdown(context.Background())
}
return nil
}