Skip to content
Open
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
2 changes: 1 addition & 1 deletion pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func (lc *LineConnector) Start(ctx context.Context) error {
}

func (lc *LineConnector) GetBridgeInfoVersion() (info, capabilities int) {
return 1, 1
return 1, 2
}

func (lc *LineConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities {
Expand Down
5 changes: 3 additions & 2 deletions pkg/connector/creategroup.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ func (lc *LineClient) CreateGroup(ctx context.Context, params *bridgev2.GroupCre
Type: &ct,
Name: &chatName,
Members: &bridgev2.ChatMemberList{
IsFull: true,
Members: members,
IsFull: true,
Members: members,
PowerLevels: lineGroupPowerLevelOverrides(),
},
},
}, nil
Expand Down
142 changes: 142 additions & 0 deletions pkg/connector/handle_room_meta.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package connector

import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strings"

"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"

"github.com/highesttt/matrix-line-messenger/pkg/line"
)

var (
_ bridgev2.RoomNameHandlingNetworkAPI = (*LineClient)(nil)
_ bridgev2.RoomAvatarHandlingNetworkAPI = (*LineClient)(nil)
)

func (lc *LineClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.MatrixRoomName) (bool, error) {
chatMid, err := matrixGroupChatMID(msg.Portal)
if err != nil {
return false, err
}

name := msg.Content.Name
if strings.TrimSpace(name) == "" {
return false, fmt.Errorf("LINE group name cannot be empty")
}
if len([]rune(name)) > 50 {
return false, fmt.Errorf("LINE group name cannot be longer than 50 characters")
}

err = lc.withLineClientRetry(ctx, func(client *line.Client) error {
chat, err := client.GetChatForUpdate(chatMid)
if err != nil {
return err
}
chat["chatName"] = name
return client.UpdateChat(chat, line.ChatAttributeName)
})
if err != nil {
return false, err
}

msg.Portal.Name = name
msg.Portal.NameSet = true
return true, nil
}

func (lc *LineClient) HandleMatrixRoomAvatar(ctx context.Context, msg *bridgev2.MatrixRoomAvatar) (bool, error) {
chatMid, err := matrixGroupChatMID(msg.Portal)
if err != nil {
return false, err
}

avatarURL := msg.Content.URL
if avatarURL == "" && msg.Content.MSC3414File != nil {
avatarURL = msg.Content.MSC3414File.URL
}

if avatarURL == "" {
err = lc.withLineClientRetry(ctx, func(client *line.Client) error {
chat, err := client.GetChatForUpdate(chatMid)
if err != nil {
return err
}
chat["picturePath"] = ""
return client.UpdateChat(chat, line.ChatAttributePictureStatus)
})
if err != nil {
return false, err
}

msg.Portal.AvatarMXC = ""
msg.Portal.AvatarHash = [32]byte{}
msg.Portal.AvatarID = "remove"
msg.Portal.AvatarSet = true
return true, nil
}

data, err := msg.Portal.Bridge.Bot.DownloadMedia(ctx, avatarURL, msg.Content.MSC3414File)
if err != nil {
return false, fmt.Errorf("failed to download Matrix room avatar: %w", err)
}

contentType := http.DetectContentType(data)
if msg.Content.Info != nil && msg.Content.Info.MimeType != "" {
contentType = msg.Content.Info.MimeType
}
if !strings.HasPrefix(contentType, "image/") {
return false, fmt.Errorf("LINE group avatar must be an image, got %s", contentType)
}

err = lc.withLineClientRetry(ctx, func(client *line.Client) error {
return client.UploadProfileImage(chatMid, data, contentType)
})
if err != nil {
return false, err
}

hash := sha256.Sum256(data)
msg.Portal.AvatarMXC = avatarURL
msg.Portal.AvatarHash = hash
msg.Portal.AvatarID = networkid.AvatarID(hex.EncodeToString(hash[:]))
msg.Portal.AvatarSet = true
return true, nil
}

