diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index a8a0764..313d839 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -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 { diff --git a/pkg/connector/creategroup.go b/pkg/connector/creategroup.go index eb6ddbc..b43ab5d 100644 --- a/pkg/connector/creategroup.go +++ b/pkg/connector/creategroup.go @@ -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 diff --git a/pkg/connector/handle_room_meta.go b/pkg/connector/handle_room_meta.go new file mode 100644 index 0000000..d4ee1c3 --- /dev/null +++ b/pkg/connector/handle_room_meta.go @@ -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") +} diff --git a/pkg/connector/powerlevels.go b/pkg/connector/powerlevels.go new file mode 100644 index 0000000..aa6ec79 --- /dev/null +++ b/pkg/connector/powerlevels.go @@ -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, + }, + } +} diff --git a/pkg/connector/sync.go b/pkg/connector/sync.go index 9768215..5178d53 100644 --- a/pkg/connector/sync.go +++ b/pkg/connector/sync.go @@ -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, diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index a6d4388..67facd5 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -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, @@ -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 { diff --git a/pkg/line/client.go b/pkg/line/client.go index d65f06e..308d68f 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -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") @@ -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" diff --git a/pkg/line/methods.go b/pkg/line/methods.go index b976573..0bf1e05 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -1,6 +1,7 @@ package line import ( + "bytes" "encoding/base64" "encoding/json" "fmt" @@ -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) @@ -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 { diff --git a/pkg/line/structs.go b/pkg/line/structs.go index d81e1d7..3d72e3a 100644 --- a/pkg/line/structs.go +++ b/pkg/line/structs.go @@ -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 } @@ -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"` +}