Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down
97 changes: 90 additions & 7 deletions pkg/connector/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"io"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -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
Comment on lines +514 to +516

Copilot AI Jan 21, 2026

Copy link

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.

Copilot uses AI. Check for mistakes.
}

opType := 26
if msg.From == lc.Mid {
opType = 25
}
lc.queueIncomingMessage(msg, opType)
}
}
Comment on lines +481 to +525

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prefetchMessages function processes up to 100 message boxes with 50 messages each (potentially 5000 messages) synchronously in a single goroutine without any rate limiting or concurrency control. This could cause significant delays during startup and may overwhelm the LINE API. Consider adding rate limiting, batching with delays, or processing a subset of recent chats.

Copilot uses AI. Check for mistakes.
}

func (lc *LineClient) syncChats(ctx context.Context) {
client := line.NewClient(lc.AccessToken)
midsResp, err := client.GetAllChatMids(true, true)
Expand Down Expand Up @@ -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()

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error from msg.CreatedTime.Int64() is silently ignored. If the conversion fails, tsInt will be 0, leading to an incorrect timestamp (Unix epoch). Consider checking the error and logging a warning or handling it appropriately.

Suggested change
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 uses AI. Check for mistakes.

Copilot AI Jan 21, 2026

Copy link

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.

Suggested change
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 uses AI. Check for mistakes.
}

lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.Message[line.Message]{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventMessage,
LogContext: func(c zerolog.Context) zerolog.Context { return c.Str("msg_id", msg.ID) },
PortalKey: portalKey,
CreatePortal: true,
Sender: bridgev2.EventSender{Sender: senderID, IsFromMe: opType == 25},
Timestamp: time.Now(),
Timestamp: ts,
},
Data: *msg,
ID: networkid.MessageID(msg.ID),
Expand Down Expand Up @@ -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]*)?`)

Copilot AI Jan 21, 2026

Copy link

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 uses AI. Check for mistakes.

Copilot AI Jan 21, 2026

Copy link

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 uses AI. Check for mistakes.
if match := urlRegex.FindString(unwrappedText); match != "" {
match = strings.TrimRight(match, ".,;:!?")
requestURL := match
if !strings.HasPrefix(match, "http") {
requestURL = "https://" + match
}

Copilot AI Jan 21, 2026

Copy link

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.

Suggested change
}
}
// 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 uses AI. Check for mistakes.
client := line.NewClient(lc.AccessToken)

Copilot AI Jan 21, 2026

Copy link

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).

Suggested change
client := line.NewClient(lc.AccessToken)

Copilot uses AI. Check for mistakes.
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,

Copilot AI Jan 21, 2026

Copy link

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.

Suggested change
CanonicalURL: info.Domain,
CanonicalURL: info.URL,

Copilot uses AI. Check for mistakes.
},
}
if info.Image != "" && info.Obs.CDN != "" {
preview.ImageURL = id.ContentURIString(info.Obs.CDN + info.Image)
}
content.BeeperLinkPreviews = []*event.BeeperLinkPreview{preview}
}

Copilot AI Jan 21, 2026

Copy link

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.

Suggested change
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}

Copilot uses AI. Check for mistakes.
}
}

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
Expand Down
40 changes: 40 additions & 0 deletions pkg/line/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copilot AI Jan 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The URL parameter is passed directly to the LINE API without validation or sanitization. Consider validating that the URL is properly formatted and not empty before making the API call to avoid unnecessary requests with invalid URLs.

Copilot uses AI. Check for mistakes.
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
}
38 changes: 38 additions & 0 deletions pkg/line/methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
46 changes: 46 additions & 0 deletions pkg/line/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Loading