diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 630ceb3e..d368bdbf 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -32,6 +32,8 @@ from .models.TransactionHistoryRequest import TransactionHistoryRequest from .models.TransactionInfoResponse import TransactionInfoResponse from .models.UpdateAppAccountTokenRequest import UpdateAppAccountTokenRequest +from .models.UploadMessageRequestBody import UploadMessageRequestBody +from .models.GetMessageListResponse import GetMessageListResponse T = TypeVar('T') @@ -457,10 +459,53 @@ class APIError(IntEnum): GENERAL_INTERNAL_RETRYABLE = 5000001 """ An error response that indicates an unknown error occurred, but you can try again. - + https://developer.apple.com/documentation/appstoreserverapi/generalinternalretryableerror """ + # Retention Messaging specific errors + HEADER_TOO_LONG_ERROR = 4000101 + """ + An error that indicates the message header exceeds 66 characters. + + https://developer.apple.com/documentation/retentionmessaging/headertoolongerror + """ + + BODY_TOO_LONG_ERROR = 4000102 + """ + An error that indicates the message body exceeds 144 characters. + + https://developer.apple.com/documentation/retentionmessaging/bodytoolongerror + """ + + ALT_TEXT_TOO_LONG_ERROR = 4000103 + """ + An error that indicates the alt text exceeds 150 characters. + + https://developer.apple.com/documentation/retentionmessaging/alttexttoolongerror + """ + + MAXIMUM_NUMBER_OF_MESSAGES_REACHED_ERROR = 4030001 + """ + An error that indicates the maximum number of retention messages (2000) has been reached. + + https://developer.apple.com/documentation/retentionmessaging/maximumnumberofmessagesreachederror + """ + + MESSAGE_NOT_FOUND_ERROR = 4040001 + """ + An error that indicates the specified message was not found. + + https://developer.apple.com/documentation/retentionmessaging/messagenotfounderror + """ + + MESSAGE_ALREADY_EXISTS_ERROR = 4090001 + """ + An error that indicates the message identifier already exists. + + https://developer.apple.com/documentation/retentionmessaging/messagealreadyexistserror + """ + @define class APIException(Exception): @@ -758,6 +803,37 @@ def set_app_account_token(self, original_transaction_id: str, update_app_account """ self._make_request("/inApps/v1/transactions/" + original_transaction_id + "/appAccountToken", "PUT", {}, update_app_account_token_request, None) + def upload_retention_message(self, message_identifier: str, retention_message_request: UploadMessageRequestBody) -> None: + """ + Upload a message to use for retention messaging. + https://developer.apple.com/documentation/retentionmessaging/upload-message + + :param message_identifier: A UUID you provide to uniquely identify the message you upload. + :param retention_message_request: The request body containing the message text and optional image reference. + :raises APIException: If a response was returned indicating the request could not be processed + """ + self._make_request("/inApps/v1/messaging/message/" + message_identifier, "PUT", {}, retention_message_request, None) + + def get_retention_message_list(self) -> GetMessageListResponse: + """ + Get the message identifier and state of all uploaded messages. + https://developer.apple.com/documentation/retentionmessaging/get-message-list + + :return: A response that contains status information for all messages. + :raises APIException: If a response was returned indicating the request could not be processed + """ + return self._make_request("/inApps/v1/messaging/message/list", "GET", {}, None, GetMessageListResponse) + + def delete_retention_message(self, message_identifier: str) -> None: + """ + Delete a previously uploaded message. + https://developer.apple.com/documentation/retentionmessaging/delete-message + + :param message_identifier: The identifier of the message to delete. + :raises APIException: If a response was returned indicating the request could not be processed + """ + self._make_request("/inApps/v1/messaging/message/" + message_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) @@ -970,3 +1046,34 @@ async def set_app_account_token(self, original_transaction_id: str, update_app_a :raises APIException: If a response was returned indicating the request could not be processed """ await self._make_request("/inApps/v1/transactions/" + original_transaction_id + "/appAccountToken", "PUT", {}, update_app_account_token_request, None) + + async def upload_retention_message(self, message_identifier: str, retention_message_request: UploadMessageRequestBody) -> None: + """ + Upload a message to use for retention messaging. + https://developer.apple.com/documentation/retentionmessaging/upload-message + + :param message_identifier: A UUID you provide to uniquely identify the message you upload. + :param retention_message_request: The request body containing the message text and optional image reference. + :raises APIException: If a response was returned indicating the request could not be processed + """ + await self._make_request("/inApps/v1/messaging/message/" + message_identifier, "PUT", {}, retention_message_request, None) + + async def get_retention_message_list(self) -> GetMessageListResponse: + """ + Get the message identifier and state of all uploaded messages. + https://developer.apple.com/documentation/retentionmessaging/get-message-list + + :return: A response that contains status information for all messages. + :raises APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request("/inApps/v1/messaging/message/list", "GET", {}, None, GetMessageListResponse) + + async def delete_retention_message(self, message_identifier: str) -> None: + """ + Delete a previously uploaded message. + https://developer.apple.com/documentation/retentionmessaging/delete-message + + :param message_identifier: The identifier of the message to delete. + :raises APIException: If a response was returned indicating the request could not be processed + """ + await self._make_request("/inApps/v1/messaging/message/" + message_identifier, "DELETE", {}, None, None) diff --git a/appstoreserverlibrary/models/GetMessageListResponse.py b/appstoreserverlibrary/models/GetMessageListResponse.py new file mode 100644 index 00000000..ad959a71 --- /dev/null +++ b/appstoreserverlibrary/models/GetMessageListResponse.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 .GetMessageListResponseItem import GetMessageListResponseItem + +@define +class GetMessageListResponse: + """ + A response that contains status information for all messages. + + https://developer.apple.com/documentation/retentionmessaging/getmessagelistresponse + """ + + messageIdentifiers: Optional[List[GetMessageListResponseItem]] = attr.ib(default=None) + """ + An array of all message identifiers and their message states. + + https://developer.apple.com/documentation/retentionmessaging/getmessagelistresponseitem + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/GetMessageListResponseItem.py b/appstoreserverlibrary/models/GetMessageListResponseItem.py new file mode 100644 index 00000000..92125d5e --- /dev/null +++ b/appstoreserverlibrary/models/GetMessageListResponseItem.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 .RetentionMessageState import RetentionMessageState +from .LibraryUtility import AttrsRawValueAware + +@define +class GetMessageListResponseItem(AttrsRawValueAware): + """ + A message identifier and status information for a message. + + https://developer.apple.com/documentation/retentionmessaging/getmessagelistresponseitem + """ + + messageIdentifier: Optional[str] = attr.ib(default=None) + """ + The identifier of the message. + + https://developer.apple.com/documentation/retentionmessaging/messageidentifier + """ + + messageState: Optional[RetentionMessageState] = RetentionMessageState.create_main_attr('rawMessageState') + """ + The current state of the message. + + https://developer.apple.com/documentation/retentionmessaging/messagestate + """ + + rawMessageState: Optional[str] = RetentionMessageState.create_raw_attr('messageState') + """ + See messageState + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/RetentionMessageState.py b/appstoreserverlibrary/models/RetentionMessageState.py new file mode 100644 index 00000000..9ecaab0a --- /dev/null +++ b/appstoreserverlibrary/models/RetentionMessageState.py @@ -0,0 +1,26 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from enum import Enum +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class RetentionMessageState(Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + The approval state of the message. + + https://developer.apple.com/documentation/retentionmessaging/messagestate + """ + + PENDING = "PENDING" + """ + The message is awaiting approval. + """ + + APPROVED = "APPROVED" + """ + The message is approved. + """ + + REJECTED = "REJECTED" + """ + The message is rejected. + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/UploadMessageImage.py b/appstoreserverlibrary/models/UploadMessageImage.py new file mode 100644 index 00000000..4f271b92 --- /dev/null +++ b/appstoreserverlibrary/models/UploadMessageImage.py @@ -0,0 +1,28 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from typing import Optional +from attr import define +import attr + +@define +class UploadMessageImage: + """ + The definition of an image with its alternative text. + + https://developer.apple.com/documentation/retentionmessaging/uploadmessageimage + """ + + imageIdentifier: Optional[str] = attr.ib(default=None) + """ + The unique identifier of an image. + + https://developer.apple.com/documentation/retentionmessaging/imageidentifier + """ + + altText: Optional[str] = attr.ib(default=None) + """ + The alternative text you provide for the corresponding image. + Maximum length: 150 + + https://developer.apple.com/documentation/retentionmessaging/alttext + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/UploadMessageRequestBody.py b/appstoreserverlibrary/models/UploadMessageRequestBody.py new file mode 100644 index 00000000..e0975789 --- /dev/null +++ b/appstoreserverlibrary/models/UploadMessageRequestBody.py @@ -0,0 +1,37 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from typing import Optional +from attr import define +import attr +from .UploadMessageImage import UploadMessageImage + +@define +class UploadMessageRequestBody: + """ + The request body for uploading a message, which includes the message text and an optional image reference. + + https://developer.apple.com/documentation/retentionmessaging/uploadmessagerequestbody + """ + + header: Optional[str] = attr.ib(default=None) + """ + 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) + """ + The body text of the retention message that the system displays to customers. + Maximum length: 144 + + https://developer.apple.com/documentation/retentionmessaging/body + """ + + image: Optional[UploadMessageImage] = attr.ib(default=None) + """ + The optional image identifier and its alternative text to appear as part of a text-based message with an image. + + https://developer.apple.com/documentation/retentionmessaging/uploadmessageimage + """ \ No newline at end of file diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000..1cda2c19 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,226 @@ +# CLI Tools for App Store Server Library + +This directory contains command-line tools for interacting with the App Store Server API. + +## Retention Message Tool + +The `retention_message.py` tool allows you to manage retention messages that can be displayed to users to encourage app re-engagement. + +### Prerequisites + +1. **App Store Connect Credentials**: You need: + - Private Key ID (`key_id`) from App Store Connect + - Issuer ID (`issuer_id`) from App Store Connect + - Your app's Bundle ID (`bundle_id`) + - Private key file (`.p8` format) downloaded from App Store Connect + +2. **Python Dependencies**: Make sure the app-store-server-library is installed: + ```bash + pip install -r ../requirements.txt + ``` + +### Usage + +#### Upload a Retention Message + +Upload a new retention message 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/SubscriptionKey_ABCDEFGHIJ.p8" \ + --header "Welcome back!" \ + --body "Check out our new features" +``` + +Upload with a specific message 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" \ + --message-id "my-campaign-001" \ + --header "Limited Time Sale!" \ + --body "50% off premium features this week" +``` + +Upload with an image: +```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" \ + --header "New Update!" \ + --body "Amazing new features await" \ + --image-id "banner-v2" \ + --image-alt-text "App update banner showing new features" +``` + +#### List All Messages + +```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 +``` + +#### Delete a 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 \ + --message-id "my-campaign-001" +``` + +### Environment Options + +By default, the tool uses the **SANDBOX** environment. For production: + +```bash +python retention_message.py \ + --environment PRODUCTION \ + # ... other parameters +``` + +### Output Formats + +#### Human-Readable (default) +``` +✓ Message uploaded successfully! + Message ID: abc-123-def + Header: Welcome back! + Body: Check out our new features +``` + +#### JSON Format +Use `--json` for programmatic usage: +```bash +python retention_message.py --json --action list # ... other params +``` + +Output: +```json +{ + "status": "success", + "messages": [ + { + "message_id": "abc-123-def", + "state": "PENDING" + } + ], + "total_count": 1 +} +``` + +### Message States + +Messages can be in one of three states: +- **PENDING**: Message uploaded and awaiting Apple's review +- **APPROVED**: Message approved and can be shown to users +- **REJECTED**: Message rejected and cannot be used + +### 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) + +### Error Handling + +The tool provides clear error messages for common issues: + +| Error Code | Description | Solution | +|------------|-------------|----------| +| 4010001 | Header text too long | Reduce header to ≤66 characters | +| 4010002 | Body text too long | Reduce body to ≤144 characters | +| 4010003 | Alt text too long | Reduce alt text to ≤150 characters | +| 4010004 | Maximum messages reached | Delete old messages first | +| 4040001 | Message not found | Check message ID spelling | +| 4090001 | Message ID already exists | Use a different message ID | + +### Security Notes + +- **Never commit** your `.p8` private key files to version control +- Store credentials securely (consider using environment variables) +- Use sandbox environment for testing +- Be cautious with production environment operations + +### Troubleshooting + +1. **"Private key file not found"** + - Verify the path to your `.p8` file is correct + - Ensure the file exists and is readable + +2. **"Invalid app identifier"** + - Check that your bundle ID matches exactly + - Verify the bundle ID is configured in App Store Connect + +3. **Authentication errors** + - Verify your Key ID and Issuer ID are correct + - Ensure your private key corresponds to the Key ID + - Check that the key has appropriate permissions + +4. **"Message not found" when deleting** + - List messages first to see available IDs + - Ensure you're using the correct environment (sandbox vs production) + +### Examples for Different Use Cases + +#### 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 B +python retention_message.py --message-id "test-b-v1" \ + --header "New features!" --body "Check out what's new" # ... other params +``` + +#### Seasonal Campaigns +```bash +# Holiday campaign +python retention_message.py --message-id "holiday-2023" \ + --header "Holiday Sale!" --body "Limited time: 40% off premium" # ... other params + +# Back to school +python retention_message.py --message-id "back-to-school-2023" \ + --header "Ready to learn?" --body "New study tools available" # ... other params +``` + +### Integration with CI/CD + +For automated deployments, use JSON output: + +```bash +#!/bin/bash +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") + +if echo "$RESULT" | jq -e '.status == "success"' > /dev/null; then + echo "Message deployed successfully" + MESSAGE_ID=$(echo "$RESULT" | jq -r '.message_id') + echo "Message ID: $MESSAGE_ID" +else + echo "Deployment failed" + exit 1 +fi +``` + +## Future Tools + +This directory is designed to be expanded with additional CLI tools for other App Store Server API functionality as needed. \ No newline at end of file diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 00000000..59cfaba1 --- /dev/null +++ b/cli/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +""" +CLI tools for the App Store Server Library. +""" + +__version__ = "1.0.0" \ No newline at end of file diff --git a/cli/retention_message.py b/cli/retention_message.py new file mode 100755 index 00000000..e04f784b --- /dev/null +++ b/cli/retention_message.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +""" +CLI tool for managing retention messages via the App Store Server API. + +This tool allows you to upload, list, and delete retention messages that can be +displayed to users to encourage app re-engagement. + +Example usage: + # Upload a message with auto-generated ID + python retention_message.py --key-id KEY123 --issuer-id ISS456 \\ + --bundle-id com.example.app --p8-file key.p8 \\ + --header "Welcome back!" --body "Check out our new features" + + # List all messages + python retention_message.py --key-id KEY123 --issuer-id ISS456 \\ + --bundle-id com.example.app --p8-file key.p8 --action list + + # Delete a message + python retention_message.py --key-id KEY123 --issuer-id ISS456 \\ + --bundle-id com.example.app --p8-file key.p8 \\ + --action delete --message-id abc-123-def +""" + +import argparse +import json +import os +import sys +import uuid +from pathlib import Path +from typing import Optional + +# Add parent directory to path to import the library +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from appstoreserverlibrary.api_client import AppStoreServerAPIClient, APIException +from appstoreserverlibrary.models.Environment import Environment +from appstoreserverlibrary.models.UploadMessageRequestBody import UploadMessageRequestBody +from appstoreserverlibrary.models.UploadMessageImage import UploadMessageImage + + +def load_private_key(p8_file_path: str) -> bytes: + """Load private key from .p8 file.""" + try: + with open(p8_file_path, 'rb') as f: + return f.read() + except FileNotFoundError: + print(f"Error: Private key file not found: {p8_file_path}") + sys.exit(1) + except Exception as e: + print(f"Error reading private key file: {e}") + sys.exit(1) + + +def create_api_client(args) -> AppStoreServerAPIClient: + """Create and return an API client with the provided credentials.""" + private_key = load_private_key(args.p8_file) + + environment = Environment.SANDBOX if args.environment == 'SANDBOX' else Environment.PRODUCTION + + return AppStoreServerAPIClient( + signing_key=private_key, + key_id=args.key_id, + issuer_id=args.issuer_id, + bundle_id=args.bundle_id, + environment=environment + ) + + +def upload_message(args) -> None: + """Upload a retention message.""" + client = create_api_client(args) + + # Generate message ID if not provided + message_id = args.message_id if args.message_id else str(uuid.uuid4()) + + # Validate message length constraints + if args.header and len(args.header) > 66: + print(f"Error: Header text too long ({len(args.header)} chars). Maximum is 66 characters.") + sys.exit(1) + + if args.body and len(args.body) > 144: + print(f"Error: Body text too long ({len(args.body)} chars). Maximum is 144 characters.") + sys.exit(1) + + if args.image_alt_text and len(args.image_alt_text) > 150: + print(f"Error: Image alt text too long ({len(args.image_alt_text)} chars). Maximum is 150 characters.") + sys.exit(1) + + # Create image object if image parameters provided + image = None + if args.image_id or args.image_alt_text: + image = UploadMessageImage( + imageIdentifier=args.image_id, + altText=args.image_alt_text + ) + + # Create request body + request_body = UploadMessageRequestBody( + header=args.header, + body=args.body, + image=image + ) + + try: + client.upload_retention_message(message_id, request_body) + + if args.json: + print(json.dumps({ + "status": "success", + "message_id": message_id, + "header": args.header, + "body": args.body, + "environment": args.environment + })) + else: + print(f"✓ Message uploaded successfully!") + print(f" Environment: {args.environment}") + print(f" Message ID: {message_id}") + if args.header: + print(f" Header: {args.header}") + if args.body: + print(f" Body: {args.body}") + if image: + print(f" Image ID: {args.image_id}") + print(f" Alt Text: {args.image_alt_text}") + + 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_messages(args) -> None: + """List all retention messages.""" + client = create_api_client(args) + + try: + response = client.get_retention_message_list() + + if args.json: + messages = [] + if response.messageIdentifiers: + for msg in response.messageIdentifiers: + messages.append({ + "message_id": msg.messageIdentifier, + "state": msg.messageState.value if msg.messageState else None + }) + print(json.dumps({ + "status": "success", + "messages": messages, + "total_count": len(messages), + "environment": args.environment + })) + else: + if not response.messageIdentifiers or len(response.messageIdentifiers) == 0: + print(f"No retention messages found in {args.environment}.") + else: + print(f"Found {len(response.messageIdentifiers)} retention message(s) in {args.environment}:") + print() + for msg in response.messageIdentifiers: + state = msg.messageState.value if msg.messageState else "UNKNOWN" + print(f" Message ID: {msg.messageIdentifier}") + 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_message(args) -> None: + """Delete a retention message.""" + if not args.message_id: + print("Error: --message-id is required for delete action") + sys.exit(1) + + client = create_api_client(args) + + try: + client.delete_retention_message(args.message_id) + + if args.json: + print(json.dumps({ + "status": "success", + "message_id": args.message_id, + "action": "deleted", + "environment": args.environment + })) + else: + print(f"✓ Message deleted successfully!") + print(f" Environment: {args.environment}") + print(f" Message ID: {args.message_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, + "message_id": args.message_id + })) + else: + print(f"✗ {error_msg}") + + sys.exit(1) + except Exception as e: + if args.json: + print(json.dumps({ + "status": "error", + "error": str(e), + "message_id": args.message_id + })) + else: + print(f"✗ Unexpected error: {e}") + sys.exit(1) + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Manage App Store retention messages", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Upload a message with auto-generated ID + %(prog)s --key-id KEY123 --issuer-id ISS456 --bundle-id com.example.app \\ + --p8-file key.p8 --header "Welcome back!" --body "New features await" + + # Upload with specific message ID and image + %(prog)s --key-id KEY123 --issuer-id ISS456 --bundle-id com.example.app \\ + --p8-file key.p8 --message-id my-msg-001 --header "Sale!" \\ + --body "50%% off premium features" --image-id banner-001 \\ + --image-alt-text "Sale banner" + + # List all messages + %(prog)s --key-id KEY123 --issuer-id ISS456 --bundle-id com.example.app \\ + --p8-file key.p8 --action list + + # Delete a message + %(prog)s --key-id KEY123 --issuer-id ISS456 --bundle-id com.example.app \\ + --p8-file key.p8 --action delete --message-id my-msg-001 + + # Production environment + %(prog)s --key-id KEY123 --issuer-id ISS456 --bundle-id com.example.app \\ + --p8-file key.p8 --environment PRODUCTION --action list + +Error Codes: + 4010001 - Header text too long (max 66 characters) + 4010002 - Body text too long (max 144 characters) + 4010003 - Alt text too long (max 150 characters) + 4010004 - Maximum number of messages reached + 4040001 - Message not found + 4090001 - Message with this ID already exists + """ + ) + + # Required arguments for all actions + required_group = parser.add_argument_group('required arguments') + required_group.add_argument( + '--key-id', + required=True, + help='Private key ID from App Store Connect (e.g., "ABCDEFGHIJ")' + ) + required_group.add_argument( + '--issuer-id', + required=True, + help='Issuer ID from App Store Connect' + ) + required_group.add_argument( + '--bundle-id', + required=True, + help='App bundle identifier (e.g., "com.example.myapp")' + ) + required_group.add_argument( + '--p8-file', + required=True, + help='Path to .p8 private key file' + ) + + # Action selection + parser.add_argument( + '--action', + choices=['upload', 'list', 'delete'], + default='upload', + help='Action to perform (default: upload)' + ) + + # Message content arguments (for upload) + content_group = parser.add_argument_group('message content (upload only)') + content_group.add_argument( + '--message-id', + help='Unique message identifier (UUID format). Auto-generated if not provided for upload.' + ) + content_group.add_argument( + '--header', + help='Header text (max 66 characters)' + ) + content_group.add_argument( + '--body', + help='Body text (max 144 characters)' + ) + content_group.add_argument( + '--image-id', + help='Image identifier for optional image' + ) + content_group.add_argument( + '--image-alt-text', + help='Alternative text for image (max 150 characters)' + ) + + # Global options + parser.add_argument( + '--environment', + choices=['SANDBOX', 'PRODUCTION'], + default='SANDBOX', + help='App Store environment (default: SANDBOX)' + ) + parser.add_argument( + '--json', + action='store_true', + help='Output results in JSON format' + ) + parser.add_argument( + '--verbose', + action='store_true', + help='Enable verbose output' + ) + + args = parser.parse_args() + + # Validate arguments based on action + if args.action == 'delete' and not args.message_id: + parser.error("--message-id is required for delete action") + + # Validate file exists + if not os.path.isfile(args.p8_file): + print(f"Error: Private key file not found: {args.p8_file}") + sys.exit(1) + + # Execute the appropriate action + if args.action == 'upload': + upload_message(args) + elif args.action == 'list': + list_messages(args) + elif args.action == 'delete': + delete_message(args) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/tests/resources/models/getRetentionMessageListResponse.json b/tests/resources/models/getRetentionMessageListResponse.json new file mode 100644 index 00000000..cbf260bd --- /dev/null +++ b/tests/resources/models/getRetentionMessageListResponse.json @@ -0,0 +1,16 @@ +{ + "messageIdentifiers": [ + { + "messageIdentifier": "test-message-1", + "messageState": "APPROVED" + }, + { + "messageIdentifier": "test-message-2", + "messageState": "PENDING" + }, + { + "messageIdentifier": "test-message-3", + "messageState": "REJECTED" + } + ] +} \ No newline at end of file diff --git a/tests/resources/models/retentionMessageAlreadyExistsError.json b/tests/resources/models/retentionMessageAlreadyExistsError.json new file mode 100644 index 00000000..2ed3e089 --- /dev/null +++ b/tests/resources/models/retentionMessageAlreadyExistsError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4090001, + "errorMessage": "An error that indicates the message identifier already exists." +} \ No newline at end of file diff --git a/tests/resources/models/retentionMessageHeaderTooLongError.json b/tests/resources/models/retentionMessageHeaderTooLongError.json new file mode 100644 index 00000000..46f70643 --- /dev/null +++ b/tests/resources/models/retentionMessageHeaderTooLongError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4000101, + "errorMessage": "An error that indicates the message header exceeds 66 characters." +} \ No newline at end of file diff --git a/tests/resources/models/retentionMessageNotFoundError.json b/tests/resources/models/retentionMessageNotFoundError.json new file mode 100644 index 00000000..93eff5f6 --- /dev/null +++ b/tests/resources/models/retentionMessageNotFoundError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4040001, + "errorMessage": "An error that indicates the specified message was not found." +} \ No newline at end of file diff --git a/tests/test_api_client.py b/tests/test_api_client.py index a3151fe9..f7976d8a 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -35,6 +35,9 @@ from appstoreserverlibrary.models.TransactionHistoryRequest import Order, ProductType, TransactionHistoryRequest from appstoreserverlibrary.models.UpdateAppAccountTokenRequest import UpdateAppAccountTokenRequest from appstoreserverlibrary.models.UserStatus import UserStatus +from appstoreserverlibrary.models.UploadMessageRequestBody import UploadMessageRequestBody +from appstoreserverlibrary.models.UploadMessageImage import UploadMessageImage +from appstoreserverlibrary.models.RetentionMessageState import RetentionMessageState from tests.util import decode_json_from_signed_date, read_data_from_binary_file, read_data_from_file @@ -593,3 +596,125 @@ def fake_execute_and_validate_inputs(method: bytes, url: str, params: Dict[str, def get_client_with_body_from_file(self, path: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200): body = read_data_from_binary_file(path) return self.get_client_with_body(body, expected_method, expected_url, expected_params, expected_json, status_code) + + def test_upload_retention_message(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + {'header': 'Test Header', 'body': 'Test message body', 'image': None}) + + retention_message_request = UploadMessageRequestBody( + header='Test Header', + body='Test message body' + ) + + # Should not raise exception for successful upload + client.upload_retention_message('test-message-id', retention_message_request) + + def test_upload_retention_message_with_image(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + { + 'header': 'Test Header', + 'body': 'Test message body', + 'image': { + 'imageIdentifier': 'test-image-id', + 'altText': 'Test image' + } + }) + + retention_message_request = UploadMessageRequestBody( + header='Test Header', + body='Test message body', + image=UploadMessageImage( + imageIdentifier='test-image-id', + altText='Test image' + ) + ) + + # Should not raise exception for successful upload + client.upload_retention_message('test-message-id', retention_message_request) + + def test_get_retention_message_list(self): + client = self.get_client_with_body_from_file('tests/resources/models/getRetentionMessageListResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/message/list', + {}, + None) + + response = client.get_retention_message_list() + + self.assertIsNotNone(response) + self.assertIsNotNone(response.messageIdentifiers) + self.assertEqual(3, len(response.messageIdentifiers)) + + # Check first message + message1 = response.messageIdentifiers[0] + self.assertEqual('test-message-1', message1.messageIdentifier) + self.assertEqual(RetentionMessageState.APPROVED, message1.messageState) + + # Check second message + message2 = response.messageIdentifiers[1] + self.assertEqual('test-message-2', message2.messageIdentifier) + self.assertEqual(RetentionMessageState.PENDING, message2.messageState) + + # Check third message + message3 = response.messageIdentifiers[2] + self.assertEqual('test-message-3', message3.messageIdentifier) + self.assertEqual(RetentionMessageState.REJECTED, message3.messageState) + + def test_delete_retention_message(self): + client = self.get_client_with_body(b'', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + None) + + # Should not raise exception for successful deletion + client.delete_retention_message('test-message-id') + + def test_upload_retention_message_already_exists_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/retentionMessageAlreadyExistsError.json', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + {'header': 'Test Header', 'body': 'Test message body', 'image': None}, + 409) + + retention_message_request = UploadMessageRequestBody( + header='Test Header', + body='Test message body' + ) + + try: + client.upload_retention_message('test-message-id', retention_message_request) + except APIException as e: + self.assertEqual(409, e.http_status_code) + self.assertEqual(4090001, e.raw_api_error) + self.assertEqual(APIError.MESSAGE_ALREADY_EXISTS_ERROR, e.api_error) + self.assertEqual("An error that indicates the message identifier already exists.", e.error_message) + return + + self.assertFalse(True) + + def test_delete_retention_message_not_found_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/retentionMessageNotFoundError.json', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/message/nonexistent-message-id', + {}, + None, + 404) + + try: + client.delete_retention_message('nonexistent-message-id') + except APIException as e: + self.assertEqual(404, e.http_status_code) + self.assertEqual(4040001, e.raw_api_error) + self.assertEqual(APIError.MESSAGE_NOT_FOUND_ERROR, e.api_error) + self.assertEqual("An error that indicates the specified message was not found.", e.error_message) + return + + self.assertFalse(True) diff --git a/tests/test_api_client_async.py b/tests/test_api_client_async.py index 1114001f..74ded647 100644 --- a/tests/test_api_client_async.py +++ b/tests/test_api_client_async.py @@ -40,6 +40,9 @@ from appstoreserverlibrary.models.Type import Type from appstoreserverlibrary.models.UpdateAppAccountTokenRequest import UpdateAppAccountTokenRequest from appstoreserverlibrary.models.UserStatus import UserStatus +from appstoreserverlibrary.models.UploadMessageRequestBody import UploadMessageRequestBody +from appstoreserverlibrary.models.UploadMessageImage import UploadMessageImage +from appstoreserverlibrary.models.RetentionMessageState import RetentionMessageState from tests.util import decode_json_from_signed_date, read_data_from_binary_file, read_data_from_file @@ -595,3 +598,125 @@ def get_client_with_body_from_file(self, path: str, expected_method: str, expect body = read_data_from_binary_file(path) return self.get_client_with_body(body, expected_method, expected_url, expected_params, expected_json, status_code) + async def test_upload_retention_message(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + {'header': 'Test Header', 'body': 'Test message body', 'image': None}) + + retention_message_request = UploadMessageRequestBody( + header='Test Header', + body='Test message body' + ) + + # Should not raise exception for successful upload + await client.upload_retention_message('test-message-id', retention_message_request) + + async def test_upload_retention_message_with_image(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + { + 'header': 'Test Header', + 'body': 'Test message body', + 'image': { + 'imageIdentifier': 'test-image-id', + 'altText': 'Test image' + } + }) + + retention_message_request = UploadMessageRequestBody( + header='Test Header', + body='Test message body', + image=UploadMessageImage( + imageIdentifier='test-image-id', + altText='Test image' + ) + ) + + # Should not raise exception for successful upload + await client.upload_retention_message('test-message-id', retention_message_request) + + async def test_get_retention_message_list(self): + client = self.get_client_with_body_from_file('tests/resources/models/getRetentionMessageListResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/message/list', + {}, + None) + + response = await client.get_retention_message_list() + + self.assertIsNotNone(response) + self.assertIsNotNone(response.messageIdentifiers) + self.assertEqual(3, len(response.messageIdentifiers)) + + # Check first message + message1 = response.messageIdentifiers[0] + self.assertEqual('test-message-1', message1.messageIdentifier) + self.assertEqual(RetentionMessageState.APPROVED, message1.messageState) + + # Check second message + message2 = response.messageIdentifiers[1] + self.assertEqual('test-message-2', message2.messageIdentifier) + self.assertEqual(RetentionMessageState.PENDING, message2.messageState) + + # Check third message + message3 = response.messageIdentifiers[2] + self.assertEqual('test-message-3', message3.messageIdentifier) + self.assertEqual(RetentionMessageState.REJECTED, message3.messageState) + + async def test_delete_retention_message(self): + client = self.get_client_with_body(b'', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + None) + + # Should not raise exception for successful deletion + await client.delete_retention_message('test-message-id') + + async def test_upload_retention_message_already_exists_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/retentionMessageAlreadyExistsError.json', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + {'header': 'Test Header', 'body': 'Test message body', 'image': None}, + 409) + + retention_message_request = UploadMessageRequestBody( + header='Test Header', + body='Test message body' + ) + + try: + await client.upload_retention_message('test-message-id', retention_message_request) + except APIException as e: + self.assertEqual(409, e.http_status_code) + self.assertEqual(4090001, e.raw_api_error) + self.assertEqual(APIError.MESSAGE_ALREADY_EXISTS_ERROR, e.api_error) + self.assertEqual("An error that indicates the message identifier already exists.", e.error_message) + return + + self.assertFalse(True) + + async def test_delete_retention_message_not_found_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/retentionMessageNotFoundError.json', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/message/nonexistent-message-id', + {}, + None, + 404) + + try: + await client.delete_retention_message('nonexistent-message-id') + except APIException as e: + self.assertEqual(404, e.http_status_code) + self.assertEqual(4040001, e.raw_api_error) + self.assertEqual(APIError.MESSAGE_NOT_FOUND_ERROR, e.api_error) + self.assertEqual("An error that indicates the specified message was not found.", e.error_message) + return + + self.assertFalse(True) +