diff --git a/pkg/connector/creategroup.go b/pkg/connector/creategroup.go index 4b7f98b..eb6ddbc 100644 --- a/pkg/connector/creategroup.go +++ b/pkg/connector/creategroup.go @@ -240,6 +240,22 @@ func (lc *LineClient) registerGroupKey(ctx context.Context, chatMid string, memb return fmt.Errorf("no members with valid E2EE keys") } + // LINE's registerE2EEGroupKey requires the caller's own key entry as well — without it the + // server rejects the request with "empty caller key". The Chrome extension wraps the group + // key for every member returned by getLastE2EEPublicKeys, which includes the caller. Mirror + // that by wrapping the group key for our own public key and appending ourselves. + selfRawID, selfPub, err := lc.E2EE.MyPublicKey() + if err != nil { + return fmt.Errorf("get own E2EE key: %w", err) + } + selfEncryptedKey, err := lc.E2EE.WrapGroupKeyForMember(selfPub, groupKeyID) + if err != nil { + return fmt.Errorf("wrap group key for self: %w", err) + } + apiMembers = append(apiMembers, lc.Mid) + keyIds = append(keyIds, selfRawID) + encryptedKeys = append(encryptedKeys, selfEncryptedKey) + if err := client.RegisterE2EEGroupKey(1, chatMid, apiMembers, keyIds, encryptedKeys); err != nil { if lc.isRefreshRequired(err) || lc.isLoggedOut(err) { if errRecover := lc.recoverToken(ctx); errRecover == nil { diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index b3cc607..2d07755 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -827,6 +827,15 @@ func (lc *LineClient) HandleMatrixLeaveRoom(ctx context.Context, portal *bridgev client := line.NewClient(lc.AccessToken) reqSeq := int(time.Now().UnixMilli() % 1_000_000_000) + + // A still-pending invite (the user declined the Request without accepting it) must be + // rejected, not removed — the user was never a member of the LINE chat. The framework + // clears MessageRequest once the invite is accepted, so an accepted chat falls through to + // the normal SendChatRemoved leave path below. + if portal.MessageRequest { + return client.RejectChatInvitation(int64(reqSeq), string(portal.ID)) + } + lc.reqSeqMu.Lock() if lc.sentReqSeqs == nil { lc.sentReqSeqs = make(map[int]time.Time) @@ -837,6 +846,17 @@ func (lc *LineClient) HandleMatrixLeaveRoom(ctx context.Context, portal *bridgev return client.SendChatRemoved(int64(reqSeq), string(portal.ID), "0", 0) } +// Compile-time assertion that LineClient handles Beeper message-request acceptance. +var _ bridgev2.MessageRequestAcceptingNetworkAPI = (*LineClient)(nil) + +// HandleMatrixAcceptMessageRequest is called when the user accepts a Request in Beeper (a +// pending LINE group invitation). It accepts the invitation on the LINE side, joining the chat. +func (lc *LineClient) HandleMatrixAcceptMessageRequest(ctx context.Context, msg *bridgev2.MatrixAcceptMessageRequest) error { + client := line.NewClient(lc.AccessToken) + reqSeq := int64(time.Now().UnixMilli() % 1_000_000_000) + return client.AcceptChatInvitation(reqSeq, string(msg.Portal.ID)) +} + func (lc *LineClient) buildMentionMetadata(ctx context.Context, body, formattedBody string, mentions *event.Mentions) map[string]string { if mentions == nil || (len(mentions.UserIDs) == 0 && !mentions.Room) { return nil diff --git a/pkg/connector/sync.go b/pkg/connector/sync.go index 719887b..813c8c6 100644 --- a/pkg/connector/sync.go +++ b/pkg/connector/sync.go @@ -211,11 +211,16 @@ func (lc *LineClient) syncChats(ctx context.Context) { portalKey := networkid.PortalKey{ID: makePortalID(chat.ChatMid), Receiver: lc.UserLogin.ID} info := lc.chatToChatInfo(ctx, &chat, true) + // Member chats are created lazily on their first message; invited (not-yet-joined) + // chats have no incoming messages, so create their portal here so the pending + // invite surfaces as a Request even when it was outstanding before this sync. + createPortal := info.MessageRequest != nil && *info.MessageRequest lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.ChatResync{ EventMeta: simplevent.EventMeta{ - Type: bridgev2.RemoteEventChatResync, - PortalKey: portalKey, - Timestamp: time.Now(), + Type: bridgev2.RemoteEventChatResync, + PortalKey: portalKey, + CreatePortal: createPortal, + Timestamp: time.Now(), }, ChatInfo: info, }) @@ -236,16 +241,21 @@ func (lc *LineClient) chatToChatInfo(ctx context.Context, chat *line.Chat, exclu } var groupMemberMids []string + selfInvitePending := false if chat.Extra.GroupExtra != nil { if chat.Extra.GroupExtra.CreatorMid == lc.Mid { members[0].PowerLevel = ptr.Ptr(100) } - // If the bridge user is not a full member but is an invitee, mark as invite - _, isMember := chat.Extra.GroupExtra.MemberMids[lc.Mid] - if !isMember { - _, isInvitee := chat.Extra.GroupExtra.InviteeMids[lc.Mid] - if isInvitee { - members[0].Membership = event.MembershipInvite + // If the bridge user is invited but not yet a full member of a GROUP (type 0), surface the + // chat as a Beeper message request (Requests section) via info.MessageRequest below. The + // gate is GROUP-only on purpose: LINE ROOMs (type 1) have no accept step — invitees are + // auto-joined (see the invitee loop below, which also joins type-1 invitees) — so a room is + // created as a normal joined room rather than a request. Don't mark self as + // MembershipInvite: on Beeper an invite-membership self user is excluded from the room + // entirely (getInitialMemberList skips non-join members), so the room would never appear. + if _, isMember := chat.Extra.GroupExtra.MemberMids[lc.Mid]; !isMember && chat.Type == 0 { + if _, isInvitee := chat.Extra.GroupExtra.InviteeMids[lc.Mid]; isInvitee { + selfInvitePending = true } } @@ -328,7 +338,7 @@ func (lc *LineClient) chatToChatInfo(ctx context.Context, chat *line.Chat, exclu ct = database.RoomTypeDM } - return &bridgev2.ChatInfo{ + info := &bridgev2.ChatInfo{ Type: &ct, Name: &name, Avatar: lc.avatarFromPicturePath(chat.PicturePath), @@ -339,6 +349,12 @@ func (lc *LineClient) chatToChatInfo(ctx context.Context, chat *line.Chat, exclu }, ExcludeChangesFromTimeline: excludeFromTimeline, } + // Leave MessageRequest nil for non-invite chats so a racing resync can't clear the flag + // out from under an accept that's already in flight. + if selfInvitePending { + info.MessageRequest = ptr.Ptr(true) + } + return info } func (lc *LineClient) generateNameFromMemberList(ctx context.Context, members []string) string { @@ -1246,15 +1262,6 @@ func (lc *LineClient) handleMemberJoin(chatMid, joinerMid string) { } func (lc *LineClient) handleInvite(ctx context.Context, chatMid string, opType OperationType) { - // OpInviteIntoChat is only sent to the invitee, so the bridge user is guaranteed to be invited. - // For OpNotifiedInviteIntoChat, fetch the chat info and check InviteeMids. - if opType == OpInviteIntoChat { - // Bridge user is the invitee for group type 0 chats; we need GetChats for chat metadata. - lc.handleInviteForSelf(ctx, chatMid) - return - } - - // OpNotifiedInviteIntoChat — someone else was invited into a chat we're in. client := line.NewClient(lc.AccessToken) chatsResp, err := client.GetChats([]string{chatMid}, true, true) if err != nil && (lc.isRefreshRequired(err) || lc.isLoggedOut(err)) { @@ -1264,25 +1271,41 @@ func (lc *LineClient) handleInvite(ctx context.Context, chatMid string, opType O } } if err != nil { - lc.UserLogin.Bridge.Log.Warn().Err(err).Str("chat_mid", chatMid).Msg("Failed to fetch chat info for notified invite") + lc.UserLogin.Bridge.Log.Warn().Err(err).Str("chat_mid", chatMid).Msg("Failed to fetch chat info for invite") return } - if len(chatsResp.Chats) == 0 { + if len(chatsResp.Chats) == 0 || chatsResp.Chats[0].Extra.GroupExtra == nil { return } chat := chatsResp.Chats[0] - if chat.Extra.GroupExtra != nil { - membership := event.MembershipInvite - if chat.Type == 1 { - membership = event.MembershipJoin - } - for inviteeMid := range chat.Extra.GroupExtra.InviteeMids { - if inviteeMid == lc.Mid || inviteeMid == string(lc.UserLogin.ID) { - continue - } - lc.emitMemberChange(chat.ChatMid, inviteeMid, membership, time.Now()) + // Both OpInviteIntoChat (123) and OpNotifiedInviteIntoChat (124) dispatch here. We deliberately + // don't branch on the op number: the op→party mapping is ambiguous and GetChats sometimes omits + // the caller from the member/invitee lists. Instead, treat the bridge user as the invitee unless + // they're a confirmed member — a member receiving this op is the inviter or an existing member, + // whose chat must NOT be flipped into a request. The !member check also covers the LINE quirk + // where GetChats omits the caller entirely when they are the one being invited. + _, selfIsMember := chat.Extra.GroupExtra.MemberMids[lc.Mid] + lc.UserLogin.Bridge.Log.Debug(). + Int("op_type", int(opType)). + Str("chat_mid", chatMid). + Bool("self_is_member", selfIsMember). + Msg("Handling chat invite") + if !selfIsMember { + // Bridge user was invited: create the portal as a Beeper message request. + lc.handleInviteForSelfFromChat(ctx, &chat) + } + + // Reflect any other pending invitees as invited members of the (now existing) portal. + membership := event.MembershipInvite + if chat.Type == 1 { + membership = event.MembershipJoin + } + for inviteeMid := range chat.Extra.GroupExtra.InviteeMids { + if inviteeMid == lc.Mid || inviteeMid == string(lc.UserLogin.ID) { + continue } + lc.emitMemberChange(chat.ChatMid, inviteeMid, membership, time.Now()) } } @@ -1302,23 +1325,26 @@ func (lc *LineClient) handleInviteForSelf(ctx context.Context, chatMid string) { if len(chatsResp.Chats) == 0 { return } - chat := chatsResp.Chats[0] + lc.handleInviteForSelfFromChat(ctx, &chatsResp.Chats[0]) +} - // OpInviteIntoChat is only sent to the invitee, so the bridge user is always the invitee. - // Even if GetChats didn't return the bridge user in InviteeMids (which happens when - // the LINE API doesn't include the caller in the invitee list), we add them here so that - // chatToChatInfo correctly sets MembershipInvite for GROUP (type 0) chats. +// handleInviteForSelfFromChat creates (or resyncs) the portal for a chat the bridge user has +// been invited to, flagged as a Beeper message request via chatToChatInfo. +func (lc *LineClient) handleInviteForSelfFromChat(ctx context.Context, chat *line.Chat) { + // The bridge user is always the invitee here. Even if GetChats didn't return the bridge + // user in InviteeMids (which happens when the LINE API doesn't include the caller in the + // invitee list), we add them so chatToChatInfo flags the chat as a message request. if chat.Extra.GroupExtra != nil { if chat.Extra.GroupExtra.InviteeMids == nil { chat.Extra.GroupExtra.InviteeMids = make(line.FlexibleMidMap) } chat.Extra.GroupExtra.InviteeMids[lc.Mid] = true - // Remove from MemberMids just in case, so MembershipInvite takes precedence + // Remove from MemberMids just in case, so the message-request flag takes precedence. delete(chat.Extra.GroupExtra.MemberMids, lc.Mid) } portalKey := networkid.PortalKey{ID: makePortalID(chat.ChatMid), Receiver: lc.UserLogin.ID} - info := lc.chatToChatInfo(ctx, &chat, false) + info := lc.chatToChatInfo(ctx, chat, false) lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, &simplevent.ChatResync{ EventMeta: simplevent.EventMeta{ Type: bridgev2.RemoteEventChatResync, @@ -1356,7 +1382,25 @@ func (lc *LineClient) handleSystemMessage(op line.Operation) { parts := strings.SplitN(locArgs, "\x1e", 2) if len(parts) == 2 { inviteeMid := parts[1] - lc.emitMemberChange(msg.To, inviteeMid, event.MembershipInvite, tsTime) + if inviteeMid == lc.Mid || inviteeMid == string(lc.UserLogin.ID) { + // The bridge user is the invitee: create the portal as a message request. + // Defense-in-depth in case no OpInviteIntoChat/OpNotifiedInviteIntoChat SSE op + // arrives — an emitMemberChange here would be dropped because the portal doesn't + // exist yet. The SSE handler usually wins the race, so only act as a fallback when + // the portal doesn't exist yet, to avoid a duplicate GetChats + ChatResync. + chatMid := msg.To + lc.wg.Add(1) + go func() { + defer lc.wg.Done() + portalKey := networkid.PortalKey{ID: makePortalID(chatMid), Receiver: lc.UserLogin.ID} + if portal, err := lc.UserLogin.Bridge.GetExistingPortalByKey(context.Background(), portalKey); err == nil && portal != nil && portal.MXID != "" { + return + } + lc.handleInviteForSelf(context.Background(), chatMid) + }() + } else { + lc.emitMemberChange(msg.To, inviteeMid, event.MembershipInvite, tsTime) + } } case "C_IC": // Invitation cancelled — emit leave for the invitee diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index 20ae5e1..374d64c 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -51,6 +51,12 @@ func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Port DeleteMaxAge: &jsontime.Seconds{Duration: 24 * time.Hour}, DeleteChatForEveryone: true, LocationMessage: event.CapLevelPartialSupport, + // Pending LINE group invitations surface as Beeper message requests. AcceptWithMessage + // is intentionally left unset so that sending a message implicitly accepts the invite + // (bridgev2 autoAcceptMessageRequest) — LINE rejects messages to un-joined groups. + MessageRequest: &event.MessageRequestFeatures{ + AcceptWithButton: event.CapLevelFullySupported, + }, File: event.FileFeatureMap{ event.MsgImage: { Caption: event.CapLevelRejected, diff --git a/pkg/e2ee/manager.go b/pkg/e2ee/manager.go index 19aa770..de520b3 100644 --- a/pkg/e2ee/manager.go +++ b/pkg/e2ee/manager.go @@ -67,6 +67,17 @@ func (m *Manager) MyKeyIDs() (rawID int, keyID int, err error) { return m.myRawKeyID, m.myKeyID, nil } +// MyPublicKey returns the bridge user's own latest LINE key ID and base64 public key. +// Used to include the caller's own entry when registering a group key. +func (m *Manager) MyPublicKey() (rawID int, pubB64 string, err error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.myRawKeyID == 0 || m.myPublicB64 == "" { + return 0, "", fmt.Errorf("my key not loaded") + } + return m.myRawKeyID, m.myPublicB64, nil +} + func (m *Manager) InitStorage(wrappedNonce, kdf1, kdf2 string) error { return m.runner.StorageInit(wrappedNonce, kdf1, kdf2) } diff --git a/pkg/line/methods.go b/pkg/line/methods.go index 2215423..21cd614 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -758,6 +758,27 @@ func (c *Client) InviteIntoChat(chatMid string, mids []string) error { return err } +// ChatInvitationRequest is the single struct argument taken by acceptChatInvitation and +// rejectChatInvitation. The Chrome extension calls them as `mutate([{reqSeq, chatMid}])`, i.e. a +// single request struct (unlike inviteIntoRoom, which uses positional scalar args). +type ChatInvitationRequest struct { + ReqSeq int64 `json:"reqSeq"` + ChatMid string `json:"chatMid"` +} + +// AcceptChatInvitation accepts a pending invitation into a LINE group chat (the bridge user +// joins the chat). +func (c *Client) AcceptChatInvitation(reqSeq int64, chatMid string) error { + _, err := c.callRPC("TalkService", "acceptChatInvitation", ChatInvitationRequest{ReqSeq: reqSeq, ChatMid: chatMid}) + return err +} + +// RejectChatInvitation declines a pending invitation into a LINE group chat. +func (c *Client) RejectChatInvitation(reqSeq int64, chatMid string) error { + _, err := c.callRPC("TalkService", "rejectChatInvitation", ChatInvitationRequest{ReqSeq: reqSeq, ChatMid: chatMid}) + return err +} + // FindContactByUserid looks up a LINE user by their user ID (not MID). func (c *Client) FindContactByUserid(userid string) (*Contact, error) { resp, err := c.callRPC("TalkService", "findContactByUserid", userid)