func (lc *LineClient) withLineClientRetry(ctx context.Context, fn func(*line.Client) error) error {
client := line.NewClient(lc.AccessToken)
err := fn(client)
if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) {
if errRecover := lc.recoverToken(ctx); errRecover == nil {
client = line.NewClient(lc.AccessToken)
err = fn(client)
}
}
return err
}

func matrixGroupChatMID(portal *bridgev2.Portal) (string, error) {
if portal == nil {
return "", fmt.Errorf("portal is nil")
}
if portal.RoomType == database.RoomTypeDM {
return "", fmt.Errorf("LINE does not support setting names or avatars for DMs")
}
chatMid := string(portal.ID)
if !isLineGroupPortalID(chatMid) {
return "", fmt.Errorf("LINE chat %s is not a group or room", chatMid)
}
return chatMid, nil
}

func isLineGroupPortalID(chatMid string) bool {
chatMid = strings.ToLower(chatMid)
return strings.HasPrefix(chatMid, "c") || strings.HasPrefix(chatMid, "r")
}
15 changes: 15 additions & 0 deletions pkg/connector/powerlevels.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package connector

import (
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"
)

func lineGroupPowerLevelOverrides() *bridgev2.PowerLevelOverrides {
return &bridgev2.PowerLevelOverrides{
Events: map[event.Type]int{
event.StateRoomName: 0,
event.StateRoomAvatar: 0,
},
}
}
1 change: 1 addition & 0 deletions pkg/connector/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,7 @@ func (lc *LineClient) chatToChatInfo(ctx context.Context, chat *line.Chat, exclu
Members: &bridgev2.ChatMemberList{
IsFull: true,
Members: members,
PowerLevels: lineGroupPowerLevelOverrides(),
ExcludeChangesFromTimeline: excludeFromTimeline,
},
ExcludeChangesFromTimeline: excludeFromTimeline,
Expand Down
11 changes: 10 additions & 1 deletion pkg/connector/userinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func (lc *LineClient) HandleMatrixReadReceipt(ctx context.Context, read *bridgev
}

func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures {
return &event.RoomFeatures{
features := &event.RoomFeatures{
MaxTextLength: 5000,
Reply: event.CapLevelFullySupported,
Reaction: event.CapLevelPartialSupport,
Expand Down Expand Up @@ -130,6 +130,15 @@ func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Port
},
},
}

if portal != nil && portal.RoomType != database.RoomTypeDM && isLineGroupPortalID(string(portal.ID)) {
features.State = event.StateFeatureMap{
event.StateRoomName.Type: {Level: event.CapLevelFullySupported},
event.StateRoomAvatar.Type: {Level: event.CapLevelFullySupported},
}
}

return features
}

