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
16 changes: 16 additions & 0 deletions pkg/connector/creategroup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions pkg/connector/send_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
122 changes: 83 additions & 39 deletions pkg/connector/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand All @@ -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 {
Comment thread
indent-zero[bot] marked this conversation as resolved.
if _, isInvitee := chat.Extra.GroupExtra.InviteeMids[lc.Mid]; isInvitee {
selfInvitePending = true
}
}

Expand Down Expand Up @@ -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),
Expand All @@ -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 {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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())
}
}

Expand All @@ -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,
Expand Down Expand Up @@ -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)
Comment thread
indent-zero[bot] marked this conversation as resolved.
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
Expand Down
6 changes: 6 additions & 0 deletions pkg/connector/userinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions pkg/e2ee/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
21 changes: 21 additions & 0 deletions pkg/line/methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading