From 2c90be43def852683cbb25a7ce052857a75eb0c5 Mon Sep 17 00:00:00 2001 From: Chris Ballinger Date: Fri, 17 Oct 2025 14:04:22 -0700 Subject: [PATCH] Add retention message image support --- appstoreserverlibrary/api_client.py | 142 +++++++++++ .../models/GetImageListResponse.py | 21 ++ .../models/GetImageListResponseItem.py | 34 +++ appstoreserverlibrary/models/ImageState.py | 26 ++ .../models/UploadMessageImage.py | 8 +- .../models/UploadMessageRequestBody.py | 8 +- cli/README.md | 233 +++++++++++++++--- cli/retention_message.py | 219 +++++++++++++++- .../models/getRetentionImageListResponse.json | 16 ++ .../models/imageAlreadyExistsError.json | 4 + tests/resources/models/imageInUseError.json | 4 + .../resources/models/imageNotFoundError.json | 4 + tests/resources/models/invalidImageError.json | 4 + tests/test_api_client.py | 168 +++++++++++++ tests/test_api_client_async.py | 168 +++++++++++++ 15 files changed, 1013 insertions(+), 46 deletions(-) create mode 100644 appstoreserverlibrary/models/GetImageListResponse.py create mode 100644 appstoreserverlibrary/models/GetImageListResponseItem.py create mode 100644 appstoreserverlibrary/models/ImageState.py create mode 100644 tests/resources/models/getRetentionImageListResponse.json create mode 100644 tests/resources/models/imageAlreadyExistsError.json create mode 100644 tests/resources/models/imageInUseError.json create mode 100644 tests/resources/models/imageNotFoundError.json create mode 100644 tests/resources/models/invalidImageError.json diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 32f3b440..57139e76 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -35,6 +35,7 @@ from .models.UploadMessageRequestBody import UploadMessageRequestBody from .models.GetMessageListResponse import GetMessageListResponse from .models.DefaultConfigurationRequest import DefaultConfigurationRequest +from .models.GetImageListResponse import GetImageListResponse T = TypeVar('T') @@ -528,6 +529,41 @@ class APIError(IntEnum): https://developer.apple.com/documentation/retentionmessaging/imagenotapprovederror """ + INVALID_IMAGE_ERROR = 4000104 + """ + An error that indicates the image is invalid (wrong format, size, or has transparency). + + https://developer.apple.com/documentation/retentionmessaging/invalidimageerror + """ + + IMAGE_NOT_FOUND_ERROR = 4040002 + """ + An error that indicates the specified image was not found. + + https://developer.apple.com/documentation/retentionmessaging/imagenotfounderror + """ + + IMAGE_IN_USE_ERROR = 4030002 + """ + An error that indicates the image is in use by a message and cannot be deleted. + + https://developer.apple.com/documentation/retentionmessaging/imageinuseerror + """ + + IMAGE_ALREADY_EXISTS_ERROR = 4090002 + """ + An error that indicates the image identifier already exists. + + https://developer.apple.com/documentation/retentionmessaging/imagealreadyexistserror + """ + + MAXIMUM_NUMBER_OF_IMAGES_REACHED_ERROR = 4030019 + """ + An error that indicates the maximum number of retention images (2000) has been reached. + + https://developer.apple.com/documentation/retentionmessaging/maximumnumberofimagesreachederror + """ + @define class APIException(Exception): @@ -635,6 +671,28 @@ def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union def _execute_request(self, method: str, url: str, params: Dict[str, Union[str, List[str]]], headers: Dict[str, str], json: Dict[str, Any]) -> requests.Response: return requests.request(method, url, params=params, headers=headers, json=json) + def _make_binary_request(self, path: str, method: str, binary_data: bytes, content_type: str) -> None: + """Make a request with binary data (e.g., image upload).""" + url = self._get_full_url(path) + headers = self._get_headers() + headers['Content-Type'] = content_type + # Remove Accept header for binary uploads + if 'Accept' in headers: + del headers['Accept'] + + response = requests.request(method, url, headers=headers, data=binary_data) + + # Parse response for errors (successful uploads return 200 with no body) + if not (200 <= response.status_code < 300): + if response.headers.get('content-type') == 'application/json': + try: + response_body = response.json() + raise APIException(response.status_code, response_body.get('errorCode'), response_body.get('errorMessage')) + except (ValueError, KeyError): + raise APIException(response.status_code) + else: + raise APIException(response.status_code) + def extend_renewal_date_for_all_active_subscribers(self, mass_extend_renewal_date_request: MassExtendRenewalDateRequest) -> MassExtendRenewalDateResponse: """ Uses a subscription's product identifier to extend the renewal date for all of its eligible active subscribers. @@ -879,6 +937,37 @@ def delete_default_retention_message(self, product_id: str, locale: str) -> None """ self._make_request("/inApps/v1/messaging/default/" + product_id + "/" + locale, "DELETE", {}, None, None) + def upload_retention_image(self, image_identifier: str, image_data: bytes) -> None: + """ + Upload an image to use for retention messaging. + https://developer.apple.com/documentation/retentionmessaging/upload-image + + :param image_identifier: A UUID you provide to uniquely identify the image you upload. + :param image_data: The PNG image file data (must be 3840x2160 pixels, no transparency). + :raises APIException: If a response was returned indicating the request could not be processed + """ + self._make_binary_request("/inApps/v1/messaging/image/" + image_identifier, "PUT", image_data, "image/png") + + def get_retention_image_list(self) -> GetImageListResponse: + """ + Get the image identifier and state for all uploaded images. + https://developer.apple.com/documentation/retentionmessaging/get-image-list + + :return: A response that contains status information for all images. + :raises APIException: If a response was returned indicating the request could not be processed + """ + return self._make_request("/inApps/v1/messaging/image/list", "GET", {}, None, GetImageListResponse) + + def delete_retention_image(self, image_identifier: str) -> None: + """ + Delete a previously uploaded image. + https://developer.apple.com/documentation/retentionmessaging/delete-image + + :param image_identifier: The identifier of the image to delete. + :raises APIException: If a response was returned indicating the request could not be processed + """ + self._make_request("/inApps/v1/messaging/image/" + image_identifier, "DELETE", {}, None, None) + class AsyncAppStoreServerAPIClient(BaseAppStoreServerAPIClient): def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str, environment: Environment): super().__init__(signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id, environment=environment) @@ -902,6 +991,28 @@ async def _make_request(self, path: str, method: str, queryParameters: Dict[str, async def _execute_request(self, method: str, url: str, params: Dict[str, Union[str, List[str]]], headers: Dict[str, str], json: Dict[str, Any]): return await self.http_client.request(method, url, params=params, headers=headers, json=json) + async def _make_binary_request(self, path: str, method: str, binary_data: bytes, content_type: str) -> None: + """Make an async request with binary data (e.g., image upload).""" + url = self._get_full_url(path) + headers = self._get_headers() + headers['Content-Type'] = content_type + # Remove Accept header for binary uploads + if 'Accept' in headers: + del headers['Accept'] + + response = await self.http_client.request(method, url, headers=headers, content=binary_data) + + # Parse response for errors (successful uploads return 200 with no body) + if not (200 <= response.status_code < 300): + if response.headers.get('content-type') == 'application/json': + try: + response_body = response.json() + raise APIException(response.status_code, response_body.get('errorCode'), response_body.get('errorMessage')) + except (ValueError, KeyError): + raise APIException(response.status_code) + else: + raise APIException(response.status_code) + async def extend_renewal_date_for_all_active_subscribers(self, mass_extend_renewal_date_request: MassExtendRenewalDateRequest) -> MassExtendRenewalDateResponse: """ Uses a subscription's product identifier to extend the renewal date for all of its eligible active subscribers. @@ -1145,3 +1256,34 @@ async def delete_default_retention_message(self, product_id: str, locale: str) - :raises APIException: If a response was returned indicating the request could not be processed """ await self._make_request("/inApps/v1/messaging/default/" + product_id + "/" + locale, "DELETE", {}, None, None) + + async def upload_retention_image(self, image_identifier: str, image_data: bytes) -> None: + """ + Upload an image to use for retention messaging. + https://developer.apple.com/documentation/retentionmessaging/upload-image + + :param image_identifier: A UUID you provide to uniquely identify the image you upload. + :param image_data: The PNG image file data (must be 3840x2160 pixels, no transparency). + :raises APIException: If a response was returned indicating the request could not be processed + """ + await self._make_binary_request("/inApps/v1/messaging/image/" + image_identifier, "PUT", image_data, "image/png") + + async def get_retention_image_list(self) -> GetImageListResponse: + """ + Get the image identifier and state for all uploaded images. + https://developer.apple.com/documentation/retentionmessaging/get-image-list + + :return: A response that contains status information for all images. + :raises APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request("/inApps/v1/messaging/image/list", "GET", {}, None, GetImageListResponse) + + async def delete_retention_image(self, image_identifier: str) -> None: + """ + Delete a previously uploaded image. + https://developer.apple.com/documentation/retentionmessaging/delete-image + + :param image_identifier: The identifier of the image to delete. + :raises APIException: If a response was returned indicating the request could not be processed + """ + await self._make_request("/inApps/v1/messaging/image/" + image_identifier, "DELETE", {}, None, None) diff --git a/appstoreserverlibrary/models/GetImageListResponse.py b/appstoreserverlibrary/models/GetImageListResponse.py new file mode 100644 index 00000000..f1206d79 --- /dev/null +++ b/appstoreserverlibrary/models/GetImageListResponse.py @@ -0,0 +1,21 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from typing import Optional, List +from attr import define +import attr +from .GetImageListResponseItem import GetImageListResponseItem + +@define +class GetImageListResponse: + """ + A response that contains status information for all images. + + https://developer.apple.com/documentation/retentionmessaging/getimagelistresponse + """ + + imageIdentifiers: Optional[List[GetImageListResponseItem]] = attr.ib(default=None) + """ + An array of all image identifiers and their image state. + + https://developer.apple.com/documentation/retentionmessaging/getimagelistresponseitem + """ diff --git a/appstoreserverlibrary/models/GetImageListResponseItem.py b/appstoreserverlibrary/models/GetImageListResponseItem.py new file mode 100644 index 00000000..5f7e6dff --- /dev/null +++ b/appstoreserverlibrary/models/GetImageListResponseItem.py @@ -0,0 +1,34 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from typing import Optional +from attr import define +import attr +from .ImageState import ImageState +from .LibraryUtility import AttrsRawValueAware + +@define +class GetImageListResponseItem(AttrsRawValueAware): + """ + An image identifier and state information for an image. + + https://developer.apple.com/documentation/retentionmessaging/getimagelistresponseitem + """ + + imageIdentifier: Optional[str] = attr.ib(default=None) + """ + The identifier of the image. + + https://developer.apple.com/documentation/retentionmessaging/imageidentifier + """ + + imageState: Optional[ImageState] = ImageState.create_main_attr('rawImageState') + """ + The current state of the image. + + https://developer.apple.com/documentation/retentionmessaging/imagestate + """ + + rawImageState: Optional[str] = ImageState.create_raw_attr('imageState') + """ + See imageState + """ diff --git a/appstoreserverlibrary/models/ImageState.py b/appstoreserverlibrary/models/ImageState.py new file mode 100644 index 00000000..22024399 --- /dev/null +++ b/appstoreserverlibrary/models/ImageState.py @@ -0,0 +1,26 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from enum import Enum +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class ImageState(Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + The approval state of an image. + + https://developer.apple.com/documentation/retentionmessaging/imagestate + """ + + PENDING = "PENDING" + """ + The image is awaiting approval. + """ + + APPROVED = "APPROVED" + """ + The image is approved. + """ + + REJECTED = "REJECTED" + """ + The image is rejected. + """ diff --git a/appstoreserverlibrary/models/UploadMessageImage.py b/appstoreserverlibrary/models/UploadMessageImage.py index 4f271b92..2a34d946 100644 --- a/appstoreserverlibrary/models/UploadMessageImage.py +++ b/appstoreserverlibrary/models/UploadMessageImage.py @@ -12,16 +12,16 @@ class UploadMessageImage: https://developer.apple.com/documentation/retentionmessaging/uploadmessageimage """ - imageIdentifier: Optional[str] = attr.ib(default=None) + imageIdentifier: str = attr.ib() """ - The unique identifier of an image. + **Required.** The unique identifier of an image. https://developer.apple.com/documentation/retentionmessaging/imageidentifier """ - altText: Optional[str] = attr.ib(default=None) + altText: str = attr.ib() """ - The alternative text you provide for the corresponding image. + **Required.** The alternative text you provide for the corresponding image. Maximum length: 150 https://developer.apple.com/documentation/retentionmessaging/alttext diff --git a/appstoreserverlibrary/models/UploadMessageRequestBody.py b/appstoreserverlibrary/models/UploadMessageRequestBody.py index e0975789..ef278158 100644 --- a/appstoreserverlibrary/models/UploadMessageRequestBody.py +++ b/appstoreserverlibrary/models/UploadMessageRequestBody.py @@ -13,17 +13,17 @@ class UploadMessageRequestBody: https://developer.apple.com/documentation/retentionmessaging/uploadmessagerequestbody """ - header: Optional[str] = attr.ib(default=None) + header: str = attr.ib() """ - The header text of the retention message that the system displays to customers. + **Required.** The header text of the retention message that the system displays to customers. Maximum length: 66 https://developer.apple.com/documentation/retentionmessaging/header """ - body: Optional[str] = attr.ib(default=None) + body: str = attr.ib() """ - The body text of the retention message that the system displays to customers. + **Required.** The body text of the retention message that the system displays to customers. Maximum length: 144 https://developer.apple.com/documentation/retentionmessaging/body diff --git a/cli/README.md b/cli/README.md index 0a3bd7ba..abe47783 100644 --- a/cli/README.md +++ b/cli/README.md @@ -30,8 +30,8 @@ python retention_message.py \ --issuer-id "12345678-1234-1234-1234-123456789012" \ --bundle-id "com.example.myapp" \ --p8-file "/path/to/SubscriptionKey_ABCDEFGHIJ.p8" \ - --header "Welcome back!" \ - --body "Check out our new features" + --header "Don't miss out!" \ + --body "Keep enjoying unlimited access to all premium content" ``` Upload with a specific message ID: @@ -41,9 +41,9 @@ python retention_message.py \ --issuer-id "12345678-1234-1234-1234-123456789012" \ --bundle-id "com.example.myapp" \ --p8-file "/path/to/key.p8" \ - --message-id "my-campaign-001" \ - --header "Limited Time Sale!" \ - --body "50% off premium features this week" + --message-id "550e8400-e29b-41d4-a716-446655440001" \ + --header "Stay subscribed and save" \ + --body "Your subscription gives you access to exclusive features" ``` Upload with an image: @@ -53,10 +53,11 @@ python retention_message.py \ --issuer-id "12345678-1234-1234-1234-123456789012" \ --bundle-id "com.example.myapp" \ --p8-file "/path/to/key.p8" \ - --header "New Update!" \ - --body "Amazing new features await" \ - --image-id "banner-v2" \ - --image-alt-text "App update banner showing new features" + --message-id "550e8400-e29b-41d4-a716-446655440002" \ + --header "You'll lose access to" \ + --body "Premium content, ad-free experience, and exclusive features" \ + --image-id "6ba7b810-9dad-11d1-80b4-00c04fd430c8" \ + --image-alt-text "Visual showing premium features and content library" ``` #### List All Messages @@ -79,7 +80,7 @@ python retention_message.py \ --bundle-id "com.example.myapp" \ --p8-file "/path/to/key.p8" \ --action delete \ - --message-id "my-campaign-001" + --message-id "550e8400-e29b-41d4-a716-446655440001" ``` #### Configure Default Messages @@ -94,7 +95,7 @@ python retention_message.py \ --bundle-id "com.example.myapp" \ --p8-file "/path/to/key.p8" \ --action set-default \ - --message-id "my-campaign-001" \ + --message-id "550e8400-e29b-41d4-a716-446655440003" \ --product-id "com.example.premium" \ --locale "en-US" ``` @@ -107,7 +108,7 @@ python retention_message.py \ --bundle-id "com.example.myapp" \ --p8-file "/path/to/key.p8" \ --action set-default \ - --message-id "my-campaign-001" \ + --message-id "550e8400-e29b-41d4-a716-446655440003" \ --product-id "com.example.premium" \ --product-id "com.example.basic" \ --product-id "com.example.pro" \ @@ -130,6 +131,146 @@ python retention_message.py \ --locale "en-US" ``` +### Image Operations + +Images can be uploaded and associated with text-based retention messages to make them more visually engaging. + +#### Upload an Image + +Upload a PNG image for use in retention messages with auto-generated ID: + +```bash +python retention_message.py \ + --key-id "ABCDEFGHIJ" \ + --issuer-id "12345678-1234-1234-1234-123456789012" \ + --bundle-id "com.example.myapp" \ + --p8-file "/path/to/key.p8" \ + --action upload-image \ + --image-file "/path/to/premium_features_banner.png" +``` + +Upload with a specific image ID: + +```bash +python retention_message.py \ + --key-id "ABCDEFGHIJ" \ + --issuer-id "12345678-1234-1234-1234-123456789012" \ + --bundle-id "com.example.myapp" \ + --p8-file "/path/to/key.p8" \ + --action upload-image \ + --image-id "6ba7b810-9dad-11d1-80b4-00c04fd430c8" \ + --image-file "/path/to/premium_features_banner.png" +``` + +**Image Requirements:** +- **Format**: PNG only +- **Dimensions**: Exactly 3840 × 2160 pixels +- **Transparency**: Not allowed +- **Maximum images**: 2000 per app + +After upload, images are in **PENDING** state awaiting Apple approval. In SANDBOX, they're auto-approved. + +#### List All Images + +Check the status of all uploaded images: + +```bash +python retention_message.py \ + --key-id "ABCDEFGHIJ" \ + --issuer-id "12345678-1234-1234-1234-123456789012" \ + --bundle-id "com.example.myapp" \ + --p8-file "/path/to/key.p8" \ + --action list-images +``` + +Output: +``` +Found 2 retention image(s) in SANDBOX: + + Image ID: banner-2024-q1 + State: APPROVED + + Image ID: promo-summer + State: PENDING +``` + +#### Delete an Image + +Delete a previously uploaded image (must not be in use by any message): + +```bash +python retention_message.py \ + --key-id "ABCDEFGHIJ" \ + --issuer-id "12345678-1234-1234-1234-123456789012" \ + --bundle-id "com.example.myapp" \ + --p8-file "/path/to/key.p8" \ + --action delete-image \ + --image-id "6ba7b810-9dad-11d1-80b4-00c04fd430c7" +``` + +**Important**: You must delete any messages that reference an image before you can delete the image itself. + +#### Complete Workflow: Message with Image + +Here's the complete workflow for creating a retention message with an image: + +```bash +# Step 1: Upload the image +python retention_message.py \ + --key-id "$KEY_ID" --issuer-id "$ISSUER_ID" \ + --bundle-id "$BUNDLE_ID" --p8-file "$P8_FILE" \ + --action upload-image \ + --image-id "7ba7b810-9dad-11d1-80b4-00c04fd430c9" \ + --image-file "./images/premium_benefits_showcase.png" + +# Step 2: Check image approval status (skip in SANDBOX as it's auto-approved) +python retention_message.py \ + --key-id "$KEY_ID" --issuer-id "$ISSUER_ID" \ + --bundle-id "$BUNDLE_ID" --p8-file "$P8_FILE" \ + --action list-images + +# Step 3: Upload a message that references the image +# Note: Both --header and --body are REQUIRED +# Note: --image-id and --image-alt-text must be provided together +python retention_message.py \ + --key-id "$KEY_ID" --issuer-id "$ISSUER_ID" \ + --bundle-id "$BUNDLE_ID" --p8-file "$P8_FILE" \ + --action upload \ + --message-id "650e8400-e29b-41d4-a716-446655440040" \ + --header "Keep all your benefits" \ + --body "Continue enjoying unlimited streaming and exclusive content" \ + --image-id "7ba7b810-9dad-11d1-80b4-00c04fd430c9" \ + --image-alt-text "Collage of premium features including ad-free streaming and exclusive shows" + +# Step 4: Set as default for products (optional) +python retention_message.py \ + --key-id "$KEY_ID" --issuer-id "$ISSUER_ID" \ + --bundle-id "$BUNDLE_ID" --p8-file "$P8_FILE" \ + --action set-default \ + --message-id "650e8400-e29b-41d4-a716-446655440040" \ + --product-id "com.example.premium" \ + --locale "en-US" +``` + +#### Image States + +Images can be in one of three states: +- **PENDING**: Image uploaded and awaiting Apple's review +- **APPROVED**: Image approved and can be used in messages +- **REJECTED**: Image rejected and cannot be used + +Both the message and its associated image must be in **APPROVED** state before the system can display them. + +#### Image Error Codes + +| Error Code | Description | Solution | +|------------|-------------|----------| +| 4000104 | Invalid image | Ensure PNG format, 3840×2160 pixels, no transparency | +| 4030002 | Image in use | Delete messages using this image first | +| 4030019 | Maximum images reached | Delete unused images (limit: 2000) | +| 4040002 | Image not found | Check image ID spelling | +| 4090002 | Image ID already exists | Use a different image ID | + ### Environment Options By default, the tool uses the **SANDBOX** environment. For production: @@ -179,11 +320,18 @@ Messages can be in one of three states: ### Constraints and Limits -- **Header text**: Maximum 66 characters -- **Body text**: Maximum 144 characters -- **Image alt text**: Maximum 150 characters -- **Message ID**: Must be unique (UUIDs recommended) -- **Total messages**: Limited number per app (see Apple's documentation) +**Required Fields (for upload action):** +- **Header text**: REQUIRED - Maximum 66 characters +- **Body text**: REQUIRED - Maximum 144 characters + +**Optional Fields:** +- **Image reference**: Optional - Use `--image-id` and `--image-alt-text` together +- **Image alt text**: Maximum 150 characters (required if image is included) +- **Message ID**: Must be unique (UUIDs recommended, auto-generated if not provided) + +**System Limits:** +- **Total messages**: 2000 per app +- **Total images**: 2000 per app ### Error Handling @@ -232,24 +380,40 @@ The tool provides clear error messages for common issues: #### A/B Testing Messages ```bash -# Upload message A -python retention_message.py --message-id "test-a-v1" \ - --header "Come back!" --body "We miss you" # ... other params +# Upload message A - Emotional appeal +python retention_message.py \ + --key-id "$KEY_ID" --issuer-id "$ISSUER_ID" \ + --bundle-id "$BUNDLE_ID" --p8-file "$P8_FILE" \ + --message-id "550e8400-e29b-41d4-a716-446655440010" \ + --header "We value your membership" \ + --body "Continue enjoying premium benefits and exclusive content" -# Upload message B -python retention_message.py --message-id "test-b-v1" \ - --header "New features!" --body "Check out what's new" # ... other params +# Upload message B - Value-focused +python retention_message.py \ + --key-id "$KEY_ID" --issuer-id "$ISSUER_ID" \ + --bundle-id "$BUNDLE_ID" --p8-file "$P8_FILE" \ + --message-id "550e8400-e29b-41d4-a716-446655440011" \ + --header "Keep your premium access" \ + --body "Ad-free experience, offline downloads, and more" ``` #### Seasonal Campaigns ```bash -# Holiday campaign -python retention_message.py --message-id "holiday-2023" \ - --header "Holiday Sale!" --body "Limited time: 40% off premium" # ... other params +# Holiday campaign - Highlight seasonal content +python retention_message.py \ + --key-id "$KEY_ID" --issuer-id "$ISSUER_ID" \ + --bundle-id "$BUNDLE_ID" --p8-file "$P8_FILE" \ + --message-id "550e8400-e29b-41d4-a716-446655440020" \ + --header "Holiday content awaits" \ + --body "Stay subscribed for exclusive seasonal features" -# Back to school -python retention_message.py --message-id "back-to-school-2023" \ - --header "Ready to learn?" --body "New study tools available" # ... other params +# Back to school - Educational content retention +python retention_message.py \ + --key-id "$KEY_ID" --issuer-id "$ISSUER_ID" \ + --bundle-id "$BUNDLE_ID" --p8-file "$P8_FILE" \ + --message-id "550e8400-e29b-41d4-a716-446655440021" \ + --header "Your learning continues" \ + --body "Keep accessing study tools and educational resources" ``` #### Setting Default Messages Across Multiple Tiers @@ -260,7 +424,8 @@ Apply the same message to all subscription tiers in a single command: python retention_message.py \ --key-id "$KEY_ID" --issuer-id "$ISSUER_ID" \ --bundle-id "$BUNDLE_ID" --p8-file "$P8_FILE" \ - --action set-default --message-id "general-retention-v1" \ + --action set-default \ + --message-id "550e8400-e29b-41d4-a716-446655440030" \ --product-id "com.example.basic" \ --product-id "com.example.premium" \ --product-id "com.example.pro" \ @@ -271,7 +436,7 @@ Output for bulk operations: ``` ✓ Default message configured successfully for 3 product(s)! Environment: SANDBOX - Message ID: general-retention-v1 + Message ID: 550e8400-e29b-41d4-a716-446655440030 Locale: en-US Products: com.example.basic, com.example.premium, com.example.pro ``` @@ -285,10 +450,12 @@ For automated deployments, use JSON output: RESULT=$(python retention_message.py --json --action upload \ --key-id "$KEY_ID" --issuer-id "$ISSUER_ID" \ --bundle-id "$BUNDLE_ID" --p8-file "$P8_FILE" \ - --header "Auto-deployed message" --body "Latest features") + --message-id "750e8400-e29b-41d4-a716-446655440050" \ + --header "Stay connected" \ + --body "Keep your subscription active for uninterrupted access") if echo "$RESULT" | jq -e '.status == "success"' > /dev/null; then - echo "Message deployed successfully" + echo "Retention message deployed successfully" MESSAGE_ID=$(echo "$RESULT" | jq -r '.message_id') echo "Message ID: $MESSAGE_ID" else diff --git a/cli/retention_message.py b/cli/retention_message.py index 6a95b8f4..4ae2850c 100755 --- a/cli/retention_message.py +++ b/cli/retention_message.py @@ -392,6 +392,187 @@ def delete_default_message(args) -> None: sys.exit(1) +def upload_image(args) -> None: + """Upload a retention messaging image.""" + if not args.image_file: + print("Error: --image-file is required for upload-image action") + sys.exit(1) + + # Generate image ID if not provided + image_id = args.image_id if args.image_id else str(uuid.uuid4()) + + # Load and validate image file + try: + with open(args.image_file, 'rb') as f: + image_data = f.read() + except FileNotFoundError: + print(f"Error: Image file not found: {args.image_file}") + sys.exit(1) + except Exception as e: + print(f"Error reading image file: {e}") + sys.exit(1) + + client = create_api_client(args) + + try: + client.upload_retention_image(image_id, image_data) + + if args.json: + print(json.dumps({ + "status": "success", + "image_id": image_id, + "file_path": args.image_file, + "environment": args.environment + })) + else: + print(f"✓ Image uploaded successfully!") + print(f" Environment: {args.environment}") + print(f" Image ID: {image_id}") + print(f" File: {args.image_file}") + print(f" Note: Image is PENDING approval. Use --action list-images to check status.") + + except APIException as e: + error_msg = f"API Error {e.http_status_code}" + if e.api_error: + error_msg += f" ({e.api_error.name})" + if e.error_message: + error_msg += f": {e.error_message}" + + if args.json: + print(json.dumps({ + "status": "error", + "error": error_msg, + "http_status": e.http_status_code + })) + else: + print(f"✗ {error_msg}") + + sys.exit(1) + except Exception as e: + if args.json: + print(json.dumps({ + "status": "error", + "error": str(e) + })) + else: + print(f"✗ Unexpected error: {e}") + sys.exit(1) + + +def list_images(args) -> None: + """List all retention messaging images.""" + client = create_api_client(args) + + try: + response = client.get_retention_image_list() + + if args.json: + images = [] + if response.imageIdentifiers: + for img in response.imageIdentifiers: + images.append({ + "image_id": img.imageIdentifier, + "state": img.imageState.value if img.imageState else None + }) + print(json.dumps({ + "status": "success", + "images": images, + "total_count": len(images), + "environment": args.environment + })) + else: + if not response.imageIdentifiers or len(response.imageIdentifiers) == 0: + print(f"No retention images found in {args.environment}.") + else: + print(f"Found {len(response.imageIdentifiers)} retention image(s) in {args.environment}:") + print() + for img in response.imageIdentifiers: + state = img.imageState.value if img.imageState else "UNKNOWN" + print(f" Image ID: {img.imageIdentifier}") + print(f" State: {state}") + print() + + except APIException as e: + error_msg = f"API Error {e.http_status_code}" + if e.api_error: + error_msg += f" ({e.api_error.name})" + if e.error_message: + error_msg += f": {e.error_message}" + + if args.json: + print(json.dumps({ + "status": "error", + "error": error_msg, + "http_status": e.http_status_code + })) + else: + print(f"✗ {error_msg}") + + sys.exit(1) + except Exception as e: + if args.json: + print(json.dumps({ + "status": "error", + "error": str(e) + })) + else: + print(f"✗ Unexpected error: {e}") + sys.exit(1) + + +def delete_image(args) -> None: + """Delete a retention messaging image.""" + if not args.image_id: + print("Error: --image-id is required for delete-image action") + sys.exit(1) + + client = create_api_client(args) + + try: + client.delete_retention_image(args.image_id) + + if args.json: + print(json.dumps({ + "status": "success", + "image_id": args.image_id, + "action": "deleted", + "environment": args.environment + })) + else: + print(f"✓ Image deleted successfully!") + print(f" Environment: {args.environment}") + print(f" Image ID: {args.image_id}") + + except APIException as e: + error_msg = f"API Error {e.http_status_code}" + if e.api_error: + error_msg += f" ({e.api_error.name})" + if e.error_message: + error_msg += f": {e.error_message}" + + if args.json: + print(json.dumps({ + "status": "error", + "error": error_msg, + "http_status": e.http_status_code, + "image_id": args.image_id + })) + else: + print(f"✗ {error_msg}") + + sys.exit(1) + except Exception as e: + if args.json: + print(json.dumps({ + "status": "error", + "error": str(e), + "image_id": args.image_id + })) + else: + print(f"✗ Unexpected error: {e}") + sys.exit(1) + + def main(): """Main entry point.""" parser = argparse.ArgumentParser( @@ -478,7 +659,7 @@ def main(): # Action selection parser.add_argument( '--action', - choices=['upload', 'list', 'delete', 'set-default', 'delete-default'], + choices=['upload', 'list', 'delete', 'set-default', 'delete-default', 'upload-image', 'list-images', 'delete-image'], default='upload', help='Action to perform (default: upload)' ) @@ -491,19 +672,19 @@ def main(): ) content_group.add_argument( '--header', - help='Header text (max 66 characters)' + help='**Required** for upload. Header text (max 66 characters)' ) content_group.add_argument( '--body', - help='Body text (max 144 characters)' + help='**Required** for upload. Body text (max 144 characters)' ) content_group.add_argument( '--image-id', - help='Image identifier for optional image' + help='Image identifier (UUID). For upload: references existing image (with --image-alt-text). For upload-image: auto-generated if not provided. For delete-image: required.' ) content_group.add_argument( '--image-alt-text', - help='Alternative text for image (max 150 characters)' + help='Alt text for image (max 150 characters). Required when using --image-id with upload action.' ) # Default message configuration arguments @@ -518,6 +699,13 @@ def main(): help='Locale code (e.g., "en-US", "fr-FR"). Default: en-US' ) + # Image operations arguments + image_group = parser.add_argument_group('image operations (upload-image, list-images, delete-image)') + image_group.add_argument( + '--image-file', + help='Path to PNG image file (3840x2160 pixels, no transparency). Required for upload-image.' + ) + # Global options parser.add_argument( '--environment', @@ -539,6 +727,16 @@ def main(): args = parser.parse_args() # Validate arguments based on action + if args.action == 'upload': + if not args.header: + parser.error("--header is required for upload action") + if not args.body: + parser.error("--body is required for upload action") + # Validate that image-id and image-alt-text are provided together or not at all + if args.image_id and not args.image_alt_text: + parser.error("--image-alt-text is required when --image-id is provided") + if args.image_alt_text and not args.image_id: + parser.error("--image-id is required when --image-alt-text is provided") if args.action == 'delete' and not args.message_id: parser.error("--message-id is required for delete action") if args.action == 'set-default': @@ -548,6 +746,11 @@ def main(): parser.error("--product-id is required for set-default action") if args.action == 'delete-default' and not args.product_id: parser.error("--product-id is required for delete-default action") + if args.action == 'upload-image': + if not args.image_file: + parser.error("--image-file is required for upload-image action") + if args.action == 'delete-image' and not args.image_id: + parser.error("--image-id is required for delete-image action") # Validate file exists if not os.path.isfile(args.p8_file): @@ -565,6 +768,12 @@ def main(): set_default_message(args) elif args.action == 'delete-default': delete_default_message(args) + elif args.action == 'upload-image': + upload_image(args) + elif args.action == 'list-images': + list_images(args) + elif args.action == 'delete-image': + delete_image(args) if __name__ == '__main__': diff --git a/tests/resources/models/getRetentionImageListResponse.json b/tests/resources/models/getRetentionImageListResponse.json new file mode 100644 index 00000000..bed03632 --- /dev/null +++ b/tests/resources/models/getRetentionImageListResponse.json @@ -0,0 +1,16 @@ +{ + "imageIdentifiers": [ + { + "imageIdentifier": "test-image-1", + "imageState": "PENDING" + }, + { + "imageIdentifier": "test-image-2", + "imageState": "APPROVED" + }, + { + "imageIdentifier": "test-image-3", + "imageState": "REJECTED" + } + ] +} diff --git a/tests/resources/models/imageAlreadyExistsError.json b/tests/resources/models/imageAlreadyExistsError.json new file mode 100644 index 00000000..f1ddb084 --- /dev/null +++ b/tests/resources/models/imageAlreadyExistsError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4090002, + "errorMessage": "The image identifier already exists." +} diff --git a/tests/resources/models/imageInUseError.json b/tests/resources/models/imageInUseError.json new file mode 100644 index 00000000..932b36e1 --- /dev/null +++ b/tests/resources/models/imageInUseError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4030002, + "errorMessage": "The request is forbidden because the image is in use." +} diff --git a/tests/resources/models/imageNotFoundError.json b/tests/resources/models/imageNotFoundError.json new file mode 100644 index 00000000..65cdd3b7 --- /dev/null +++ b/tests/resources/models/imageNotFoundError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4040002, + "errorMessage": "The system can't find the image." +} diff --git a/tests/resources/models/invalidImageError.json b/tests/resources/models/invalidImageError.json new file mode 100644 index 00000000..a2470b94 --- /dev/null +++ b/tests/resources/models/invalidImageError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4000104, + "errorMessage": "The request is invalid and can't be accepted." +} diff --git a/tests/test_api_client.py b/tests/test_api_client.py index ee4708af..2b3e1bf0 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -39,6 +39,7 @@ from appstoreserverlibrary.models.UploadMessageImage import UploadMessageImage from appstoreserverlibrary.models.RetentionMessageState import RetentionMessageState from appstoreserverlibrary.models.DefaultConfigurationRequest import DefaultConfigurationRequest +from appstoreserverlibrary.models.ImageState import ImageState from tests.util import decode_json_from_signed_date, read_data_from_binary_file, read_data_from_file @@ -789,3 +790,170 @@ def test_delete_retention_message_not_found_error(self): return self.assertFalse(True) + + def get_client_with_binary_mock(self, response_body: bytes, expected_method: str, expected_url: str, expected_content_type: str, expected_binary_data: bytes, status_code: int = 200): + """Helper for testing binary upload endpoints (e.g., image upload).""" + signing_key = self.get_signing_key() + client = AppStoreServerAPIClient(signing_key, 'keyId', 'issuerId', 'com.example', Environment.LOCAL_TESTING) + + def fake_binary_request(path: str, method: str, binary_data: bytes, content_type: str): + url = client._get_full_url(path) + self.assertEqual(expected_method, method) + self.assertEqual(expected_url, url) + self.assertEqual(expected_content_type, content_type) + self.assertEqual(expected_binary_data, binary_data) + + # Simulate error handling from _make_binary_request + if not (200 <= status_code < 300): + if response_body: + import json + response_data = json.loads(response_body.decode('utf-8')) + raise APIException(status_code, response_data.get('errorCode'), response_data.get('errorMessage')) + else: + raise APIException(status_code) + + client._make_binary_request = fake_binary_request + return client + + def test_upload_retention_image(self): + test_image_data = b'\x89PNG\r\n\x1a\n' # Minimal PNG header + client = self.get_client_with_binary_mock( + b'', # Empty response body for successful upload + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/image/test-image-id', + 'image/png', + test_image_data + ) + + # Should not raise exception for successful upload + client.upload_retention_image('test-image-id', test_image_data) + + def test_upload_retention_image_already_exists_error(self): + test_image_data = b'\x89PNG\r\n\x1a\n' + error_body = read_data_from_binary_file('tests/resources/models/imageAlreadyExistsError.json') + client = self.get_client_with_binary_mock( + error_body, + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/image/test-image-id', + 'image/png', + test_image_data, + 409 + ) + + try: + client.upload_retention_image('test-image-id', test_image_data) + except APIException as e: + self.assertEqual(409, e.http_status_code) + self.assertEqual(4090002, e.raw_api_error) + self.assertEqual(APIError.IMAGE_ALREADY_EXISTS_ERROR, e.api_error) + self.assertEqual("The image identifier already exists.", e.error_message) + return + + self.assertFalse(True) + + def test_upload_retention_image_invalid_error(self): + test_image_data = b'invalid image data' + error_body = read_data_from_binary_file('tests/resources/models/invalidImageError.json') + client = self.get_client_with_binary_mock( + error_body, + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/image/test-image-id', + 'image/png', + test_image_data, + 400 + ) + + try: + client.upload_retention_image('test-image-id', test_image_data) + except APIException as e: + self.assertEqual(400, e.http_status_code) + self.assertEqual(4000104, e.raw_api_error) + self.assertEqual(APIError.INVALID_IMAGE_ERROR, e.api_error) + self.assertEqual("The request is invalid and can't be accepted.", e.error_message) + return + + self.assertFalse(True) + + def test_get_retention_image_list(self): + client = self.get_client_with_body_from_file( + 'tests/resources/models/getRetentionImageListResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/image/list', + {}, + None + ) + + response = client.get_retention_image_list() + + self.assertIsNotNone(response) + self.assertIsNotNone(response.imageIdentifiers) + self.assertEqual(3, len(response.imageIdentifiers)) + + # Check first image + image1 = response.imageIdentifiers[0] + self.assertEqual('test-image-1', image1.imageIdentifier) + self.assertEqual(ImageState.PENDING, image1.imageState) + + # Check second image + image2 = response.imageIdentifiers[1] + self.assertEqual('test-image-2', image2.imageIdentifier) + self.assertEqual(ImageState.APPROVED, image2.imageState) + + # Check third image + image3 = response.imageIdentifiers[2] + self.assertEqual('test-image-3', image3.imageIdentifier) + self.assertEqual(ImageState.REJECTED, image3.imageState) + + def test_delete_retention_image(self): + client = self.get_client_with_body( + b'', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/image/test-image-id', + {}, + None + ) + + # Should not raise exception for successful deletion + client.delete_retention_image('test-image-id') + + def test_delete_retention_image_not_found_error(self): + client = self.get_client_with_body_from_file( + 'tests/resources/models/imageNotFoundError.json', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/image/nonexistent-image-id', + {}, + None, + 404 + ) + + try: + client.delete_retention_image('nonexistent-image-id') + except APIException as e: + self.assertEqual(404, e.http_status_code) + self.assertEqual(4040002, e.raw_api_error) + self.assertEqual(APIError.IMAGE_NOT_FOUND_ERROR, e.api_error) + self.assertEqual("The system can't find the image.", e.error_message) + return + + self.assertFalse(True) + + def test_delete_retention_image_in_use_error(self): + client = self.get_client_with_body_from_file( + 'tests/resources/models/imageInUseError.json', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/image/image-in-use', + {}, + None, + 403 + ) + + try: + client.delete_retention_image('image-in-use') + except APIException as e: + self.assertEqual(403, e.http_status_code) + self.assertEqual(4030002, e.raw_api_error) + self.assertEqual(APIError.IMAGE_IN_USE_ERROR, e.api_error) + self.assertEqual("The request is forbidden because the image is in use.", e.error_message) + return + + self.assertFalse(True) diff --git a/tests/test_api_client_async.py b/tests/test_api_client_async.py index 74ded647..ac9f28d4 100644 --- a/tests/test_api_client_async.py +++ b/tests/test_api_client_async.py @@ -43,6 +43,7 @@ from appstoreserverlibrary.models.UploadMessageRequestBody import UploadMessageRequestBody from appstoreserverlibrary.models.UploadMessageImage import UploadMessageImage from appstoreserverlibrary.models.RetentionMessageState import RetentionMessageState +from appstoreserverlibrary.models.ImageState import ImageState from tests.util import decode_json_from_signed_date, read_data_from_binary_file, read_data_from_file @@ -720,3 +721,170 @@ async def test_delete_retention_message_not_found_error(self): self.assertFalse(True) + def get_client_with_binary_mock(self, response_body: bytes, expected_method: str, expected_url: str, expected_content_type: str, expected_binary_data: bytes, status_code: int = 200): + """Helper for testing async binary upload endpoints (e.g., image upload).""" + signing_key = self.get_signing_key() + client = AsyncAppStoreServerAPIClient(signing_key, 'keyId', 'issuerId', 'com.example', Environment.LOCAL_TESTING) + + async def fake_binary_request(path: str, method: str, binary_data: bytes, content_type: str): + url = client._get_full_url(path) + self.assertEqual(expected_method, method) + self.assertEqual(expected_url, url) + self.assertEqual(expected_content_type, content_type) + self.assertEqual(expected_binary_data, binary_data) + + # Simulate error handling from _make_binary_request + if not (200 <= status_code < 300): + if response_body: + import json + response_data = json.loads(response_body.decode('utf-8')) + raise APIException(status_code, response_data.get('errorCode'), response_data.get('errorMessage')) + else: + raise APIException(status_code) + + client._make_binary_request = fake_binary_request + return client + + async def test_upload_retention_image(self): + test_image_data = b'\x89PNG\r\n\x1a\n' # Minimal PNG header + client = self.get_client_with_binary_mock( + b'', # Empty response body for successful upload + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/image/test-image-id', + 'image/png', + test_image_data + ) + + # Should not raise exception for successful upload + await client.upload_retention_image('test-image-id', test_image_data) + + async def test_upload_retention_image_already_exists_error(self): + test_image_data = b'\x89PNG\r\n\x1a\n' + error_body = read_data_from_binary_file('tests/resources/models/imageAlreadyExistsError.json') + client = self.get_client_with_binary_mock( + error_body, + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/image/test-image-id', + 'image/png', + test_image_data, + 409 + ) + + try: + await client.upload_retention_image('test-image-id', test_image_data) + except APIException as e: + self.assertEqual(409, e.http_status_code) + self.assertEqual(4090002, e.raw_api_error) + self.assertEqual(APIError.IMAGE_ALREADY_EXISTS_ERROR, e.api_error) + self.assertEqual("The image identifier already exists.", e.error_message) + return + + self.assertFalse(True) + + async def test_upload_retention_image_invalid_error(self): + test_image_data = b'invalid image data' + error_body = read_data_from_binary_file('tests/resources/models/invalidImageError.json') + client = self.get_client_with_binary_mock( + error_body, + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/image/test-image-id', + 'image/png', + test_image_data, + 400 + ) + + try: + await client.upload_retention_image('test-image-id', test_image_data) + except APIException as e: + self.assertEqual(400, e.http_status_code) + self.assertEqual(4000104, e.raw_api_error) + self.assertEqual(APIError.INVALID_IMAGE_ERROR, e.api_error) + self.assertEqual("The request is invalid and can't be accepted.", e.error_message) + return + + self.assertFalse(True) + + async def test_get_retention_image_list(self): + client = self.get_client_with_body_from_file( + 'tests/resources/models/getRetentionImageListResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/image/list', + {}, + None + ) + + response = await client.get_retention_image_list() + + self.assertIsNotNone(response) + self.assertIsNotNone(response.imageIdentifiers) + self.assertEqual(3, len(response.imageIdentifiers)) + + # Check first image + image1 = response.imageIdentifiers[0] + self.assertEqual('test-image-1', image1.imageIdentifier) + self.assertEqual(ImageState.PENDING, image1.imageState) + + # Check second image + image2 = response.imageIdentifiers[1] + self.assertEqual('test-image-2', image2.imageIdentifier) + self.assertEqual(ImageState.APPROVED, image2.imageState) + + # Check third image + image3 = response.imageIdentifiers[2] + self.assertEqual('test-image-3', image3.imageIdentifier) + self.assertEqual(ImageState.REJECTED, image3.imageState) + + async def test_delete_retention_image(self): + client = self.get_client_with_body( + b'', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/image/test-image-id', + {}, + None + ) + + # Should not raise exception for successful deletion + await client.delete_retention_image('test-image-id') + + async def test_delete_retention_image_not_found_error(self): + client = self.get_client_with_body_from_file( + 'tests/resources/models/imageNotFoundError.json', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/image/nonexistent-image-id', + {}, + None, + 404 + ) + + try: + await client.delete_retention_image('nonexistent-image-id') + except APIException as e: + self.assertEqual(404, e.http_status_code) + self.assertEqual(4040002, e.raw_api_error) + self.assertEqual(APIError.IMAGE_NOT_FOUND_ERROR, e.api_error) + self.assertEqual("The system can't find the image.", e.error_message) + return + + self.assertFalse(True) + + async def test_delete_retention_image_in_use_error(self): + client = self.get_client_with_body_from_file( + 'tests/resources/models/imageInUseError.json', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/image/image-in-use', + {}, + None, + 403 + ) + + try: + await client.delete_retention_image('image-in-use') + except APIException as e: + self.assertEqual(403, e.http_status_code) + self.assertEqual(4030002, e.raw_api_error) + self.assertEqual(APIError.IMAGE_IN_USE_ERROR, e.api_error) + self.assertEqual("The request is forbidden because the image is in use.", e.error_message) + return + + self.assertFalse(True) +