Repository navigation
feat: 馃幐 Implement message prefetching and link previews #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+481
to
+525
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func (lc *LineClient) syncChats(ctx context.Context) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| client := line.NewClient(lc.AccessToken) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| midsResp, err := client.GetAllChatMids(true, true) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -948,14 +997,20 @@ 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() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tsInt, _ := msg.CreatedTime.Int64() | |
| ts := time.UnixMilli(tsInt) | |
| if ts.IsZero() { | |
| ts = time.Now() | |
| 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() | |
| } |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The timestamp check ts.IsZero() will not work as expected for Unix timestamps. A Unix timestamp of 0 (January 1, 1970) is a valid timestamp and will not be zero according to time.Time.IsZero(). Consider checking if tsInt == 0 before creating the time object, or check if tsInt < 0 to catch invalid values.
| ts := time.UnixMilli(tsInt) | |
| if ts.IsZero() { | |
| ts = time.Now() | |
| var ts time.Time | |
| if tsInt <= 0 { | |
| ts = time.Now() | |
| } else { | |
| ts = time.UnixMilli(tsInt) |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The URL regex pattern (https?://)?([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(/[^\s]*)? does not account for URLs with authentication credentials, ports, or certain special characters in paths (like parentheses, which are common in URLs). This could miss valid URLs or incorrectly match URL-like text. Consider using a more comprehensive URL matching pattern or a URL parsing library.
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The regex pattern is compiled on every message conversion. This regex should be compiled once and stored as a package-level variable to avoid repeated compilation overhead, especially during message prefetching where many messages could be processed.
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The GetPageInfo API call is made synchronously during message processing. This could significantly slow down message handling, especially during the prefetch phase where 50 messages per chat are processed. If a URL takes a long time to respond or times out, it will block message processing. Consider adding a timeout to the HTTP client or making link preview fetching asynchronous.
| } | |
| } | |
| // Ensure a bounded timeout for any HTTP requests made by GetPageInfo. | |
| if http.DefaultClient != nil { | |
| clientCopy := *http.DefaultClient | |
| clientCopy.Timeout = 5 * time.Second | |
| http.DefaultClient = &clientCopy | |
| } |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A new LINE client is created for every message that contains a URL for fetching link preview information. This creates unnecessary overhead. Consider reusing the client instance that was already created in the queueIncomingMessage function scope (available as lc.AccessToken via line.NewClient).
| client := line.NewClient(lc.AccessToken) |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The CanonicalURL field is being set to info.Domain instead of info.URL. According to the MSC2481 spec for link previews, CanonicalURL should contain the canonical URL of the page, not just the domain. This should likely be set to info.URL.
| CanonicalURL: info.Domain, | |
| CanonicalURL: info.URL, |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The URL check using strings.Contains(unwrappedText, ".") is too broad and will trigger regex matching for any text containing a period, including sentences, decimal numbers, or file extensions. Consider a more specific check or move this logic after a successful regex match to avoid unnecessary processing.
| 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} | |
| } | |
| 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} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
During prefetch, a database query is executed for every message (up to 5000 messages) to check if it already exists. This could create a performance bottleneck, especially with a large number of messages. Consider batching the existence checks or using a bloom filter to reduce database queries.