Skip to content

Commit 1ddc5ee

Browse files
committed
feat: 🎸 Implement message prefetching and link previews
1 parent 3a6a61c commit 1ddc5ee

5 files changed

Lines changed: 217 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Based on the [mautrix-twilio](https://github.com/mautrix/twilio) bridge
1313
> Messages sent to the LINE Bot using Beeper Desktop may appear as indefinitely sending.\
1414
> Use Beeper Mobile to send commands to the LINE Bot account after creating the chat with Beeper Desktop.
1515
16-
## Roadmap
16+
## Features
1717

1818
- [x] Basic messaging (encrypted text messages)
1919
- [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
2424
- [x] Read receipts
2525
- [x] Reaction support (Receive ONLY)
2626
- [x] Reply support
27-
- [ ] Prefetch chats
27+
- [x] Prefetch chats
2828
- [x] Group chats
2929
- [x] Media messages (images, videos, voice notes, files)
3030
- [x] Sticker support
31+
- [x] Link previews
3132

3233
## How to Use
3334

‎pkg/connector/client.go‎

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"io"
1919
"net/http"
2020
"os"
21+
"regexp"
2122
"strconv"
2223
"strings"
2324
"sync"
@@ -473,9 +474,57 @@ func (lc *LineClient) Connect(ctx context.Context) {
473474
}
474475

475476
go lc.syncChats(ctx)
477+
go lc.prefetchMessages(ctx)
476478
go lc.pollLoop(ctx)
477479
}
478480

481+
func (lc *LineClient) prefetchMessages(ctx context.Context) {
482+
client := line.NewClient(lc.AccessToken)
483+
opts := line.MessageBoxesOptions{
484+
ActiveOnly: true,
485+
MessageBoxCountLimit: 100,
486+
WithUnreadCount: true,
487+
LastMessagesPerMessageBoxCount: 0,
488+
}
489+
490+
res, err := client.GetMessageBoxes(opts)
491+
if err != nil && lc.isRefreshRequired(err) {
492+
if errRefresh := lc.refreshAndSave(ctx); errRefresh == nil {
493+
client = line.NewClient(lc.AccessToken)
494+
res, err = client.GetMessageBoxes(opts)
495+
}
496+
}
497+
if err != nil {
498+
lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to prefetch message boxes")
499+
return
500+
}
501+
502+
for _, box := range res.MessageBoxes {
503+
// Fetch recent messages for all active chats to ensure history is populated
504+
msgs, err := client.GetRecentMessagesV2(box.ID, 50)
505+
if err != nil {
506+
lc.UserLogin.Bridge.Log.Warn().Err(err).Str("chat_mid", box.ID).Msg("Failed to fetch recent messages")
507+
continue
508+
}
509+
510+
// Reverse messages to process oldest first
511+
for i := len(msgs) - 1; i >= 0; i-- {
512+
msg := msgs[i]
513+
514+
existing, err := lc.UserLogin.Bridge.DB.Message.GetPartByID(ctx, lc.UserLogin.ID, networkid.MessageID(msg.ID), "")
515+
if err == nil && existing != nil {
516+
continue
517+
}
518+
519+
opType := 26
520+
if msg.From == lc.Mid {
521+
opType = 25
522+
}
523+
lc.queueIncomingMessage(msg, opType)
524+
}
525+
}
526+
}
527+
479528
func (lc *LineClient) syncChats(ctx context.Context) {
480529
client := line.NewClient(lc.AccessToken)
481530
midsResp, err := client.GetAllChatMids(true, true)
@@ -948,14 +997,20 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) {
948997
}
949998
decryptedBody := bodyText
950999

1000+
tsInt, _ := msg.CreatedTime.Int64()
1001+
ts := time.UnixMilli(tsInt)
1002+
if ts.IsZero() {
1003+
ts = time.Now()
1004+
}
1005+
9511006
lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.Message[line.Message]{
9521007
EventMeta: simplevent.EventMeta{
9531008
Type: bridgev2.RemoteEventMessage,
9541009
LogContext: func(c zerolog.Context) zerolog.Context { return c.Str("msg_id", msg.ID) },
9551010
PortalKey: portalKey,
9561011
CreatePortal: true,
9571012
Sender: bridgev2.EventSender{Sender: senderID, IsFromMe: opType == 25},
958-
Timestamp: time.Now(),
1013+
Timestamp: ts,
9591014
},
9601015
Data: *msg,
9611016
ID: networkid.MessageID(msg.ID),
@@ -1370,15 +1425,43 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) {
13701425
}
13711426

13721427
// Default to Text
1428+
content := &event.MessageEventContent{
1429+
MsgType: event.MsgText,
1430+
Body: unwrappedText,
1431+
RelatesTo: replyRelatesTo,
1432+
}
1433+
1434+
if strings.Contains(unwrappedText, ".") {
1435+
urlRegex := regexp.MustCompile(`(https?://)?([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(/[^\s]*)?`)
1436+
if match := urlRegex.FindString(unwrappedText); match != "" {
1437+
match = strings.TrimRight(match, ".,;:!?")
1438+
requestURL := match
1439+
if !strings.HasPrefix(match, "http") {
1440+
requestURL = "https://" + match
1441+
}
1442+
client := line.NewClient(lc.AccessToken)
1443+
if info, err := client.GetPageInfo(requestURL); err == nil {
1444+
preview := &event.BeeperLinkPreview{
1445+
MatchedURL: match,
1446+
LinkPreview: event.LinkPreview{
1447+
Title: info.Title,
1448+
Description: info.Summary,
1449+
CanonicalURL: info.Domain,
1450+
},
1451+
}
1452+
if info.Image != "" && info.Obs.CDN != "" {
1453+
preview.ImageURL = id.ContentURIString(info.Obs.CDN + info.Image)
1454+
}
1455+
content.BeeperLinkPreviews = []*event.BeeperLinkPreview{preview}
1456+
}
1457+
}
1458+
}
1459+
13731460
return &bridgev2.ConvertedMessage{
13741461
Parts: []*bridgev2.ConvertedMessagePart{
13751462
{
1376-
Type: event.EventMessage,
1377-
Content: &event.MessageEventContent{
1378-
MsgType: event.MsgText,
1379-
Body: unwrappedText,
1380-
RelatesTo: replyRelatesTo,
1381-
},
1463+
Type: event.EventMessage,
1464+
Content: content,
13821465
},
13831466
},
13841467
}, nil

‎pkg/line/client.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,3 +538,43 @@ func (c *Client) constructTalkMeta(messageID string) string {
538538

539539
return base64.StdEncoding.EncodeToString(metaBytes)
540540
}
541+
542+
func (c *Client) GetPageInfo(url string) (*PageInfoResult, error) {
543+
apiURL := "https://legy-jp.line-apps.com/sc/api/v2/pageinfo/get"
544+
req, err := http.NewRequest("GET", apiURL, nil)
545+
if err != nil {
546+
return nil, err
547+
}
548+
549+
q := req.URL.Query()
550+
q.Add("url", url)
551+
q.Add("caller", "LINE_CHROME")
552+
req.URL.RawQuery = q.Encode()
553+
554+
req.Header.Set("User-Agent", UserAgent)
555+
if c.AccessToken != "" {
556+
req.Header.Set("x-line-access", c.AccessToken)
557+
req.Header.Set("Cookie", fmt.Sprintf("lct=%s", c.AccessToken))
558+
}
559+
560+
resp, err := c.HTTPClient.Do(req)
561+
if err != nil {
562+
return nil, err
563+
}
564+
defer resp.Body.Close()
565+
566+
if resp.StatusCode != 200 {
567+
return nil, fmt.Errorf("pageinfo request failed: %d", resp.StatusCode)
568+
}
569+
570+
var wrapper PageInfoResponse
571+
if err := json.NewDecoder(resp.Body).Decode(&wrapper); err != nil {
572+
return nil, err
573+
}
574+
575+
if wrapper.Code != 0 {
576+
return nil, fmt.Errorf("pageinfo API error: %s", wrapper.Message)
577+
}
578+
579+
return &wrapper.Result, nil
580+
}

‎pkg/line/methods.go‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,3 +430,41 @@ func (c *Client) AcquireEncryptedAccessToken() (string, error) {
430430

431431
return parts[1], nil
432432
}
433+
434+
func (c *Client) GetMessageBoxes(options MessageBoxesOptions) (*MessageBoxesResponse, error) {
435+
resp, err := c.callRPC("TalkService", "getMessageBoxes", options, 2)
436+
if err != nil {
437+
return nil, err
438+
}
439+
var wrapper struct {
440+
Code int `json:"code"`
441+
Message string `json:"message"`
442+
Data MessageBoxesResponse `json:"data"`
443+
}
444+
if err := json.Unmarshal(resp, &wrapper); err != nil {
445+
return nil, err
446+
}
447+
if wrapper.Code != 0 {
448+
return nil, fmt.Errorf("getMessageBoxes failed: %s", wrapper.Message)
449+
}
450+
return &wrapper.Data, nil
451+
}
452+
453+
func (c *Client) GetRecentMessagesV2(chatMid string, limit int) ([]*Message, error) {
454+
resp, err := c.callRPC("TalkService", "getRecentMessagesV2", chatMid, limit)
455+
if err != nil {
456+
return nil, err
457+
}
458+
var wrapper struct {
459+
Code int `json:"code"`
460+
Message string `json:"message"`
461+
Data []*Message `json:"data"`
462+
}
463+
if err := json.Unmarshal(resp, &wrapper); err != nil {
464+
return nil, err
465+
}
466+
if wrapper.Code != 0 {
467+
return nil, fmt.Errorf("getRecentMessagesV2 failed: %s", wrapper.Message)
468+
}
469+
return wrapper.Data, nil
470+
}

‎pkg/line/structs.go‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,49 @@ type AcquireEncryptedAccessTokenResponse struct {
247247
Message string `json:"message"`
248248
Data string `json:"data"` // Format: "expirySeconds\x1eToken"
249249
}
250+
251+
type MessageBoxesOptions struct {
252+
ActiveOnly bool `json:"activeOnly"`
253+
UnreadOnly bool `json:"unreadOnly"`
254+
MessageBoxCountLimit int `json:"messageBoxCountLimit"`
255+
WithUnreadCount bool `json:"withUnreadCount"`
256+
LastMessagesPerMessageBoxCount int `json:"lastMessagesPerMessageBoxCount"`
257+
}
258+
259+
type MessageBoxesResponse struct {
260+
MessageBoxes []MessageBox `json:"messageBoxes"`
261+
HasNext bool `json:"hasNext"`
262+
}
263+
264+
type MessageBox struct {
265+
ID string `json:"id"`
266+
MidType int `json:"midType"`
267+
LastDeliveredMessageID *MessageIDWrapper `json:"lastDeliveredMessageId"`
268+
LastSeenMessageID string `json:"lastSeenMessageId"`
269+
UnreadCount json.Number `json:"unreadCount"`
270+
LastMessages []Message `json:"lastMessages"`
271+
}
272+
273+
type MessageIDWrapper struct {
274+
DeliveredTime json.Number `json:"deliveredTime"`
275+
MessageID string `json:"messageId"`
276+
}
277+
278+
type PageInfoResponse struct {
279+
Result PageInfoResult `json:"result"`
280+
Code int `json:"code"`
281+
Message string `json:"message"`
282+
}
283+
284+
type PageInfoResult struct {
285+
URL string `json:"url"`
286+
Domain string `json:"domain"`
287+
Title string `json:"title"`
288+
Summary string `json:"summary"`
289+
Image string `json:"image"`
290+
Obs ObsInfo `json:"obs"`
291+
}
292+
293+
type ObsInfo struct {
294+
CDN string `json:"cdn"`
295+
}

0 commit comments

Comments
 (0)