feat: 🎸 Add refresh token compat + group messaging#9
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds two major features to the LINE Messenger bridge: refresh token support for automatic token renewal and end-to-end encrypted group messaging capabilities.
- Implements refresh token flow with automatic retry logic when access tokens expire
- Adds complete E2EE group messaging support including key unwrapping, encryption, and decryption
- Replaces the deprecated
GetMessageBoxesAPI withGetAllChatMidsandGetChatsfor better chat synchronization
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 19 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/runner.go | Adds ChannelUnwrapGroupSharedKey method to unwrap encrypted group shared keys |
| pkg/line/structs.go | Defines new data structures for group keys, chats, and token refresh requests/responses |
| pkg/line/methods.go | Adds group key fetching methods and refactors public key parsing; replaces message box API |
| pkg/line/client.go | Implements RefreshAccessToken method for token renewal |
| pkg/internal/runner.js | Adds JavaScript handler for channel-based group key unwrapping |
| pkg/e2ee/manager.go | Implements group key management with unwrapping, encryption, and decryption capabilities |
| pkg/connector/connector.go | Extends user login metadata to store and persist refresh tokens |
| pkg/connector/client.go | Integrates token refresh throughout API calls; implements group message handling and chat sync |
| README.md | Updates feature checklist to mark group chats as complete |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if err != nil { | ||
| return nil, fmt.Errorf("missing own E2EE key: %w", err) | ||
| } | ||
| fromMid := lc.midOrFallback() |
There was a problem hiding this comment.
The variable fromMid is declared here but not used until much later in the function (line 932 or 959). Consider moving this declaration closer to where it's first used to improve code readability and reduce the scope of the variable.
| res, err := client.GetContactsV2(missingMids) | ||
| if err == nil && res != nil && res.Contacts != nil { | ||
| for mid, wrapper := range res.Contacts { | ||
| lc.contactCache[mid] = wrapper.Contact |
There was a problem hiding this comment.
The contactCache map is accessed concurrently from multiple goroutines without synchronization. It's written to in syncChats (line 491), syncSingleChat (line 491), and getContact (line 732), and read from in generateNameFromMembers (line 315) and syncSingleChat (line 483). Since these operations can happen from different goroutines (e.g., syncSingleChat is called from a goroutine in handleOperation), this creates a data race condition. Consider protecting contactCache with a mutex or using a concurrent-safe map implementation.
| client = line.NewClient(lc.AccessToken) | ||
| ei3, err = client.GetEncryptedIdentityV3() | ||
| } else { | ||
| lc.UserLogin.Bridge.Log.Error().Err(errRefresh).Msg("Failed to refresh token") |
There was a problem hiding this comment.
When refreshing the token fails in the retry logic, the original error is returned but the client instance is not reset. This means subsequent operations will continue using the old (expired) client. Consider resetting the client or handling this case more explicitly to avoid using a stale client instance.
| lc.UserLogin.Bridge.Log.Error().Err(errRefresh).Msg("Failed to refresh token") | |
| lc.UserLogin.Bridge.Log.Error().Err(errRefresh).Msg("Failed to refresh token") | |
| // Invalidate tokens to avoid using a known-stale client state. | |
| lc.AccessToken = "" | |
| lc.RefreshToken = "" | |
| // Propagate the refresh failure so callers see a hard error. | |
| err = fmt.Errorf("token refresh failed: %w", errRefresh) |
| portal, err := lc.UserLogin.Bridge.GetPortalByKey(ctx, portalKey) | ||
| if err != nil || portal == nil { | ||
| continue | ||
| chunkSize := 20 |
There was a problem hiding this comment.
The chunk size of 20 is a magic number without explanation. Consider extracting this as a named constant (e.g., const maxChatsPerBatch = 20) to clarify why this specific value is chosen and make it easier to adjust if needed.
| } | ||
| if pub == "" || keyID == 0 { | ||
| return nil, fmt.Errorf("negotiateE2EEPublicKey: missing fields (pub=%t keyID=%d raw=%s)", pub != "", keyID, string(wrapper.Data)) | ||
| return nil, fmt.Errorf("missing fields (pub=%t keyID=%d raw=%s)", pub != "", keyID, string(rawData)) |
There was a problem hiding this comment.
The error message references 'negotiateE2EEPublicKey' but this code has been refactored into a separate parseE2EEPublicKey function. The error message should be updated to reflect the actual function name where the error occurs, which would be more accurate for debugging purposes.
| return nil, fmt.Errorf("missing fields (pub=%t keyID=%d raw=%s)", pub != "", keyID, string(rawData)) | |
| return nil, fmt.Errorf("parseE2EEPublicKey: missing fields (pub=%t keyID=%d raw=%s)", pub != "", keyID, string(rawData)) |
| if _, _, err2 := lc.ensurePeerKey(ctx, msg.From); err2 != nil { | ||
| lc.UserLogin.Bridge.Log.Warn().Err(err).Err(err2).Str("peer", msg.From).Int("key_id", peerRaw).Msg("Failed to fetch peer key for decrypt") | ||
| } |
There was a problem hiding this comment.
The fallback to ensurePeerKey (line 905) may overwrite a previously cached key with different keyID. If ensurePeerKeyByID fails for keyID X, then ensurePeerKey succeeds and caches a key with keyID Y, the cache entry for mid will now point to keyID Y. This could lead to using the wrong key for subsequent operations if the specific keyID is important.
| if _, _, err2 := lc.ensurePeerKey(ctx, msg.From); err2 != nil { | |
| lc.UserLogin.Bridge.Log.Warn().Err(err).Err(err2).Str("peer", msg.From).Int("key_id", peerRaw).Msg("Failed to fetch peer key for decrypt") | |
| } | |
| lc.UserLogin.Bridge.Log.Warn(). | |
| Err(err). | |
| Str("peer", msg.From). | |
| Int("key_id", peerRaw). | |
| Msg("Failed to fetch peer key by ID for decrypt") |
| // keyVersion 1 | ||
| res, err := client.GetE2EEPublicKey(mid, 1, keyID) |
There was a problem hiding this comment.
The keyVersion parameter is hardcoded to 1 without explanation. If the API supports multiple key versions, this should either be made configurable, derived from context, or documented why version 1 is always appropriate. Consider adding a comment explaining this choice.
| @@ -104,8 +104,44 @@ func (c *Client) GetEncryptedIdentityV3() (*EncryptedIdentityV3, error) { | |||
| if err := json.Unmarshal(resp, &wrapper); err != nil { | |||
| return nil, err | |||
| } | |||
There was a problem hiding this comment.
The GetEncryptedIdentityV3 function does not check wrapper.Code before returning the data, unlike other similar functions in this file (e.g., GetProfile, GetE2EEGroupSharedKey, GetLastE2EEGroupSharedKey). This inconsistency means API errors might not be properly detected and could lead to returning invalid data. The function should check if wrapper.Code != 0 and return an appropriate error.
| } | |
| } | |
| if wrapper.Code != 0 { | |
| return nil, fmt.Errorf("getEncryptedIdentityV3 failed: %s", wrapper.Message) | |
| } |
| } | ||
| var keyID int | ||
| if v, ok := resp["keyId"]; ok { | ||
| _ = json.Unmarshal(v, &keyID) |
There was a problem hiding this comment.
The error returned from json.Unmarshal is being silently discarded. If unmarshaling fails, keyID will remain 0, and the function will return 0 without indicating that an error occurred during parsing. This could mask JSON parsing issues and make debugging difficult.
| client := line.NewClient(lc.AccessToken) | ||
| res, err := client.GetContactsV2([]string{mid}) | ||
| if err != nil && lc.isRefreshRequired(err) { | ||
| if errRefresh := lc.refreshAndSave(context.TODO()); errRefresh == nil { |
There was a problem hiding this comment.
Using context.TODO() indicates that a proper context should be passed but isn't available. This is called from getContact which may not have a context parameter. Consider adding a context parameter to getContact or using context.Background() with a comment explaining why a proper context can't be used here.
| if errRefresh := lc.refreshAndSave(context.TODO()); errRefresh == nil { | |
| // getContact does not currently accept a context; use Background for this | |
| // non-interactive refresh operation instead of a placeholder context.TODO(). | |
| if errRefresh := lc.refreshAndSave(context.Background()); errRefresh == nil { |
No description provided.