func (lc *LineClient) IsThisUser(ctx context.Context, userID networkid.UserID) bool {
Expand Down
31 changes: 30 additions & 1 deletion pkg/line/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,12 +415,18 @@ func (c *Client) ConfirmE2EELogin(verifier, serverPublicKeyB64, encryptedKeyChai
// postWithHMAC is a small helper for non-standard RPC endpoints that still expect
// the same headers and HMAC signature as the Talk endpoints.
func (c *Client) postWithHMAC(fullURL string, body []byte) ([]byte, error) {
return c.postWithHMACContentType(fullURL, body, "application/json")
}

func (c *Client) postWithHMACContentType(fullURL string, body []byte, contentType string) ([]byte, error) {
req, err := http.NewRequest("POST", fullURL, bytes.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

req.Header.Set("Content-Type", "application/json")
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.Header.Set("User-Agent", UserAgent)
req.Header.Set("x-line-chrome-version", ExtensionVersion)
req.Header.Set("x-line-application", "CHROMEOS\t3.7.2\tChrome_OS")
Expand Down Expand Up @@ -456,6 +462,29 @@ func (c *Client) postWithHMAC(fullURL string, body []byte) ([]byte, error) {
return io.ReadAll(resp.Body)
}

func (c *Client) UploadProfileImage(mid string, data []byte, contentType string) error {
if contentType == "" {
contentType = http.DetectContentType(data)
}
fullURL := fmt.Sprintf(
"https://line-chrome-gw.line-apps.com/api/obs/uploadProfile?mid=%s",
url.QueryEscape(mid),
)
respBytes, err := c.postWithHMACContentType(fullURL, data, contentType)
if err != nil {
return err
}

var wrapper struct {
Code int `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(respBytes, &wrapper); err == nil && wrapper.Code != 0 {
return fmt.Errorf("uploadProfile failed: %s", wrapper.Message)
}
return nil
}

func (c *Client) RefreshAccessToken(refreshToken string) (*TokenV3IssueResult, error) {
url := "https://line-chrome-gw.line-apps.com/api/auth/tokenRefresh"

Expand Down
64 changes: 64 additions & 0 deletions pkg/line/methods.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package line

import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -30,6 +31,12 @@ func InvalidateOBSTokenCache() {
obsTokenMu.Unlock()
}

func unmarshalJSONUseNumber(data []byte, v any) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
return dec.Decode(v)
}

// LoginV2 performs the loginV2 RPC call to authenticate a user
func (c *Client) LoginV2(email, password, certificate, secret string) ([]byte, error) {
return c.LoginV2WithType(2, email, password, certificate, secret)
Expand Down Expand Up @@ -610,6 +617,63 @@ func (c *Client) GetChats(mids []string, withMembers, withInvitees bool) (*GetCh
return &wrapper.Data, nil
}

// GetChatForUpdate fetches a raw chat object that can be round-tripped back to
// updateChat. LINE's Chrome extension mutates its current Redux chat object, and
// groupExtra member maps contain timestamp values that the bridge's typed Chat
// struct intentionally normalizes away for membership checks.
func (c *Client) GetChatForUpdate(chatMid string) (map[string]any, error) {
req := GetChatsRequest{
ChatMids: []string{chatMid},
WithMembers: true,
WithInvitees: true,
}
resp, err := c.callRPC("TalkService", "getChats", req, 2)
if err != nil {
return nil, err
}
var wrapper struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Chats []map[string]any `json:"chats"`
} `json:"data"`
}
if err := unmarshalJSONUseNumber(resp, &wrapper); err != nil {
return nil, err
}
if wrapper.Code != 0 {
return nil, fmt.Errorf("getChats failed: %s", wrapper.Message)
}
if len(wrapper.Data.Chats) == 0 {
return nil, fmt.Errorf("chat %s not found", chatMid)
}
return wrapper.Data.Chats[0], nil
}

func (c *Client) UpdateChat(chat map[string]any, updatedAttribute int) error {
req := UpdateChatRequest{
ReqSeq: int(time.Now().UnixMilli() % 1_000_000_000),
Chat: chat,
UpdatedAttribute: updatedAttribute,
}
resp, err := c.callRPC("TalkService", "updateChat", req)
if err != nil {
return err
}
var wrapper struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(resp, &wrapper); err != nil {
return fmt.Errorf("failed to parse updateChat response: %w", err)
}
if wrapper.Code != 0 {
return fmt.Errorf("updateChat failed: %s", wrapper.Message)
}
return nil
}

func (c *Client) GetLastOpRevision() (int64, error) {
resp, err := c.callRPC("TalkService", "getLastOpRevision")
if err != nil {
Expand Down
18 changes: 16 additions & 2 deletions pkg/line/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import "encoding/json"
type FlexibleMidMap map[string]bool

func (f *FlexibleMidMap) UnmarshalJSON(data []byte) error {
var m map[string]bool
var m map[string]json.RawMessage
if err := json.Unmarshal(data, &m); err == nil {
*f = m
*f = make(FlexibleMidMap, len(m))
for mid := range m {
(*f)[mid] = true
}
return nil
}

Expand Down Expand Up @@ -347,3 +350,14 @@ type CreateChatRequest struct {
type CreateChatResponse2 struct {
Chat Chat `json:"chat"`
}

const (
ChatAttributeName = 1
ChatAttributePictureStatus = 2
)

type UpdateChatRequest struct {
ReqSeq int `json:"reqSeq"`
Chat map[string]any `json:"chat"`
UpdatedAttribute int `json:"updatedAttribute"`
}
Loading