323 lines
8.0 KiB
Go
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
|
|
}
|