Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
108 changes: 98 additions & 10 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,25 +997,39 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) {
}
decryptedBody := bodyText

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]{
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),
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
Expand Down Expand Up @@ -1048,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)) {
Expand Down Expand Up @@ -1188,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().
Expand Down Expand Up @@ -1370,15 +1431,40 @@ func (lc *LineClient) queueIncomingMessage(msg *line.Message, opType int) {
}

// Default to Text
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: unwrappedText,
RelatesTo: replyRelatesTo,
}

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,
},
}

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.
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
Expand Down Expand Up @@ -1416,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{
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"`
}