Repository: slackapi/python-slack-sdk (python)

Issue:
Custom Values passed into correctly into Bot/Installation class when cloned during token rotation
(Filling out the following details about bugs will help us solve your issue sooner.)

### Reproducible in:

```bash
pip freeze | grep slack
python --version
sw_vers && uname -v # or `ver`
```

#### The Slack SDK version

```bash
slack_bolt==1.22.0
slack_sdk==3.34.0
```

#### Python runtime version

```bash
Python 3.11.11
```

#### OS info

```bash
ProductName:		macOS
ProductVersion:		14.0
BuildVersion:		23A344
Darwin Kernel Version 23.0.0: Fri Sep 15 14:43:05 PDT 2023; root:xnu-10002.1.13~1/RELEASE_ARM64_T6020
```

#### Steps to reproduce:

1. Custom implementation of InstallationStore using `custom_values` + `token_rotation` enabled
2. During `token_rotation` if custom_values exists, this line: https://github.com/slackapi/python-slack-sdk/blob/main/slack_sdk/oauth/token_rotation/rotator.py#L88 fails because it tries to pass the custom_values which in my case are the `dict` of values. The `Bot` class doesn't expect certain values.


### Expected result:

Expect the Bot to be cloned with custom values correctly. 

https://github.com/slackapi/python-slack-sdk/blob/main/slack_sdk/oauth/installation_store/models/bot.py#L90

Seems to merge the entries into a flat dictionary so that when it is passed into the `Bot(**bot.to_dict())` it passes all the key/value pairs. It seems it should instead pass the custom_values as a key instead.

Original
```
  def to_dict(self) -> Dict[str, Any]:
        standard_values = {
            "app_id": self.app_id,
            "enterprise_id": self.enterprise_id,
            "enterprise_name": self.enterprise_name,
            "team_id": self.team_id,
            "team_name": self.team_name,
            "bot_token": self.bot_token,
            "bot_id": self.bot_id,
            "bot_user_id": self.bot_user_id,
            "bot_scopes": ",".join(self.bot_scopes) if self.bot_scopes else None,
            "bot_refresh_token": self.bot_refresh_token,
            "bot_token_expires_at": (
                datetime.utcfromtimestamp(self.bot_token_expires_at) if self.bot_token_expires_at is not None else None
            ),
            "is_enterprise_install": self.is_enterprise_install,
            "installed_at": datetime.utcfromtimestamp(self.installed_at),
        }
        # prioritize standard_values over custom_values
        # when the same keys exist in both
        return {**self.custom_values, **standard_values}
```

Potential Fix
```
  def to_dict(self) -> Dict[str, Any]:
        standard_values = {
            "app_id": self.app_id,
            "enterprise_id": self.enterprise_id,
            "enterprise_name": self.enterprise_name,
            "team_id": self.team_id,
            "team_name": self.team_name,
            "bot_token": self.bot_token,
            "bot_id": self.bot_id,
            "bot_user_id": self.bot_user_id,
            "bot_scopes": ",".join(self.bot_scopes) if self.bot_scopes else None,
            "bot_refresh_token": self.bot_refresh_token,
            "bot_token_expires_at": (
                datetime.utcfromtimestamp(self.bot_token_expires_at) if self.bot_token_expires_at is not None else None
            ),
            "is_enterprise_install": self.is_enterprise_install,
            "installed_at": datetime.utcfromtimestamp(self.installed_at),
        }
        # prioritize standard_values over custom_values
        # when the same keys exist in both
        return {"custom_values": self.custom_values, **standard_values}
```

Which would then allow it to be passed into the `Bot()` class

### Actual result:

Error:
```
Failed to run a middleware (error: Bot.__init__() got an unexpected keyword argument 'id')
File "/app/.venv/lib/python3.11/site-packages/slack_sdk/oauth/token_rotation/rotator.py", line 88, in perform_bot_token_rotation
refreshed_bot = Bot(**bot.to_dict()) # type: ignore
TypeError: Bot.__init__() got an unexpected keyword argument 'id'
```



Pull request:
Fix #1636 Custom Values passed into correctly into Bot/Installation class when cloned during token rotation
## Summary

This pull request resolves #1636

### Category <!-- place an `x` in each of the `[ ]`  -->

