From 1ddc5ee5084a467e5bb4e355231bb202de3605d0 Mon Sep 17 00:00:00 2001 From: highesttt Date: Wed, 21 Jan 2026 12:18:42 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20Implement=20message?= =?UTF-8?q?=20prefetching=20and=20link=20previews?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 ++- pkg/connector/client.go | 97 ++++++++++++++++++++++++++++++++++++++--- pkg/line/client.go | 40 +++++++++++++++++ pkg/line/methods.go | 38 ++++++++++++++++ pkg/line/structs.go | 46 +++++++++++++++++++ 5 files changed, 217 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 75da036..2678cba 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Based on the [mautrix-twilio](https://github.com/mautrix/twilio) bridge > Messages sent to the LINE Bot using Beeper Desktop may appear as indefinitely sending.\ > Use Beeper Mobile to send commands to the LINE Bot account after creating the chat with Beeper Desktop. -## Roadmap +## Features - [x] Basic messaging (encrypted text messages) - [x] Actual login via mail/password (instead of access token) @@ -24,10 +24,11 @@ Based on the [mautrix-twilio](https://github.com/mautrix/twilio) bridge - [x] Read receipts - [x] Reaction support (Receive ONLY) - [x] Reply support -- [ ] Prefetch chats +- [x] Prefetch chats - [x] Group chats - [x] Media messages (images, videos, voice notes, files) - [x] Sticker support +- [x] Link previews ## How to Use diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 60a9727..2a67050 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -18,6 +18,7 @@ import ( "io" "net/http" "os" + "regexp" "strconv" "strings" "sync" @@ -473,9 +474,57 @@ func (lc *LineClient) Connect(ctx context.Context) { } go lc.syncChats(ctx) + go lc.prefetchMessages(ctx) go lc.pollLoop(ctx) } +func (lc *LineClient) prefetchMessages(ctx context.Context) { + client := line.NewClient(lc.AccessToken) + opts := line.MessageBoxesOptions{ + ActiveOnly: true, + MessageBoxCountLimit: 100, + WithUnreadCount: true, + LastMessagesPerMessageBoxCount: 0, + } + + res, err := client.GetMessageBoxes(opts) + if err != nil && lc.isRefreshRequired(err) { + if errRefresh := lc.refreshAndSave(ctx); errRefresh == nil { + client = line.NewClient(lc.AccessToken) + res, err = client.GetMessageBoxes(opts) + } + } + if err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to prefetch message boxes") + return + } + + for _, box := range res.MessageBoxes { + // Fetch recent messages for all active chats to ensure history is populated + msgs, err := client.GetRecentMessagesV2(box.ID, 50) + if err != nil { + lc.UserLogin.Bridge.Log.Warn().Err(err).Str("chat_mid", box.ID).Msg("Failed to fetch recent messages") + continue + } + + // Reverse messages to process oldest first + for i := len(msgs) - 1; i >= 0; i-- { + msg := msgs[i] + + existing, err := lc.UserLogin.Bridge.DB.Message.GetPartByID(ctx, lc.UserLogin.ID, networkid.MessageID(msg.ID), "") + if err == nil && existing != nil { + continue + } + + opType := 26 + if msg.From == lc.Mid { + opType = 25 + } + lc.queueIncomingMessage(msg, opType) + } + } +} + func (lc *LineClient) syncChats(ctx context.Context) { client := line.NewClient(lc.AccessToken) midsResp, err := client.GetAllChatMids(true, true) @@ -948,6 +997,12 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { } decryptedBody := bodyText + tsInt, _ := msg.CreatedTime.Int64() + ts := time.UnixMilli(tsInt) + if ts.IsZero() { + ts = time.Now() + } + lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.Message[line.Message]{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventMessage, @@ -955,7 +1010,7 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { PortalKey: portalKey, CreatePortal: true, Sender: bridgev2.EventSender{Sender: senderID, IsFromMe: opType == 25}, - Timestamp: time.Now(), + Timestamp: ts, }, Data: *msg, ID: networkid.MessageID(msg.ID), @@ -1370,15 +1425,43 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { } // Default to Text + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: unwrappedText, + RelatesTo: replyRelatesTo, + } + + if strings.Contains(unwrappedText, ".") { + urlRegex := regexp.MustCompile(`(https?://)?([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(/[^\s]*)?`) + if match := urlRegex.FindString(unwrappedText); match != "" { + match = strings.TrimRight(match, ".,;:!?") + requestURL := match + if !strings.HasPrefix(match, "http") { + requestURL = "https://" + match + } + client := line.NewClient(lc.AccessToken) + if info, err := client.GetPageInfo(requestURL); err == nil { + preview := &event.BeeperLinkPreview{ + MatchedURL: match, + LinkPreview: event.LinkPreview{ + Title: info.Title, + Description: info.Summary, + CanonicalURL: info.Domain, + }, + } + if info.Image != "" && info.Obs.CDN != "" { + preview.ImageURL = id.ContentURIString(info.Obs.CDN + info.Image) + } + content.BeeperLinkPreviews = []*event.BeeperLinkPreview{preview} + } + } + } + return &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{ { - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgText, - Body: unwrappedText, - RelatesTo: replyRelatesTo, - }, + Type: event.EventMessage, + Content: content, }, }, }, nil diff --git a/pkg/line/client.go b/pkg/line/client.go index 803c324..4e78490 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -538,3 +538,43 @@ func (c *Client) constructTalkMeta(messageID string) string { return base64.StdEncoding.EncodeToString(metaBytes) } + +func (c *Client) GetPageInfo(url string) (*PageInfoResult, error) { + apiURL := "https://legy-jp.line-apps.com/sc/api/v2/pageinfo/get" + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, err + } + + q := req.URL.Query() + q.Add("url", url) + q.Add("caller", "LINE_CHROME") + req.URL.RawQuery = q.Encode() + + req.Header.Set("User-Agent", UserAgent) + if c.AccessToken != "" { + req.Header.Set("x-line-access", c.AccessToken) + req.Header.Set("Cookie", fmt.Sprintf("lct=%s", c.AccessToken)) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("pageinfo request failed: %d", resp.StatusCode) + } + + var wrapper PageInfoResponse + if err := json.NewDecoder(resp.Body).Decode(&wrapper); err != nil { + return nil, err + } + + if wrapper.Code != 0 { + return nil, fmt.Errorf("pageinfo API error: %s", wrapper.Message) + } + + return &wrapper.Result, nil +} diff --git a/pkg/line/methods.go b/pkg/line/methods.go index 7872e43..49b60ab 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -430,3 +430,41 @@ func (c *Client) AcquireEncryptedAccessToken() (string, error) { return parts[1], nil } + +func (c *Client) GetMessageBoxes(options MessageBoxesOptions) (*MessageBoxesResponse, error) { + resp, err := c.callRPC("TalkService", "getMessageBoxes", options, 2) + if err != nil { + return nil, err + } + var wrapper struct { + Code int `json:"code"` + Message string `json:"message"` + Data MessageBoxesResponse `json:"data"` + } + if err := json.Unmarshal(resp, &wrapper); err != nil { + return nil, err + } + if wrapper.Code != 0 { + return nil, fmt.Errorf("getMessageBoxes failed: %s", wrapper.Message) + } + return &wrapper.Data, nil +} + +func (c *Client) GetRecentMessagesV2(chatMid string, limit int) ([]*Message, error) { + resp, err := c.callRPC("TalkService", "getRecentMessagesV2", chatMid, limit) + if err != nil { + return nil, err + } + var wrapper struct { + Code int `json:"code"` + Message string `json:"message"` + Data []*Message `json:"data"` + } + if err := json.Unmarshal(resp, &wrapper); err != nil { + return nil, err + } + if wrapper.Code != 0 { + return nil, fmt.Errorf("getRecentMessagesV2 failed: %s", wrapper.Message) + } + return wrapper.Data, nil +} diff --git a/pkg/line/structs.go b/pkg/line/structs.go index 9dc83bf..393e7d4 100644 --- a/pkg/line/structs.go +++ b/pkg/line/structs.go @@ -247,3 +247,49 @@ type AcquireEncryptedAccessTokenResponse struct { Message string `json:"message"` Data string `json:"data"` // Format: "expirySeconds\x1eToken" } + +type MessageBoxesOptions struct { + ActiveOnly bool `json:"activeOnly"` + UnreadOnly bool `json:"unreadOnly"` + MessageBoxCountLimit int `json:"messageBoxCountLimit"` + WithUnreadCount bool `json:"withUnreadCount"` + LastMessagesPerMessageBoxCount int `json:"lastMessagesPerMessageBoxCount"` +} + +type MessageBoxesResponse struct { + MessageBoxes []MessageBox `json:"messageBoxes"` + HasNext bool `json:"hasNext"` +} + +type MessageBox struct { + ID string `json:"id"` + MidType int `json:"midType"` + LastDeliveredMessageID *MessageIDWrapper `json:"lastDeliveredMessageId"` + LastSeenMessageID string `json:"lastSeenMessageId"` + UnreadCount json.Number `json:"unreadCount"` + LastMessages []Message `json:"lastMessages"` +} + +type MessageIDWrapper struct { + DeliveredTime json.Number `json:"deliveredTime"` + MessageID string `json:"messageId"` +} + +type PageInfoResponse struct { + Result PageInfoResult `json:"result"` + Code int `json:"code"` + Message string `json:"message"` +} + +type PageInfoResult struct { + URL string `json:"url"` + Domain string `json:"domain"` + Title string `json:"title"` + Summary string `json:"summary"` + Image string `json:"image"` + Obs ObsInfo `json:"obs"` +} + +type ObsInfo struct { + CDN string `json:"cdn"` +} From 7e57ec7c46a41968ea142578c8472a831bf94309 Mon Sep 17 00:00:00 2001 From: highesttt Date: Wed, 21 Jan 2026 12:38:18 -0600 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20Improve=20message=20?= =?UTF-8?q?timestamp=20handling=20Only=20create=201=20new=20client=20when?= =?UTF-8?q?=20queuing=20incoming=20messages=20Added=20replies=20as=20fully?= =?UTF-8?q?=20supported=20and=20read=20receipts=20as=20true?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/connector/client.go | 59 ++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 2a67050..5b2da25 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -997,10 +997,18 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { } decryptedBody := bodyText - tsInt, _ := msg.CreatedTime.Int64() - ts := time.UnixMilli(tsInt) - if ts.IsZero() { + var ts time.Time + if tsInt, err := msg.CreatedTime.Int64(); err != nil { + lc.UserLogin.Bridge.Log.Warn(). + Err(err). + Str("msg_id", msg.ID). + Msg("Failed to convert message CreatedTime to int64, using current time") ts = time.Now() + } else { + ts = time.UnixMilli(tsInt) + if ts.IsZero() { + ts = time.Now() + } } lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.Message[line.Message]{ @@ -1017,11 +1025,11 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data line.Message) (*bridgev2.ConvertedMessage, error) { replyRelatesTo := lc.resolveReplyRelatesTo(ctx, &data) // Handle Images + client := line.NewClient(lc.AccessToken) if data.ContentType == 1 { // Image oid := data.ContentMetadata["OID"] if oid != "" { - client := line.NewClient(lc.AccessToken) imgData, err := client.DownloadOBS(oid, data.ID) // Refresh token if we get a 401 @@ -1103,7 +1111,6 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { } if oid != "" { - client := line.NewClient(lc.AccessToken) videoData, err := client.DownloadOBSWithSID(oid, data.ID, "emv") if err != nil && (strings.Contains(err.Error(), "401") || lc.isRefreshRequired(err)) { @@ -1243,7 +1250,6 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { } if oid != "" { - client := line.NewClient(lc.AccessToken) fileData, err := client.DownloadOBSWithSID(oid, data.ID, "emf") if err != nil { lc.UserLogin.Bridge.Log.Error(). @@ -1431,29 +1437,26 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) { RelatesTo: replyRelatesTo, } - if strings.Contains(unwrappedText, ".") { - urlRegex := regexp.MustCompile(`(https?://)?([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(/[^\s]*)?`) - if match := urlRegex.FindString(unwrappedText); match != "" { - match = strings.TrimRight(match, ".,;:!?") - requestURL := match - if !strings.HasPrefix(match, "http") { - requestURL = "https://" + match + urlRegex := regexp.MustCompile(`(https?://)?([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(/[^\s]*)?`) + if match := urlRegex.FindString(unwrappedText); match != "" { + match = strings.TrimRight(match, ".,;:!?") + requestURL := match + if !strings.HasPrefix(match, "http") { + requestURL = "https://" + match + } + if info, err := client.GetPageInfo(requestURL); err == nil { + preview := &event.BeeperLinkPreview{ + MatchedURL: match, + LinkPreview: event.LinkPreview{ + Title: info.Title, + Description: info.Summary, + CanonicalURL: info.Domain, + }, } - client := line.NewClient(lc.AccessToken) - if info, err := client.GetPageInfo(requestURL); err == nil { - preview := &event.BeeperLinkPreview{ - MatchedURL: match, - LinkPreview: event.LinkPreview{ - Title: info.Title, - Description: info.Summary, - CanonicalURL: info.Domain, - }, - } - if info.Image != "" && info.Obs.CDN != "" { - preview.ImageURL = id.ContentURIString(info.Obs.CDN + info.Image) - } - content.BeeperLinkPreviews = []*event.BeeperLinkPreview{preview} + if info.Image != "" && info.Obs.CDN != "" { + preview.ImageURL = id.ContentURIString(info.Obs.CDN + info.Image) } + content.BeeperLinkPreviews = []*event.BeeperLinkPreview{preview} } } @@ -1499,6 +1502,8 @@ func (lc *LineClient) LogoutRemote(ctx context.Context) {} func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { return &event.RoomFeatures{ MaxTextLength: 5000, + Reply: event.CapLevelFullySupported, + ReadReceipts: true, File: event.FileFeatureMap{ event.MsgImage: { MimeTypes: map[string]event.CapabilitySupportLevel{