- [x] **slack_sdk.web.WebClient (sync/async)** (Web API client)
- [ ] **slack_sdk.webhook.WebhookClient (sync/async)** (Incoming Webhook, response_url sender)
- [ ] **slack_sdk.socket_mode** (Socket Mode client)
- [ ] **slack_sdk.signature** (Request Signature Verifier)
- [x] **slack_sdk.oauth** (OAuth Flow Utilities)
- [ ] **slack_sdk.models** (UI component builders)
- [ ] **slack_sdk.scim** (SCIM API client)
- [ ] **slack_sdk.audit_logs** (Audit Logs API client)
- [ ] **slack_sdk.rtm_v2** (RTM client)
- [ ] `/docs` (Documents)
- [ ] `/tutorial` (PythOnBoardingBot tutorial)
- [ ] `tests`/`integration_tests` (Automated tests for this library)

## Requirements <!-- place an `x` in each `[ ]` -->

- [x] I've read and understood the [Contributing Guidelines](https://github.com/slackapi/python-slack-sdk/blob/main/.github/contributing.md) and have done my best effort to follow them.
- [x] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct).
- [x] I've run `python3 -m venv .venv && source .venv/bin/activate && ./scripts/run_validation.sh` after making the changes.


Patch:
diff --git a/slack_sdk/oauth/installation_store/models/bot.py b/slack_sdk/oauth/installation_store/models/bot.py
index 22f6dd1..52c1dac 100644
--- a/slack_sdk/oauth/installation_store/models/bot.py
+++ b/slack_sdk/oauth/installation_store/models/bot.py
@@ -87,8 +87,8 @@ class Bot:
     def get_custom_value(self, name: str) -> Optional[Any]:
         return self.custom_values.get(name)
 
-    def to_dict(self) -> Dict[str, Any]:
-        standard_values = {
+    def _to_standard_value_dict(self) -> Dict[str, Any]:
+        return {
             "app_id": self.app_id,
             "enterprise_id": self.enterprise_id,
             "enterprise_name": self.enterprise_name,
@@ -105,6 +105,11 @@ class Bot:
             "is_enterprise_install": self.is_enterprise_install,
             "installed_at": datetime.utcfromtimestamp(self.installed_at),
         }
+
+    def to_dict_for_copying(self) -> Dict[str, Any]:
+        return {"custom_values": self.custom_values, **self._to_standard_value_dict()}
+
+    def to_dict(self) -> Dict[str, Any]:
         # prioritize standard_values over custom_values
         # when the same keys exist in both
-        return {**self.custom_values, **standard_values}
+        return {**self.custom_values, **self._to_standard_value_dict()}
diff --git a/slack_sdk/oauth/installation_store/models/installation.py b/slack_sdk/oauth/installation_store/models/installation.py
index 42d4c90..91c6510 100644
--- a/slack_sdk/oauth/installation_store/models/installation.py
+++ b/slack_sdk/oauth/installation_store/models/installation.py
@@ -159,8 +159,8 @@ class Installation:
     def get_custom_value(self, name: str) -> Optional[Any]:
         return self.custom_values.get(name)
 
-    def to_dict(self) -> Dict[str, Any]:
-        standard_values = {
+    def _to_standard_value_dict(self) -> Dict[str, Any]:
+        return {
             "app_id": self.app_id,
             "enterprise_id": self.enterprise_id,
             "enterprise_name": self.enterprise_name,
@@ -190,6 +190,11 @@ class Installation:
             "token_type": self.token_type,
             "installed_at": datetime.utcfromtimestamp(self.installed_at),
         }
+
+    def to_dict_for_copying(self) -> Dict[str, Any]:
+        return {"custom_values": self.custom_values, **self._to_standard_value_dict()}
+
+    def to_dict(self) -> Dict[str, Any]:
         # prioritize standard_values over custom_values
         # when the same keys exist in both
-        return {**self.custom_values, **standard_values}
+        return {**self.custom_values, **self._to_standard_value_dict()}
diff --git a/slack_sdk/oauth/token_rotation/async_rotator.py b/slack_sdk/oauth/token_rotation/async_rotator.py
index 1b4047b..c3506f0 100644
--- a/slack_sdk/oauth/token_rotation/async_rotator.py
+++ b/slack_sdk/oauth/token_rotation/async_rotator.py
@@ -54,7 +54,7 @@ class AsyncTokenRotator:
 
         if rotated_bot is not None:
             if rotated_installation is None:
-                rotated_installation = Installation(**installation.to_dict())
+                rotated_installation = Installation(**installation.to_dict_for_copying())
             rotated_installation.bot_token = rotated_bot.bot_token
             rotated_installation.bot_refresh_token = rotated_bot.bot_refresh_token
             rotated_installation.bot_token_expires_at = rotated_bot.bot_token_expires_at
@@ -93,7 +93,7 @@ class AsyncTokenRotator:
             if refresh_response.get("token_type") != "bot":
                 return None
 
-            refreshed_bot = Bot(**bot.to_dict())
+            refreshed_bot = Bot(**bot.to_dict_for_copying())
             refreshed_bot.bot_token = refresh_response["access_token"]
             refreshed_bot.bot_refresh_token = refresh_response.get("refresh_token")
             refreshed_bot.bot_token_expires_at = int(time()) + int(refresh_response["expires_in"])
@@ -132,7 +132,7 @@ class AsyncTokenRotator:
             if refresh_response.get("token_type") != "user":
                 return None
 
-            refreshed_installation = Installation(**installation.to_dict())
+            refreshed_installation = Installation(**installation.to_dict_for_copying())
             refreshed_installation.user_token = refresh_response.get("access_token")
             refreshed_installation.user_refresh_token = refresh_response.get("refresh_token")
             refreshed_installation.user_token_expires_at = int(time()) + int(refresh_response.get("expires_in"))  # type: ignore[arg-type] # noqa: E501
diff --git a/slack_sdk/oauth/token_rotation/rotator.py b/slack_sdk/oauth/token_rotation/rotator.py
index aa5e429..e7dab22 100644
--- a/slack_sdk/oauth/token_rotation/rotator.py
+++ b/slack_sdk/oauth/token_rotation/rotator.py
@@ -48,7 +48,7 @@ class TokenRotator:
 
         if rotated_bot is not None:
             if rotated_installation is None:
-                rotated_installation = Installation(**installation.to_dict())
+                rotated_installation = Installation(**installation.to_dict_for_copying())
             rotated_installation.bot_token = rotated_bot.bot_token
             rotated_installation.bot_refresh_token = rotated_bot.bot_refresh_token
             rotated_installation.bot_token_expires_at = rotated_bot.bot_token_expires_at
@@ -85,7 +85,7 @@ class TokenRotator:
             if refresh_response.get("token_type") != "bot":
                 return None
 
-            refreshed_bot = Bot(**bot.to_dict())
+            refreshed_bot = Bot(**bot.to_dict_for_copying())
             refreshed_bot.bot_token = refresh_response["access_token"]
             refreshed_bot.bot_refresh_token = refresh_response.get("refresh_token")
             refreshed_bot.bot_token_expires_at = int(time()) + int(refresh_response["expires_in"])
@@ -125,7 +125,7 @@ class TokenRotator:
             if refresh_response.get("token_type") != "user":
                 return None
 
-            refreshed_installation = Installation(**installation.to_dict())
+            refreshed_installation = Installation(**installation.to_dict_for_copying())
             refreshed_installation.user_token = refresh_response.get("access_token")
             refreshed_installation.user_refresh_token = refresh_response.get("refresh_token")
             refreshed_installation.user_token_expires_at = int(time()) + int(refresh_response["expires_in"])


Test patch:
diff --git a/tests/slack_sdk/oauth/installation_store/test_models.py b/tests/slack_sdk/oauth/installation_store/test_models.py
index c39e4aa..d63964b 100644
--- a/tests/slack_sdk/oauth/installation_store/test_models.py
+++ b/tests/slack_sdk/oauth/installation_store/test_models.py
@@ -20,6 +20,7 @@ class TestModels(unittest.TestCase):
         )
         self.assertIsNotNone(bot)
         self.assertIsNotNone(bot.to_dict())
+        self.assertIsNotNone(bot.to_dict_for_copying())
 
     def test_bot_custom_fields(self):
         bot = Bot(
@@ -33,6 +34,7 @@ class TestModels(unittest.TestCase):
         bot.set_custom_value("app_id", "A222")
         self.assertEqual(bot.get_custom_value("service_user_id"), "XYZ123")
         self.assertEqual(bot.to_dict().get("service_user_id"), "XYZ123")
+        self.assertEqual(bot.to_dict_for_copying().get("custom_values").get("service_user_id"), "XYZ123")
 
     def test_installation(self):
         installation = Installation(
@@ -73,6 +75,7 @@ class TestModels(unittest.TestCase):
         self.assertEqual(installation.get_custom_value("service_user_id"), "XYZ123")
         self.assertEqual(installation.to_dict().get("service_user_id"), "XYZ123")
         self.assertEqual(installation.to_dict().get("app_id"), "A111")
+        self.assertEqual(installation.to_dict_for_copying().get("custom_values").get("app_id"), "A222")
 
         bot = installation.to_bot()
         self.assertEqual(bot.app_id, "A111")
@@ -80,3 +83,4 @@ class TestModels(unittest.TestCase):
 
         self.assertEqual(bot.to_dict().get("app_id"), "A111")
         self.assertEqual(bot.to_dict().get("service_user_id"), "XYZ123")
+        self.assertEqual(bot.to_dict_for_copying().get("custom_values").get("app_id"), "A222")
diff --git a/tests/slack_sdk/oauth/token_rotation/test_token_rotator.py b/tests/slack_sdk/oauth/token_rotation/test_token_rotator.py
index 9926932..d599e3a 100644
--- a/tests/slack_sdk/oauth/token_rotation/test_token_rotator.py
+++ b/tests/slack_sdk/oauth/token_rotation/test_token_rotator.py
@@ -43,6 +43,31 @@ class TestTokenRotator(unittest.TestCase):
         )
         self.assertIsNone(should_not_be_refreshed)
 
+    def test_refresh_with_custom_values(self):
+        installation = Installation(
+            app_id="A111",
+            enterprise_id="E111",
+            team_id="T111",
+            user_id="U111",
+            bot_id="B111",
+            bot_token="xoxe.xoxp-1-initial",
+            bot_scopes=["chat:write"],
+            bot_user_id="U222",
+            bot_refresh_token="xoxe-1-initial",
+            bot_token_expires_in=43200,
+            custom_values={"foo": "bar"},
+        )
+        refreshed = self.token_rotator.perform_token_rotation(
+            installation=installation, minutes_before_expiration=60 * 24 * 365
+        )
+        self.assertIsNotNone(refreshed)
+        self.assertIsNotNone(refreshed.custom_values)
+
+        should_not_be_refreshed = self.token_rotator.perform_token_rotation(
+            installation=installation, minutes_before_expiration=1
+        )
+        self.assertIsNone(should_not_be_refreshed)
+
     def test_token_rotation_disabled(self):
         installation = Installation(
             app_id="A111",
diff --git a/tests/slack_sdk_async/oauth/token_rotation/test_token_rotator.py b/tests/slack_sdk_async/oauth/token_rotation/test_token_rotator.py
index edc9576..4c16bb3 100644
--- a/tests/slack_sdk_async/oauth/token_rotation/test_token_rotator.py
+++ b/tests/slack_sdk_async/oauth/token_rotation/test_token_rotator.py
@@ -45,6 +45,32 @@ class TestTokenRotator(unittest.TestCase):
         )
         self.assertIsNone(should_not_be_refreshed)
 
+    @async_test
+    async def test_refresh_with_custom_values(self):
+        installation = Installation(
+            app_id="A111",
+            enterprise_id="E111",
+            team_id="T111",
+            user_id="U111",
+            bot_id="B111",
+            bot_token="xoxe.xoxp-1-initial",
+            bot_scopes=["chat:write"],
+            bot_user_id="U222",
+            bot_refresh_token="xoxe-1-initial",
+            bot_token_expires_in=43200,
+            custom_values={"foo": "bar"},
+        )
+        refreshed = await self.token_rotator.perform_token_rotation(
+            installation=installation, minutes_before_expiration=60 * 24 * 365
+        )
+        self.assertIsNotNone(refreshed)
+        self.assertIsNotNone(refreshed.custom_values)
+
+        should_not_be_refreshed = await self.token_rotator.perform_token_rotation(
+            installation=installation, minutes_before_expiration=1
+        )
+        self.assertIsNone(should_not_be_refreshed)
+
     @async_test
     async def test_token_rotation_disabled(self):
         installation = Installation(