diff --git a/.claude/hooks/lint-python.py b/.claude/hooks/lint-python.py new file mode 100755 index 0000000..af3574d --- /dev/null +++ b/.claude/hooks/lint-python.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Claude Code Hook: Python 파일 자동 lint/fix +- ruff check --fix: 린트 오류 자동 수정 +- ruff format: 코드 포매팅 +- ty check: 타입 체크 (오류 시 exit code 1로 Claude에게 수정 요청) +""" +import json +import os +import subprocess +import sys + + +def main(): + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError: + return 0 + + file_path = input_data.get("tool_input", {}).get("file_path", "") + + # Python 파일만 처리 + if not file_path or not file_path.endswith(".py"): + return 0 + + project_dir = os.environ.get("CLAUDE_PROJECT_DIR", "") + if not project_dir: + return 0 + + os.chdir(project_dir) + + # 1. ruff check --fix + subprocess.run( + ["ruff", "check", "--fix", file_path], + capture_output=True, + text=True, + timeout=30, + ) + + # 2. ruff format + subprocess.run( + ["ruff", "format", file_path], + capture_output=True, + text=True, + timeout=30, + ) + + # 3. ty check (타입 오류 시 차단) + result = subprocess.run( + ["ty", "check", file_path], + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode != 0: + output = result.stdout.strip() or result.stderr.strip() + if output: + print(f"ty type error:\n{output}", file=sys.stderr) + return 1 # 타입 오류 시 Hook 실패 → Claude가 수정하도록 함 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9f9706d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/lint-python.py\"" + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index 118a46a..8d3bfd2 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ debug # ruff .ruff_cache/ + +# omc +.omc/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bffc9c5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,156 @@ +# SOLAPI-PYTHON KNOWLEDGE BASE + +**Generated:** 2026-01-21 +**Commit:** b77fdd9 +**Branch:** main + +## OVERVIEW + +Python SDK for SOLAPI messaging platform. Sends SMS/LMS/MMS/Kakao/Naver/RCS messages in Korea. Thin wrapper around REST API using httpx + Pydantic v2. + +## STRUCTURE + +``` +solapi-python/ +├── solapi/ # Main package (single export: SolapiMessageService) +│ ├── services/ # message_service.py - all API operations +│ ├── model/ # Pydantic models (see solapi/model/AGENTS.md) +│ ├── lib/ # authenticator.py, fetcher.py +│ └── error/ # MessageNotReceivedError only +├── tests/ # pytest integration tests +├── examples/ # Feature-based usage examples +└── debug/ # Dev test scripts (not part of package) +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +|------|----------|-------| +| Send messages | `solapi/services/message_service.py` | All 10 API methods in single class | +| Request models | `solapi/model/request/` | Pydantic BaseModel with validators | +| Response models | `solapi/model/response/` | Separate from request models | +| Kakao/Naver/RCS | `solapi/model/{kakao,naver,rcs}/` | Domain-specific models | +| Authentication | `solapi/lib/authenticator.py` | HMAC-SHA256 signature | +| HTTP client | `solapi/lib/fetcher.py` | httpx with 3 retries | +| Test fixtures | `tests/conftest.py` | env-based credentials | +| Usage examples | `examples/simple/` | Copy-paste ready | + +## CONVENTIONS + +### Pydantic Everywhere +- ALL models extend `BaseModel` +- Field aliases: `Field(alias="camelCase")` for API compatibility +- Validators: `@field_validator` for normalization (e.g., phone numbers) + +### Model Organization (Domain-Driven) +``` +model/ +├── request/ # Outbound API payloads +├── response/ # Inbound API responses +├── kakao/ # Kakao-specific (option, button) +├── naver/ # Naver-specific +├── rcs/ # RCS-specific +└── webhook/ # Delivery reports +``` + +### Naming +- Files: `snake_case.py` +- Classes: `PascalCase` +- Request suffix: `*Request` (e.g., `SendMessageRequest`) +- Response suffix: `*Response` (e.g., `SendMessageResponse`) + +### Code Style (Ruff) +- Line length: 88 +- Quote style: double +- Import sorting: isort (I rule) +- Target: Python 3.9+ + +### Tidy First Principles +- Never mix refactoring and feature changes in the same commit +- Tidy related code before making behavioral changes +- Tidying: guard clauses, dead code removal, rename, extract conditionals +- Separate tidying commits from feature commits + +## ANTI-PATTERNS (THIS PROJECT) + +### NEVER +- Add CLI/console scripts - this is library-only +- Create multiple service classes - all goes in `SolapiMessageService` +- Mix request/response models - they're deliberately separate +- Use dataclasses or TypedDict for API models - Pydantic only +- Hardcode credentials - use env vars + +### VERSION SYNC REQUIRED +```python +# solapi/model/request/__init__.py +VERSION = "python/5.0.3" # MUST update on every release! +``` +Also update `pyproject.toml` version. + +## UNIQUE PATTERNS + +### Single Service Class +```python +# All API methods in one class (318 lines) +class SolapiMessageService: + def send(...) # SMS/LMS/MMS/Kakao/Naver/RCS + def upload_file(...) # Storage + def get_balance(...) # Account + def get_groups(...) # Message groups + def get_messages(...) # Message history + def cancel_scheduled_message(...) +``` + +### Minimal Error Handling +- Only `MessageNotReceivedError` exists +- API errors raised as generic `Exception` with errorCode, errorMessage + +### Authentication Flow +``` +SolapiMessageService.__init__(api_key, api_secret) + → Authenticator.get_auth_info() + → HMAC-SHA256 signature + → Authorization header +``` + +## COMMANDS + +```bash +# Install +pip install solapi + +# Dev setup +pip install -e ".[dev]" + +# Lint & format +ruff check --fix . +ruff format . + +# Test (requires env vars) +export SOLAPI_API_KEY="..." +export SOLAPI_API_SECRET="..." +export SOLAPI_SENDER="..." +export SOLAPI_RECIPIENT="..." +pytest + +# Build +python -m build +``` + +## ENV VARS (Testing) + +| Variable | Purpose | +|----------|---------| +| `SOLAPI_API_KEY` | API authentication | +| `SOLAPI_API_SECRET` | API authentication | +| `SOLAPI_SENDER` | Registered sender number | +| `SOLAPI_RECIPIENT` | Test recipient number | +| `SOLAPI_KAKAO_PF_ID` | Kakao business channel | +| `SOLAPI_KAKAO_TEMPLATE_ID` | Kakao template | + +## NOTES + +- No CI/CD pipeline - testing/linting is local only +- uv workspace includes Django webhook example +- Tests are integration tests (hit real API) +- Korean comments in some files (i18n TODO exists) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..948123a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,124 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Python SDK for SOLAPI messaging platform. Sends SMS/LMS/MMS/Kakao/Naver/RCS messages in Korea. Thin wrapper around REST API using httpx + Pydantic v2. + +## Commands + +```bash +# Dev setup +pip install -e ".[dev]" + +# Lint & format +ruff check --fix . +ruff format . + +# Test (requires env vars - see below) +pytest +pytest tests/test_balance.py # Single file +pytest -v # Verbose + +# Build +python -m build +``` + +## Testing Environment Variables + +Tests are integration tests that hit the real API: + +| Variable | Purpose | +|----------|---------| +| `SOLAPI_API_KEY` | API authentication | +| `SOLAPI_API_SECRET` | API authentication | +| `SOLAPI_SENDER` | Registered sender number | +| `SOLAPI_RECIPIENT` | Test recipient number | +| `SOLAPI_KAKAO_PF_ID` | Kakao business channel | +| `SOLAPI_KAKAO_TEMPLATE_ID` | Kakao template | + +## Architecture + +### Package Structure +``` +solapi/ +├── services/ # message_service.py - single SolapiMessageService class +├── model/ # Pydantic models (see solapi/model/AGENTS.md) +│ ├── request/ # Outbound API payloads +│ ├── response/ # Inbound API responses (deliberately separate) +│ ├── kakao/ # Kakao channel models +│ ├── naver/ # Naver channel models +│ ├── rcs/ # RCS channel models +│ └── webhook/ # Delivery reports +├── lib/ # authenticator.py, fetcher.py +└── error/ # MessageNotReceivedError only +``` + +### Key Design Decisions + +**Single Service Class**: All 10 API methods live in `SolapiMessageService` - do not create additional service classes. + +**Request/Response Separation**: Request and response models are deliberately separate and should never be shared, even for similar fields. + +**Pydantic Everywhere**: All API models use Pydantic BaseModel with field aliases for camelCase API compatibility: +```python +pf_id: str = Field(alias="pfId") +``` + +**Phone Number Normalization**: Use `@field_validator` to strip dashes from phone numbers. + +### Version Sync Required + +When releasing, update version in BOTH locations: +- `pyproject.toml` → `version = "X.Y.Z"` +- `solapi/model/request/__init__.py` → `VERSION = "python/X.Y.Z"` + +## Code Style + +- **Linter**: Ruff (line-length: 88, double quotes, isort) +- **Target**: Python 3.9+ +- **Files**: `snake_case.py` +- **Classes**: `PascalCase` +- **Request/Response suffixes**: `*Request`, `*Response` + +## Tidy First Principles + +Follow Kent Beck's "Tidy First?" principles: + +### Separate Changes +- Never mix **structural changes** (refactoring) with **behavioral changes** (features/fixes) in the same commit +- Order: tidying commit → feature commit + +### Tidy First +Tidy the relevant code area before making behavioral changes: +- Use guard clauses to reduce nesting +- Remove dead code +- Rename for clarity +- Extract complex conditionals + +### Small Steps +- Keep tidying changes small and safe +- One tidying per commit +- Maintain passing tests + +## Key Locations + +| Task | Location | +|------|----------| +| Send messages | `solapi/services/message_service.py` | +| Request models | `solapi/model/request/` | +| Response models | `solapi/model/response/` | +| Kakao/Naver/RCS | `solapi/model/{kakao,naver,rcs}/` | +| Authentication | `solapi/lib/authenticator.py` | +| HTTP client | `solapi/lib/fetcher.py` | +| Test fixtures | `tests/conftest.py` | +| Usage examples | `examples/simple/` | + +## Anti-Patterns + +- Do not add CLI/console scripts - this is library-only +- Do not create multiple service classes +- Do not mix request/response models +- Do not use dataclasses or TypedDict for API models - Pydantic only +- Do not hardcode credentials diff --git a/examples/images/example_square.jpg b/examples/images/example_square.jpg new file mode 100644 index 0000000..8598047 Binary files /dev/null and b/examples/images/example_square.jpg differ diff --git a/examples/images/example_wide.jpg b/examples/images/example_wide.jpg new file mode 100644 index 0000000..f7af943 Binary files /dev/null and b/examples/images/example_wide.jpg differ diff --git a/examples/simple/send_bms_free_carousel_commerce.py b/examples/simple/send_bms_free_carousel_commerce.py new file mode 100644 index 0000000..57c8d0e --- /dev/null +++ b/examples/simple/send_bms_free_carousel_commerce.py @@ -0,0 +1,121 @@ +""" +카카오 BMS 자유형 CAROUSEL_COMMERCE 타입 발송 예제 +캐러셀 커머스 형식으로, 여러 상품을 슬라이드로 보여주는 구조입니다. +이미지 업로드 시 fileType은 'BMS_CAROUSEL_COMMERCE_LIST'를 사용해야 합니다. (2:1 비율 이미지 필수) +head + list(상품카드들) + tail 구조입니다. +head 없이 2-6개 아이템, head 포함 시 1-5개 아이템 가능합니다. +가격 정보(regularPrice, discountPrice, discountRate, discountFixed)는 숫자 타입입니다. +캐러셀 커머스 버튼은 WL, AL 타입만 지원합니다. +쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from pathlib import Path + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import ( + BmsAppButton, + BmsCarouselCommerceItem, + BmsCarouselCommerceSchema, + BmsCarouselHead, + BmsCarouselTail, + BmsCommerce, + BmsCoupon, + BmsWebButton, +) +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + file_response = message_service.upload_file( + file_path=str(Path(__file__).parent / "../images/example_wide.jpg"), + upload_type=FileTypeEnum.BMS_CAROUSEL_COMMERCE_LIST, + ) + image_id = file_response.file_id + print(f"파일 업로드 성공! File ID: {image_id}") + + message = RequestMessage( + from_="발신번호", + to="수신번호", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="CAROUSEL_COMMERCE", + adult=False, + additional_content="🔥 이번 주 한정 특가!", + carousel=BmsCarouselCommerceSchema( + head=BmsCarouselHead( + header="홍길동님을 위한 추천", + content="최근 관심 상품과 비슷한 아이템을 모았어요!", + image_id=image_id, + link_mobile="https://example.com/recommend", + ), + items=[ + BmsCarouselCommerceItem( + image_id=image_id, + commerce=BmsCommerce( + title="에어프라이어 대용량 5.5L", + regular_price=159000, + discount_price=119000, + discount_rate=25, + ), + additional_content="⚡ 무료배송", + image_link="https://example.com/airfryer", + buttons=[ + BmsWebButton( + name="지금 구매", + link_mobile="https://example.com", + link_pc="https://example.com", + ), + BmsAppButton( + name="앱에서 보기", + link_mobile="https://example.com", + link_android="examplescheme://path", + link_ios="examplescheme://path", + ), + ], + coupon=BmsCoupon( + title="10000원 할인 쿠폰", + description="첫 구매 고객 전용", + link_mobile="https://example.com/coupon", + ), + ), + BmsCarouselCommerceItem( + image_id=image_id, + commerce=BmsCommerce( + title="스마트 로봇청소기 프로", + regular_price=499000, + discount_price=399000, + discount_fixed=100000, + ), + buttons=[ + BmsWebButton( + name="상세 보기", + link_mobile="https://example.com", + link_pc="https://example.com", + ), + ], + ), + ], + tail=BmsCarouselTail( + link_mobile="https://example.com/all-products", + ), + ), + ), + ), + ) + + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_carousel_feed.py b/examples/simple/send_bms_free_carousel_feed.py new file mode 100644 index 0000000..db2a240 --- /dev/null +++ b/examples/simple/send_bms_free_carousel_feed.py @@ -0,0 +1,115 @@ +""" +카카오 BMS 자유형 CAROUSEL_FEED 타입 발송 예제 +캐러셀 피드 형식으로, 여러 카드를 좌우로 슬라이드하는 구조입니다. +이미지 업로드 시 fileType은 'BMS_CAROUSEL_FEED_LIST'를 사용해야 합니다. (2:1 비율 이미지 필수) +head 없이 2-6개 아이템, head 포함 시 1-5개 아이템 가능합니다. +캐러셀 피드 버튼은 WL, AL 타입만 지원합니다. +쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from pathlib import Path + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import ( + BmsAppButton, + BmsCarouselFeedItem, + BmsCarouselFeedSchema, + BmsCarouselTail, + BmsCoupon, + BmsWebButton, +) +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + file_response = message_service.upload_file( + file_path=str(Path(__file__).parent / "../images/example_wide.jpg"), + upload_type=FileTypeEnum.BMS_CAROUSEL_FEED_LIST, + ) + image_id = file_response.file_id + print(f"파일 업로드 성공! File ID: {image_id}") + + message = RequestMessage( + from_="발신번호", + to="수신번호", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="CAROUSEL_FEED", + adult=False, + carousel=BmsCarouselFeedSchema( + items=[ + BmsCarouselFeedItem( + header="🏃 마라톤 완주 도전!", + content="첫 마라톤 완주를 목표로 8주 트레이닝 프로그램을 시작해보세요.", + image_id=image_id, + image_link="https://example.com/marathon", + buttons=[ + BmsWebButton( + name="프로그램 신청", + link_mobile="https://example.com", + link_pc="https://example.com", + ), + BmsAppButton( + name="앱에서 보기", + link_mobile="https://example.com", + link_android="examplescheme://path", + link_ios="examplescheme://path", + ), + ], + coupon=BmsCoupon( + title="10% 할인 쿠폰", + description="첫 등록 고객 전용", + link_mobile="https://example.com/coupon", + ), + ), + BmsCarouselFeedItem( + header="🧘 요가 입문 클래스", + content="초보자를 위한 기초 요가 동작을 배워보세요. 유연성과 마음의 평화를 함께!", + image_id=image_id, + buttons=[ + BmsWebButton( + name="클래스 보기", + link_mobile="https://example.com", + link_pc="https://example.com", + ), + ], + ), + BmsCarouselFeedItem( + header="💪 홈트레이닝 루틴", + content="장비 없이도 OK! 집에서 하는 30분 전신 운동 루틴.", + image_id=image_id, + buttons=[ + BmsAppButton( + name="영상 시청", + link_mobile="https://example.com", + link_android="examplescheme://path", + link_ios="examplescheme://path", + ), + ], + ), + ], + tail=BmsCarouselTail( + link_mobile="https://example.com/more", + link_pc="https://example.com/more", + ), + ), + ), + ), + ) + + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_commerce.py b/examples/simple/send_bms_free_commerce.py new file mode 100644 index 0000000..9a9d14d --- /dev/null +++ b/examples/simple/send_bms_free_commerce.py @@ -0,0 +1,81 @@ +""" +카카오 BMS 자유형 COMMERCE 타입 발송 예제 +커머스(상품) 메시지로, 상품 이미지와 가격 정보, 쿠폰을 포함합니다. +이미지 업로드 시 fileType은 'BMS'를 사용해야 합니다. (2:1 비율 이미지 권장) +COMMERCE 타입은 buttons가 필수입니다 (최소 1개). +가격 정보(regularPrice, discountPrice, discountRate, discountFixed)는 숫자 타입입니다. +쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from pathlib import Path + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import ( + BmsAppButton, + BmsCommerce, + BmsCoupon, + BmsWebButton, +) +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + file_response = message_service.upload_file( + file_path=str(Path(__file__).parent / "../images/example_wide.jpg"), + upload_type=FileTypeEnum.BMS, + ) + print(f"파일 업로드 성공! File ID: {file_response.file_id}") + + message = RequestMessage( + from_="발신번호", + to="수신번호", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="COMMERCE", + adult=False, + additional_content="🚀 오늘 주문 시 내일 도착! 무료배송", + image_id=file_response.file_id, + commerce=BmsCommerce( + title="스마트 공기청정기 2024 신형", + regular_price=299000, + discount_price=209000, + discount_rate=30, + ), + buttons=[ + BmsWebButton( + name="지금 구매하기", + link_mobile="https://example.com", + link_pc="https://example.com", + ), + BmsAppButton( + name="앱에서 보기", + link_mobile="https://example.com", + link_android="examplescheme://path", + link_ios="examplescheme://path", + ), + ], + coupon=BmsCoupon( + title="포인트 UP 쿠폰", + description="구매 시 2배 적립", + link_mobile="https://example.com/coupon", + ), + ), + ), + ) + + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_image.py b/examples/simple/send_bms_free_image.py new file mode 100644 index 0000000..f2c9e1c --- /dev/null +++ b/examples/simple/send_bms_free_image.py @@ -0,0 +1,47 @@ +""" +카카오 BMS 자유형 IMAGE 타입 발송 예제 +이미지 업로드 후 imageId를 사용하여 발송합니다. +이미지 업로드 시 fileType은 반드시 'BMS'를 사용해야 합니다. +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from pathlib import Path + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + file_response = message_service.upload_file( + file_path=str(Path(__file__).parent / "../images/example_square.jpg"), + upload_type=FileTypeEnum.BMS, + ) + print(f"파일 업로드 성공! File ID: {file_response.file_id}") + + message = RequestMessage( + from_="발신번호", + to="수신번호", + text="🆕 신상품이 입고되었어요!\n지금 바로 확인해보세요.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="IMAGE", + image_id=file_response.file_id, + ), + ), + ) + + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_image_with_buttons.py b/examples/simple/send_bms_free_image_with_buttons.py new file mode 100644 index 0000000..28ba55f --- /dev/null +++ b/examples/simple/send_bms_free_image_with_buttons.py @@ -0,0 +1,76 @@ +""" +카카오 BMS 자유형 IMAGE 타입 + 버튼 발송 예제 +이미지 업로드 후 imageId를 사용하여 버튼과 함께 발송합니다. +이미지 업로드 시 fileType은 반드시 'BMS'를 사용해야 합니다. +BMS 자유형 버튼 타입: WL(웹링크), AL(앱링크), AC(채널추가), BK(봇키워드), MD(상담요청), BC(상담톡전환), BT(챗봇전환), BF(비즈니스폼) +쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from pathlib import Path + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import ( + BmsAppButton, + BmsChannelAddButton, + BmsCoupon, + BmsWebButton, +) +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + file_response = message_service.upload_file( + file_path=str(Path(__file__).parent / "../images/example_square.jpg"), + upload_type=FileTypeEnum.BMS, + ) + print(f"파일 업로드 성공! File ID: {file_response.file_id}") + + message = RequestMessage( + from_="발신번호", + to="수신번호", + text="🎁 연말 감사 이벤트!\n\n한 해 동안 함께해주셔서 감사합니다.\n특별한 혜택으로 보답드려요!", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="IMAGE", + adult=False, + image_id=file_response.file_id, + image_link="https://example.com/year-end-event", + buttons=[ + BmsWebButton( + name="이벤트 참여하기", + link_mobile="https://example.com", + link_pc="https://example.com", + ), + BmsAppButton( + name="앱에서 보기", + link_mobile="https://example.com", + link_android="examplescheme://path", + link_ios="examplescheme://path", + ), + BmsChannelAddButton(name="채널 추가"), + ], + coupon=BmsCoupon( + title="10000원 할인 쿠폰", + description="연말 감사 할인", + link_mobile="https://example.com/coupon", + ), + ), + ), + ) + + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_premium_video.py b/examples/simple/send_bms_free_premium_video.py new file mode 100644 index 0000000..4406160 --- /dev/null +++ b/examples/simple/send_bms_free_premium_video.py @@ -0,0 +1,92 @@ +""" +카카오 BMS 자유형 PREMIUM_VIDEO 타입 발송 예제 +프리미엄 비디오 메시지로, 카카오TV 영상 URL과 썸네일 이미지를 포함합니다. +videoUrl은 반드시 "https://tv.kakao.com/"으로 시작해야 합니다. +유효하지 않은 동영상 URL 기입 시 발송 상태가 그룹 정보를 찾을 수 없음 오류로 표시됩니다. +쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from pathlib import Path + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import BmsCoupon, BmsVideo, BmsWebButton +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +message = RequestMessage( + from_="발신번호", + to="수신번호", + text="🎬 이번 시즌 인기 드라마 하이라이트!\n놓치신 분들을 위한 명장면 모음입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="PREMIUM_VIDEO", + video=BmsVideo( + video_url="https://tv.kakao.com/v/460734285", + ), + ), + ), +) + +try: + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") + +try: + file_response = message_service.upload_file( + file_path=str(Path(__file__).parent / "../images/example_square.jpg"), + upload_type=FileTypeEnum.KAKAO, + ) + + full_message = RequestMessage( + from_="발신번호", + to="수신번호", + text="🍿 주말 영화 추천!\n\n올해 가장 화제가 된 영화를 미리 만나보세요.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="PREMIUM_VIDEO", + adult=False, + header="🎥 이 주의 추천 영화", + content="2024년 최고의 액션 블록버스터! 지금 바로 예고편을 확인해보세요.", + video=BmsVideo( + video_url="https://tv.kakao.com/v/460734285", + image_id=file_response.file_id, + image_link="https://example.com/movie-trailer", + ), + buttons=[ + BmsWebButton( + name="예매하기", + link_mobile="https://example.com", + link_pc="https://example.com", + ), + ], + coupon=BmsCoupon( + title="10% 할인 쿠폰", + description="영화 예매 시 할인", + link_mobile="https://example.com/coupon", + ), + ), + ), + ) + + response = message_service.send(full_message) + print("\n전체 필드 메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") +except Exception as e: + print(f"전체 필드 메시지 발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_text.py b/examples/simple/send_bms_free_text.py new file mode 100644 index 0000000..4243d03 --- /dev/null +++ b/examples/simple/send_bms_free_text.py @@ -0,0 +1,95 @@ +""" +카카오 BMS 자유형 TEXT 타입 발송 예제 +텍스트 전용 메시지로, 가장 기본적인 형태입니다. +targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. +그 외의 모든 채널은 I 타입만 사용 가능합니다. +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.message_type import MessageType + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 최소 구조 단건 발송 예제 +message = RequestMessage( + from_="발신번호", + to="수신번호", + text="안녕하세요! BMS 자유형 TEXT 메시지입니다.\n\n오늘 하루도 행복하세요!", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="TEXT", + ), + ), +) + +try: + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") + +# 전체 필드 단건 발송 예제 (adult, additionalContent 포함) +full_message = RequestMessage( + from_="발신번호", + to="수신번호", + text="🎉 회원님, 특별한 소식이 있습니다!\n\n이번 주말 단독 할인 이벤트가 진행됩니다.\n자세한 내용은 아래 버튼을 눌러 확인해주세요.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="TEXT", + adult=False, + additional_content="📅 이벤트 기간: 12월 1일 ~ 12월 7일", + ), + ), +) + +try: + response = message_service.send(full_message) + print("\n전체 필드 메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") +except Exception as e: + print(f"전체 필드 메시지 발송 실패: {str(e)}") + +# 다건 발송 예제 +messages = [ + RequestMessage( + from_="발신번호", + to="수신번호1", + text="첫 번째 수신자에게 보내는 BMS 메시지입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms(targeting="I", chat_bubble_type="TEXT"), + ), + ), + RequestMessage( + from_="발신번호", + to="수신번호2", + text="두 번째 수신자에게 보내는 BMS 메시지입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms(targeting="I", chat_bubble_type="TEXT"), + ), + ), +] + +try: + response = message_service.send(messages) + print("\n다건 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"총 메시지 개수: {response.group_info.count.total}") +except Exception as e: + print(f"다건 발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_text_with_buttons.py b/examples/simple/send_bms_free_text_with_buttons.py new file mode 100644 index 0000000..2ce6ebc --- /dev/null +++ b/examples/simple/send_bms_free_text_with_buttons.py @@ -0,0 +1,62 @@ +""" +카카오 BMS 자유형 TEXT 타입 + 버튼 발송 예제 +텍스트와 버튼을 포함한 메시지입니다. +BMS 자유형 버튼 타입: WL(웹링크), AL(앱링크), AC(채널추가), BK(봇키워드), MD(상담요청), BC(상담톡전환), BT(챗봇전환), BF(비즈니스폼) +쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import ( + BmsAppButton, + BmsBotKeywordButton, + BmsChannelAddButton, + BmsCoupon, + BmsWebButton, +) +from solapi.model.message_type import MessageType + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +message = RequestMessage( + from_="발신번호", + to="수신번호", + text="🎁 연말 감사 이벤트!\n\n한 해 동안 함께해주셔서 감사합니다.\n특별한 혜택으로 보답드려요!", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="TEXT", + adult=False, + buttons=[ + BmsWebButton(name="이벤트 참여하기", link_mobile="https://example.com"), + BmsAppButton( + name="앱에서 보기", + link_mobile="https://example.com", + link_android="examplescheme://path", + link_ios="examplescheme://path", + ), + BmsChannelAddButton(name="채널 추가"), + BmsBotKeywordButton(name="이벤트 문의", chat_extra="event_inquiry"), + ], + coupon=BmsCoupon( + title="10000원 할인 쿠폰", + description="연말 감사 할인 쿠폰입니다.", + link_mobile="https://example.com/coupon", + ), + ), + ), +) + +try: + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_wide.py b/examples/simple/send_bms_free_wide.py new file mode 100644 index 0000000..093682e --- /dev/null +++ b/examples/simple/send_bms_free_wide.py @@ -0,0 +1,54 @@ +""" +카카오 BMS 자유형 WIDE 타입 발송 예제 +와이드 이미지를 사용하는 메시지입니다. +이미지 업로드 시 fileType은 'BMS_WIDE'를 사용해야 합니다. (2:1 비율 이미지 권장) +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from pathlib import Path + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import BmsWebButton +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + file_response = message_service.upload_file( + file_path=str(Path(__file__).parent / "../images/example_wide.jpg"), + upload_type=FileTypeEnum.BMS_WIDE, + ) + print(f"파일 업로드 성공! File ID: {file_response.file_id}") + + message = RequestMessage( + from_="발신번호", + to="수신번호", + text="✨ 이번 시즌 신상품을 만나보세요!\n\n트렌디한 스타일로 가을을 준비하세요.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="WIDE", + image_id=file_response.file_id, + buttons=[ + BmsWebButton( + name="자세히 보기", + link_mobile="https://example.com", + ), + ], + ), + ), + ) + + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_wide_item_list.py b/examples/simple/send_bms_free_wide_item_list.py new file mode 100644 index 0000000..2d46d46 --- /dev/null +++ b/examples/simple/send_bms_free_wide_item_list.py @@ -0,0 +1,84 @@ +""" +카카오 BMS 자유형 WIDE_ITEM_LIST 타입 발송 예제 +와이드 아이템 리스트 형식으로, 메인 아이템(2:1 비율)과 서브 아이템(1:1 비율)으로 구성됩니다. +메인 아이템: fileType은 'BMS_WIDE_MAIN_ITEM_LIST' (2:1 비율 이미지 필수) +서브 아이템: fileType은 'BMS_WIDE_SUB_ITEM_LIST' (1:1 비율 이미지 필수, 최소 3개 필요) +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from pathlib import Path + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import BmsMainWideItem, BmsSubWideItem, BmsWebButton +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + main_file_response = message_service.upload_file( + file_path=str(Path(__file__).parent / "../images/example_wide.jpg"), + upload_type=FileTypeEnum.BMS_WIDE_MAIN_ITEM_LIST, + ) + main_image_id = main_file_response.file_id + print(f"메인 이미지 업로드 성공! File ID: {main_image_id}") + + sub_file_response = message_service.upload_file( + file_path=str(Path(__file__).parent / "../images/example_square.jpg"), + upload_type=FileTypeEnum.BMS_WIDE_SUB_ITEM_LIST, + ) + sub_image_id = sub_file_response.file_id + print(f"서브 이미지 업로드 성공! File ID: {sub_image_id}") + + message = RequestMessage( + from_="발신번호", + to="수신번호", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="WIDE_ITEM_LIST", + header="🏆 베스트 상품 모음", + main_wide_item=BmsMainWideItem( + image_id=main_image_id, + title="이번 주 인기 상품", + link_mobile="https://example.com/main", + ), + sub_wide_item_list=[ + BmsSubWideItem( + image_id=sub_image_id, + title="인기 1위 - 프리미엄 티셔츠", + link_mobile="https://example.com/item1", + ), + BmsSubWideItem( + image_id=sub_image_id, + title="인기 2위 - 캐주얼 팬츠", + link_mobile="https://example.com/item2", + ), + BmsSubWideItem( + image_id=sub_image_id, + title="인기 3위 - 데일리 백", + link_mobile="https://example.com/item3", + ), + ], + buttons=[ + BmsWebButton( + name="전체 상품 보기", + link_mobile="https://example.com", + ), + ], + ), + ), + ) + + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"발송 실패: {str(e)}") diff --git a/pyproject.toml b/pyproject.toml index c3b7b39..4794770 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "solapi" -version = "5.0.2" +version = "5.0.3" description = "SOLAPI SDK for Python" authors = [ { name = "SOLAPI Team", email = "contact@solapi.com" } @@ -33,7 +33,8 @@ dependencies = [ [project.optional-dependencies] dev = [ "ruff>=0.11.0", - "pytest>=7.0.0" + "pytest>=7.0.0", + "ty>=0.0.1" ] [build-system] diff --git a/solapi/model/AGENTS.md b/solapi/model/AGENTS.md new file mode 100644 index 0000000..a8e8daf --- /dev/null +++ b/solapi/model/AGENTS.md @@ -0,0 +1,115 @@ +# SOLAPI MODEL LAYER + +## OVERVIEW + +Pydantic v2 models for SOLAPI REST API. Domain-driven organization by messaging channel. + +## STRUCTURE + +``` +model/ +├── request/ # Outbound payloads +│ ├── message.py # Core Message class +│ ├── send_message_request.py +│ ├── storage.py # File upload +│ ├── kakao/ # Kakao BMS, option +│ ├── voice/ # Voice message +│ ├── messages/ # Get messages query +│ └── groups/ # Get groups query +├── response/ # Inbound payloads +│ ├── send_message_response.py +│ ├── common_response.py +│ ├── storage.py +│ ├── balance/ +│ ├── messages/ +│ └── groups/ +├── kakao/ # Kakao channel models +├── naver/ # Naver channel models +├── rcs/ # RCS channel models +└── webhook/ # Delivery reports +``` + +## WHERE TO LOOK + +| Task | File | Notes | +|------|------|-------| +| Build message payload | `request/message.py` | Main `Message` class | +| Send request wrapper | `request/send_message_request.py` | Wraps messages list | +| Handle send response | `response/send_message_response.py` | Parse API response | +| Kakao options | `kakao/kakao_option.py` | PF ID, template, buttons | +| Naver options | `naver/naver_option.py` | Naver talk settings | +| RCS options | `rcs/rcs_options.py` | RCS specific fields | +| Webhook parsing | `webhook/single_report.py` | Delivery status | + +## CONVENTIONS + +### All Models Are Pydantic +```python +from pydantic import BaseModel, Field + +class Message(BaseModel): + to: str = Field(alias="to") # camelCase alias for API +``` + +### Request vs Response Separation +- NEVER share classes between request/response +- Request: what you send to API +- Response: what API returns +- Even similar fields get separate classes + +### Field Aliases for API +```python +# snake_case in Python, camelCase in JSON +pf_id: str = Field(alias="pfId") +template_id: str = Field(alias="templateId") +``` + +### Validators for Normalization +```python +@field_validator("to", mode="before") +@classmethod +def normalize_phone(cls, v: str) -> str: + return v.replace("-", "") # Strip dashes +``` + +### Optional Fields +```python +# Use Optional with None default +subject: Optional[str] = None +image_id: Optional[str] = Field(default=None, alias="imageId") +``` + +## ANTI-PATTERNS + +### NEVER +- Use TypedDict for API models (Pydantic only) +- Share model between request/response +- Forget alias when API uses camelCase +- Skip validators for phone numbers + +### VERSION IN THIS PACKAGE +```python +# request/__init__.py line 1-2 +VERSION = "python/5.0.3" # Sync with pyproject.toml! +``` + +## KEY CLASSES + +### Request Side +- `Message` - Core message with to, from, text, type +- `SendMessageRequest` - Wrapper with messages list + version +- `SendRequestConfig` - app_id, scheduled_date, allow_duplicates +- `KakaoOption` - Kakao-specific (pfId, templateId, buttons) +- `FileUploadRequest` - Base64 encoded file + +### Response Side +- `SendMessageResponse` - Contains group_info, failed_message_list +- `GroupMessageResponse` - Generic group response +- `GetBalanceResponse` - balance, point fields +- `FileUploadResponse` - fileId for uploaded files + +## NOTES + +- Korean comments exist (i18n TODO) +- Some TODOs for future field additions (kakao button types, group count fields) +- Webhook models for delivery status callbacks diff --git a/solapi/model/kakao/bms/__init__.py b/solapi/model/kakao/bms/__init__.py new file mode 100644 index 0000000..72e6010 --- /dev/null +++ b/solapi/model/kakao/bms/__init__.py @@ -0,0 +1,68 @@ +"""BMS (카카오 브랜드 메시지) 자유형 모델.""" + +from solapi.model.kakao.bms.bms_button import ( + BmsAppButton, + BmsBotKeywordButton, + BmsBotTransferButton, + BmsBusinessFormButton, + BmsButton, + BmsButtonLinkType, + BmsChannelAddButton, + BmsConsultButton, + BmsLinkButton, + BmsMessageDeliveryButton, + BmsWebButton, +) +from solapi.model.kakao.bms.bms_carousel import ( + BmsCarouselCommerceItem, + BmsCarouselCommerceSchema, + BmsCarouselFeedItem, + BmsCarouselFeedSchema, + BmsCarouselHead, + BmsCarouselTail, +) +from solapi.model.kakao.bms.bms_commerce import BmsCommerce +from solapi.model.kakao.bms.bms_coupon import BmsCoupon +from solapi.model.kakao.bms.bms_option import ( + BmsChatBubbleType, + BmsOption, + validate_bms_required_fields, +) +from solapi.model.kakao.bms.bms_video import BmsVideo +from solapi.model.kakao.bms.bms_wide_item import BmsMainWideItem, BmsSubWideItem + +__all__ = [ + # Button types + "BmsButtonLinkType", + "BmsWebButton", + "BmsAppButton", + "BmsChannelAddButton", + "BmsBotKeywordButton", + "BmsMessageDeliveryButton", + "BmsConsultButton", + "BmsBotTransferButton", + "BmsBusinessFormButton", + "BmsButton", + "BmsLinkButton", + # Commerce + "BmsCommerce", + # Coupon + "BmsCoupon", + # Video + "BmsVideo", + # Wide Item + "BmsMainWideItem", + "BmsSubWideItem", + # Carousel + "BmsCarouselHead", + "BmsCarouselTail", + "BmsCarouselFeedItem", + "BmsCarouselFeedSchema", + "BmsCarouselCommerceItem", + "BmsCarouselCommerceSchema", + # Option + "BmsChatBubbleType", + "BmsOption", + # Validation + "validate_bms_required_fields", +] diff --git a/solapi/model/kakao/bms/bms_button.py b/solapi/model/kakao/bms/bms_button.py new file mode 100644 index 0000000..9e0ba81 --- /dev/null +++ b/solapi/model/kakao/bms/bms_button.py @@ -0,0 +1,117 @@ +from typing import Annotated, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, model_validator +from pydantic.alias_generators import to_camel + +BmsButtonLinkType = Literal["AC", "WL", "AL", "BK", "MD", "BC", "BT", "BF"] + + +class BmsWebButton(BaseModel): + """WL: 웹 링크 버튼.""" + + link_type: Literal["WL"] = "WL" + name: str + link_mobile: str + link_pc: Optional[str] = None + target_out: Optional[bool] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsAppButton(BaseModel): + """AL: 앱 링크 버튼. linkMobile, linkAndroid, linkIos 중 하나 이상 필수.""" + + link_type: Literal["AL"] = "AL" + name: str + link_mobile: Optional[str] = None + link_android: Optional[str] = None + link_ios: Optional[str] = None + target_out: Optional[bool] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + @model_validator(mode="after") + def validate_at_least_one_link(self) -> "BmsAppButton": + if not any([self.link_mobile, self.link_android, self.link_ios]): + raise ValueError( + "AL 타입 버튼은 linkMobile, linkAndroid, linkIos 중 하나 이상 필수입니다." + ) + return self + + +class BmsChannelAddButton(BaseModel): + """AC: 채널 추가 버튼.""" + + link_type: Literal["AC"] = "AC" + name: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsBotKeywordButton(BaseModel): + """BK: 봇 키워드 버튼.""" + + link_type: Literal["BK"] = "BK" + name: str + chat_extra: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsMessageDeliveryButton(BaseModel): + """MD: 메시지 전달 버튼.""" + + link_type: Literal["MD"] = "MD" + name: str + chat_extra: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsConsultButton(BaseModel): + """BC: 상담 요청 버튼.""" + + link_type: Literal["BC"] = "BC" + name: str + chat_extra: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsBotTransferButton(BaseModel): + """BT: 봇 전환 버튼.""" + + link_type: Literal["BT"] = "BT" + name: str + chat_extra: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsBusinessFormButton(BaseModel): + """BF: 비즈니스폼 버튼.""" + + link_type: Literal["BF"] = "BF" + name: str + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +BmsButton = Annotated[ + Union[ + BmsWebButton, + BmsAppButton, + BmsChannelAddButton, + BmsBotKeywordButton, + BmsMessageDeliveryButton, + BmsConsultButton, + BmsBotTransferButton, + BmsBusinessFormButton, + ], + "BMS 버튼 통합 타입 (linkType으로 구분)", +] + +BmsLinkButton = Annotated[ + Union[BmsWebButton, BmsAppButton], + "BMS 링크 버튼 (WL, AL만 허용) - 캐러셀 등에서 사용", +] diff --git a/solapi/model/kakao/bms/bms_carousel.py b/solapi/model/kakao/bms/bms_carousel.py new file mode 100644 index 0000000..7e129b9 --- /dev/null +++ b/solapi/model/kakao/bms/bms_carousel.py @@ -0,0 +1,66 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from solapi.model.kakao.bms.bms_button import BmsLinkButton +from solapi.model.kakao.bms.bms_commerce import BmsCommerce +from solapi.model.kakao.bms.bms_coupon import BmsCoupon + + +class BmsCarouselHead(BaseModel): + header: Optional[str] = None + content: Optional[str] = None + image_id: Optional[str] = None + link_mobile: Optional[str] = None + link_pc: Optional[str] = None + link_android: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsCarouselTail(BaseModel): + link_mobile: Optional[str] = None + link_pc: Optional[str] = None + link_android: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsCarouselFeedItem(BaseModel): + header: Optional[str] = None + content: Optional[str] = None + image_id: Optional[str] = None + image_link: Optional[str] = None + buttons: Optional[list[BmsLinkButton]] = None + coupon: Optional[BmsCoupon] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsCarouselFeedSchema(BaseModel): + items: Optional[list[BmsCarouselFeedItem]] = Field(default=None, alias="list") + tail: Optional[BmsCarouselTail] = None + + model_config = ConfigDict(populate_by_name=True) + + +class BmsCarouselCommerceItem(BaseModel): + commerce: Optional[BmsCommerce] = None + image_id: Optional[str] = None + image_link: Optional[str] = None + buttons: Optional[list[BmsLinkButton]] = None + additional_content: Optional[str] = None + coupon: Optional[BmsCoupon] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsCarouselCommerceSchema(BaseModel): + head: Optional[BmsCarouselHead] = None + items: Optional[list[BmsCarouselCommerceItem]] = Field(default=None, alias="list") + tail: Optional[BmsCarouselTail] = None + + model_config = ConfigDict(populate_by_name=True) diff --git a/solapi/model/kakao/bms/bms_commerce.py b/solapi/model/kakao/bms/bms_commerce.py new file mode 100644 index 0000000..aee583f --- /dev/null +++ b/solapi/model/kakao/bms/bms_commerce.py @@ -0,0 +1,67 @@ +from typing import Optional, Union + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from pydantic.alias_generators import to_camel + + +class BmsCommerce(BaseModel): + title: Optional[str] = None + regular_price: Optional[int] = None + discount_price: Optional[int] = None + discount_rate: Optional[int] = None + discount_fixed: Optional[int] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + @field_validator( + "regular_price", + "discount_price", + "discount_rate", + "discount_fixed", + mode="before", + ) + @classmethod + def coerce_to_int(cls, v: Union[int, float, str, None]) -> Optional[int]: + if v is None: + return None + if isinstance(v, str): + v = v.strip() + if not v: + return None + return int(float(v)) + return int(v) + + @model_validator(mode="after") + def validate_price_combination(self) -> "BmsCommerce": + if self.regular_price is None: + return self + + has_discount_price = self.discount_price is not None + has_discount_rate = self.discount_rate is not None + has_discount_fixed = self.discount_fixed is not None + + # 할인 정보 없음 = 유효 + if not any([has_discount_price, has_discount_rate, has_discount_fixed]): + return self + + # discountRate와 discountFixed는 상호 배타적 + if has_discount_rate and has_discount_fixed: + raise ValueError( + "discountRate와 discountFixed는 동시에 사용할 수 없습니다. " + "할인율(discountRate) 또는 정액할인(discountFixed) 중 하나만 선택하세요." + ) + + # 할인 정보는 완전한 세트여야 함 (discountPrice + discountRate/discountFixed) + if has_discount_price != (has_discount_rate or has_discount_fixed): + if has_discount_price: + raise ValueError( + "discountPrice를 사용하려면 discountRate(할인율) 또는 " + "discountFixed(정액할인) 중 하나를 함께 지정해야 합니다." + ) + else: + raise ValueError( + "discountRate 또는 discountFixed를 사용하려면 " + "discountPrice(할인가)도 함께 지정해야 합니다." + ) + + return self diff --git a/solapi/model/kakao/bms/bms_coupon.py b/solapi/model/kakao/bms/bms_coupon.py new file mode 100644 index 0000000..c412e5f --- /dev/null +++ b/solapi/model/kakao/bms/bms_coupon.py @@ -0,0 +1,64 @@ +import re +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator +from pydantic.alias_generators import to_camel + +WON_DISCOUNT_PATTERN = re.compile(r"^([1-9]\d{0,7})원 할인 쿠폰$") +PERCENT_DISCOUNT_PATTERN = re.compile(r"^([1-9]\d?|100)% 할인 쿠폰$") +FREE_COUPON_PATTERN = re.compile(r"^.{1,7} 무료 쿠폰$") +UP_COUPON_PATTERN = re.compile(r"^.{1,7} UP 쿠폰$") + + +def _is_valid_coupon_title(title: str) -> bool: + if title == "배송비 할인 쿠폰": + return True + + won_match = WON_DISCOUNT_PATTERN.match(title) + if won_match: + num = int(won_match.group(1)) + return 1 <= num <= 99_999_999 + + if PERCENT_DISCOUNT_PATTERN.match(title): + return True + + if FREE_COUPON_PATTERN.match(title): + return True + + return bool(UP_COUPON_PATTERN.match(title)) + + +class BmsCoupon(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + link_mobile: Optional[str] = None + link_pc: Optional[str] = None + link_android: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + @field_validator("title") + @classmethod + def validate_coupon_title(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + if not _is_valid_coupon_title(v): + raise ValueError( + "쿠폰 제목은 다음 형식 중 하나여야 합니다: " + '"N원 할인 쿠폰" (1~99999999), ' + '"N% 할인 쿠폰" (1~100), ' + '"배송비 할인 쿠폰", ' + '"OOO 무료 쿠폰" (7자 이내), ' + '"OOO UP 쿠폰" (7자 이내)' + ) + return v + + @field_validator("description") + @classmethod + def validate_coupon_description(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + if len(v) > 12: + raise ValueError("쿠폰 설명은 최대 12자 이하로 입력해주세요.") + return v diff --git a/solapi/model/kakao/bms/bms_option.py b/solapi/model/kakao/bms/bms_option.py new file mode 100644 index 0000000..8e2ceff --- /dev/null +++ b/solapi/model/kakao/bms/bms_option.py @@ -0,0 +1,106 @@ +from typing import Any, Callable, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, model_validator +from pydantic.alias_generators import to_camel + +from solapi.model.kakao.bms.bms_button import BmsButton +from solapi.model.kakao.bms.bms_carousel import ( + BmsCarouselCommerceSchema, + BmsCarouselFeedSchema, +) +from solapi.model.kakao.bms.bms_commerce import BmsCommerce +from solapi.model.kakao.bms.bms_coupon import BmsCoupon +from solapi.model.kakao.bms.bms_video import BmsVideo +from solapi.model.kakao.bms.bms_wide_item import BmsMainWideItem, BmsSubWideItem + +BmsChatBubbleType = Literal[ + "TEXT", + "IMAGE", + "WIDE", + "WIDE_ITEM_LIST", + "COMMERCE", + "CAROUSEL_FEED", + "CAROUSEL_COMMERCE", + "PREMIUM_VIDEO", +] + +BMS_REQUIRED_FIELDS: dict[BmsChatBubbleType, list[str]] = { + "TEXT": [], + "IMAGE": ["image_id"], + "WIDE": ["image_id"], + "WIDE_ITEM_LIST": ["header", "main_wide_item", "sub_wide_item_list"], + "COMMERCE": ["image_id", "commerce", "buttons"], + "CAROUSEL_FEED": ["carousel"], + "CAROUSEL_COMMERCE": ["carousel"], + "PREMIUM_VIDEO": ["video"], +} + +WIDE_ITEM_LIST_MIN_SUB_ITEMS = 3 + + +def _to_camel(s: str) -> str: + components = s.split("_") + return components[0] + "".join(x.title() for x in components[1:]) + + +def validate_bms_required_fields( + chat_bubble_type: Optional[BmsChatBubbleType], + sub_wide_item_list: Optional[list], + get_field_value: Callable[[str], Any], +) -> None: + if chat_bubble_type is None: + return + + required_fields = BMS_REQUIRED_FIELDS.get(chat_bubble_type, []) + missing_fields = [ + field for field in required_fields if get_field_value(field) is None + ] + + if missing_fields: + camel_fields = [_to_camel(f) for f in missing_fields] + raise ValueError( + f"BMS {chat_bubble_type} 타입에 필수 필드가 누락되었습니다: " + f"{', '.join(camel_fields)}" + ) + + if chat_bubble_type == "WIDE_ITEM_LIST": + if ( + not sub_wide_item_list + or len(sub_wide_item_list) < WIDE_ITEM_LIST_MIN_SUB_ITEMS + ): + raise ValueError( + f"WIDE_ITEM_LIST 타입의 subWideItemList는 최소 " + f"{WIDE_ITEM_LIST_MIN_SUB_ITEMS}개 이상이어야 합니다. " + f"현재: {len(sub_wide_item_list) if sub_wide_item_list else 0}개" + ) + + +class BmsOption(BaseModel): + targeting: Literal["I", "M", "N"] + chat_bubble_type: BmsChatBubbleType + + adult: Optional[bool] = None + header: Optional[str] = None + image_id: Optional[str] = None + image_link: Optional[str] = None + additional_content: Optional[str] = None + content: Optional[str] = None + + carousel: Optional[Union[BmsCarouselFeedSchema, BmsCarouselCommerceSchema]] = None + main_wide_item: Optional[BmsMainWideItem] = None + sub_wide_item_list: Optional[list[BmsSubWideItem]] = None + buttons: Optional[list[BmsButton]] = None + coupon: Optional[BmsCoupon] = None + commerce: Optional[BmsCommerce] = None + video: Optional[BmsVideo] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + @model_validator(mode="after") + def validate_required_fields(self) -> "BmsOption": + validate_bms_required_fields( + chat_bubble_type=self.chat_bubble_type, + sub_wide_item_list=self.sub_wide_item_list, + get_field_value=lambda field: getattr(self, field, None), + ) + return self diff --git a/solapi/model/kakao/bms/bms_video.py b/solapi/model/kakao/bms/bms_video.py new file mode 100644 index 0000000..4e6eecb --- /dev/null +++ b/solapi/model/kakao/bms/bms_video.py @@ -0,0 +1,24 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator +from pydantic.alias_generators import to_camel + +KAKAO_TV_URL_PREFIX = "https://tv.kakao.com/" + + +class BmsVideo(BaseModel): + video_url: str + image_id: Optional[str] = None + image_link: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + @field_validator("video_url") + @classmethod + def validate_kakao_tv_url(cls, v: str) -> str: + if not v.startswith(KAKAO_TV_URL_PREFIX): + raise ValueError( + f"videoUrl은 '{KAKAO_TV_URL_PREFIX}'으로 시작하는 " + "카카오TV 동영상 링크여야 합니다." + ) + return v diff --git a/solapi/model/kakao/bms/bms_wide_item.py b/solapi/model/kakao/bms/bms_wide_item.py new file mode 100644 index 0000000..a6c5bcd --- /dev/null +++ b/solapi/model/kakao/bms/bms_wide_item.py @@ -0,0 +1,26 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class BmsMainWideItem(BaseModel): + title: Optional[str] = None + image_id: Optional[str] = None + link_mobile: Optional[str] = None + link_pc: Optional[str] = None + link_android: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsSubWideItem(BaseModel): + title: Optional[str] = None + image_id: Optional[str] = None + link_mobile: Optional[str] = None + link_pc: Optional[str] = None + link_android: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/message_type.py b/solapi/model/message_type.py index cfa8b4c..652b5e2 100644 --- a/solapi/model/message_type.py +++ b/solapi/model/message_type.py @@ -19,6 +19,15 @@ class MessageType(Enum): RCS_LTPL: RCS LMS 템플릿 문자 FAX: 팩스 VOICE: 음성문자(TTS) + BMS_TEXT: 브랜드 메시지 텍스트형 + BMS_IMAGE: 브랜드 메시지 이미지형 + BMS_WIDE: 브랜드 메시지 와이드형 + BMS_WIDE_ITEM_LIST: 브랜드 메시지 와이드 아이템 리스트형 + BMS_CAROUSEL_FEED: 브랜드 메시지 캐러셀 피드형 + BMS_PREMIUM_VIDEO: 브랜드 메시지 프리미엄 비디오형 + BMS_COMMERCE: 브랜드 메시지 커머스형 + BMS_CAROUSEL_COMMERCE: 브랜드 메시지 캐러셀 커머스형 + BMS_FREE: 브랜드 메시지 자유형 """ SMS = "SMS" @@ -36,6 +45,15 @@ class MessageType(Enum): RCS_LTPL = "RCS_LTPL" FAX = "FAX" VOICE = "VOICE" + BMS_TEXT = "BMS_TEXT" + BMS_IMAGE = "BMS_IMAGE" + BMS_WIDE = "BMS_WIDE" + BMS_WIDE_ITEM_LIST = "BMS_WIDE_ITEM_LIST" + BMS_CAROUSEL_FEED = "BMS_CAROUSEL_FEED" + BMS_PREMIUM_VIDEO = "BMS_PREMIUM_VIDEO" + BMS_COMMERCE = "BMS_COMMERCE" + BMS_CAROUSEL_COMMERCE = "BMS_CAROUSEL_COMMERCE" + BMS_FREE = "BMS_FREE" def __str__(self) -> str: return self.value diff --git a/solapi/model/request/__init__.py b/solapi/model/request/__init__.py index 0fe91e2..7d920d8 100644 --- a/solapi/model/request/__init__.py +++ b/solapi/model/request/__init__.py @@ -1,2 +1,2 @@ # NOTE: Python SDK가 업데이트 될 때마다 Version도 갱신해야 함! -VERSION = "python/5.0.2" +VERSION = "python/5.0.3" diff --git a/solapi/model/request/kakao/bms.py b/solapi/model/request/kakao/bms.py index 9a59c51..f17e6b6 100644 --- a/solapi/model/request/kakao/bms.py +++ b/solapi/model/request/kakao/bms.py @@ -1,12 +1,53 @@ -from typing import Literal +from typing import Literal, Optional, Union -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, model_validator +from pydantic.alias_generators import to_camel + +from solapi.model.kakao.bms.bms_button import BmsButton +from solapi.model.kakao.bms.bms_carousel import ( + BmsCarouselCommerceSchema, + BmsCarouselFeedSchema, +) +from solapi.model.kakao.bms.bms_commerce import BmsCommerce +from solapi.model.kakao.bms.bms_coupon import BmsCoupon +from solapi.model.kakao.bms.bms_option import ( + BmsChatBubbleType, + validate_bms_required_fields, +) +from solapi.model.kakao.bms.bms_video import BmsVideo +from solapi.model.kakao.bms.bms_wide_item import BmsMainWideItem, BmsSubWideItem class Bms(BaseModel): - targeting: Literal["M", "N", "I"] + targeting: Optional[Literal["I", "M", "N"]] = None + chat_bubble_type: Optional[BmsChatBubbleType] = None + + adult: Optional[bool] = None + header: Optional[str] = None + image_id: Optional[str] = None + image_link: Optional[str] = None + additional_content: Optional[str] = None + content: Optional[str] = None + + carousel: Optional[Union[BmsCarouselFeedSchema, BmsCarouselCommerceSchema]] = None + main_wide_item: Optional[BmsMainWideItem] = None + sub_wide_item_list: Optional[list[BmsSubWideItem]] = None + buttons: Optional[list[BmsButton]] = None + coupon: Optional[BmsCoupon] = None + commerce: Optional[BmsCommerce] = None + video: Optional[BmsVideo] = None model_config = ConfigDict( + alias_generator=to_camel, populate_by_name=True, extra="ignore", ) + + @model_validator(mode="after") + def validate_required_fields(self) -> "Bms": + validate_bms_required_fields( + chat_bubble_type=self.chat_bubble_type, + sub_wide_item_list=self.sub_wide_item_list, + get_field_value=lambda field: getattr(self, field, None), + ) + return self diff --git a/solapi/model/request/message.py b/solapi/model/request/message.py index 793edc1..c5fa214 100644 --- a/solapi/model/request/message.py +++ b/solapi/model/request/message.py @@ -81,4 +81,5 @@ def normalize_to_phone_number( extra="ignore", populate_by_name=True, alias_generator=to_camel, + use_enum_values=True, ) diff --git a/solapi/model/request/storage.py b/solapi/model/request/storage.py index e46a657..dff6bbd 100644 --- a/solapi/model/request/storage.py +++ b/solapi/model/request/storage.py @@ -9,6 +9,13 @@ class FileTypeEnum(str, Enum): KAKAO = "KAKAO" RCS = "RCS" FAX = "FAX" + # BMS (Brand Message Service) file types + BMS = "BMS" + BMS_WIDE = "BMS_WIDE" + BMS_WIDE_MAIN_ITEM_LIST = "BMS_WIDE_MAIN_ITEM_LIST" + BMS_WIDE_SUB_ITEM_LIST = "BMS_WIDE_SUB_ITEM_LIST" + BMS_CAROUSEL_FEED_LIST = "BMS_CAROUSEL_FEED_LIST" + BMS_CAROUSEL_COMMERCE_LIST = "BMS_CAROUSEL_COMMERCE_LIST" class FileUploadRequest(BaseModel): diff --git a/tests/test_bms_free.py b/tests/test_bms_free.py new file mode 100644 index 0000000..43afbe3 --- /dev/null +++ b/tests/test_bms_free.py @@ -0,0 +1,911 @@ +import pytest + +from solapi.model.kakao.bms import ( + BmsAppButton, + BmsCarouselCommerceItem, + BmsCarouselCommerceSchema, + BmsCarouselFeedItem, + BmsCarouselFeedSchema, + BmsCommerce, + BmsCoupon, + BmsMainWideItem, + BmsOption, + BmsSubWideItem, + BmsVideo, + BmsWebButton, +) +from solapi.model.request.kakao.bms import Bms + + +class TestBmsCommerce: + def test_valid_regular_price_only(self): + commerce = BmsCommerce(title="상품명", regular_price=10000) + assert commerce.title == "상품명" + assert commerce.regular_price == 10000 + + def test_valid_discount_rate(self): + commerce = BmsCommerce( + title="상품명", + regular_price=10000, + discount_price=8000, + discount_rate=20, + ) + assert commerce.discount_rate == 20 + + def test_valid_discount_fixed(self): + commerce = BmsCommerce( + title="상품명", + regular_price=10000, + discount_price=8000, + discount_fixed=2000, + ) + assert commerce.discount_fixed == 2000 + + def test_invalid_both_discount_types(self): + with pytest.raises(ValueError, match="discountRate와 discountFixed는 동시에"): + BmsCommerce( + title="상품명", + regular_price=10000, + discount_price=8000, + discount_rate=20, + discount_fixed=2000, + ) + + def test_invalid_discount_rate_without_price(self): + with pytest.raises(ValueError, match="discountPrice.*함께 지정"): + BmsCommerce( + title="상품명", + regular_price=10000, + discount_rate=20, + ) + + def test_invalid_discount_price_alone(self): + with pytest.raises(ValueError, match="discountRate.*discountFixed.*함께"): + BmsCommerce( + title="상품명", + regular_price=10000, + discount_price=8000, + ) + + def test_string_to_int_coercion(self): + commerce = BmsCommerce(title="상품명", regular_price="10000") # type: ignore[arg-type] + assert commerce.regular_price == 10000 + + +class TestBmsCoupon: + def test_valid_won_discount(self): + coupon = BmsCoupon(title="5000원 할인 쿠폰", description="설명") + assert coupon.title == "5000원 할인 쿠폰" + + def test_valid_percent_discount(self): + coupon = BmsCoupon(title="10% 할인 쿠폰", description="설명") + assert coupon.title == "10% 할인 쿠폰" + + def test_valid_shipping_discount(self): + coupon = BmsCoupon(title="배송비 할인 쿠폰", description="설명") + assert coupon.title == "배송비 할인 쿠폰" + + def test_valid_free_coupon(self): + coupon = BmsCoupon(title="커피 무료 쿠폰", description="설명") + assert coupon.title == "커피 무료 쿠폰" + + def test_valid_up_coupon(self): + coupon = BmsCoupon(title="포인트 UP 쿠폰", description="설명") + assert coupon.title == "포인트 UP 쿠폰" + + def test_invalid_coupon_title(self): + with pytest.raises(ValueError, match="쿠폰 제목은 다음 형식"): + BmsCoupon(title="잘못된 쿠폰", description="설명") + + +class TestBmsVideo: + def test_valid_kakao_tv_url(self): + video = BmsVideo(video_url="https://tv.kakao.com/v/123456") + assert video.video_url == "https://tv.kakao.com/v/123456" + + def test_invalid_url(self): + with pytest.raises(ValueError, match="카카오TV 동영상 링크"): + BmsVideo(video_url="https://youtube.com/watch?v=123") + + +class TestBmsButton: + def test_web_button(self): + button = BmsWebButton(name="버튼", link_mobile="https://example.com") + assert button.link_type == "WL" + assert button.name == "버튼" + + def test_app_button_with_mobile(self): + button = BmsAppButton(name="앱 버튼", link_mobile="https://example.com") + assert button.link_type == "AL" + + def test_app_button_with_android(self): + button = BmsAppButton(name="앱 버튼", link_android="app://path") + assert button.link_android == "app://path" + + def test_app_button_without_links(self): + with pytest.raises( + ValueError, match="linkMobile, linkAndroid, linkIos 중 하나" + ): + BmsAppButton(name="앱 버튼") + + +class TestBmsWideItem: + def test_main_wide_item(self): + item = BmsMainWideItem(image_id="img123", link_mobile="https://example.com") + assert item.image_id == "img123" + assert item.title is None + + def test_sub_wide_item(self): + item = BmsSubWideItem( + title="서브 아이템", + image_id="img123", + link_mobile="https://example.com", + ) + assert item.title == "서브 아이템" + + +class TestBmsCarousel: + def test_feed_schema(self): + items = [ + BmsCarouselFeedItem( + header="헤더1", + content="내용1", + image_id="img1", + buttons=[BmsWebButton(name="버튼", link_mobile="https://example.com")], + ), + BmsCarouselFeedItem( + header="헤더2", + content="내용2", + image_id="img2", + buttons=[BmsWebButton(name="버튼", link_mobile="https://example.com")], + ), + ] + schema = BmsCarouselFeedSchema(items=items) + assert schema.items is not None + assert len(schema.items) == 2 + + def test_commerce_schema(self): + items = [ + BmsCarouselCommerceItem( + commerce=BmsCommerce(title="상품1", regular_price=10000), + image_id="img1", + buttons=[BmsWebButton(name="구매", link_mobile="https://example.com")], + ), + BmsCarouselCommerceItem( + commerce=BmsCommerce(title="상품2", regular_price=20000), + image_id="img2", + buttons=[BmsWebButton(name="구매", link_mobile="https://example.com")], + ), + ] + schema = BmsCarouselCommerceSchema(items=items) + assert schema.items is not None + assert len(schema.items) == 2 + + +class TestBmsOption: + def test_text_type_minimal(self): + bms = BmsOption(targeting="I", chat_bubble_type="TEXT") + assert bms.targeting == "I" + assert bms.chat_bubble_type == "TEXT" + + def test_image_type_requires_image_id(self): + with pytest.raises(ValueError, match="imageId"): + BmsOption(targeting="I", chat_bubble_type="IMAGE") + + def test_image_type_valid(self): + bms = BmsOption(targeting="I", chat_bubble_type="IMAGE", image_id="img123") + assert bms.image_id == "img123" + + def test_wide_type_requires_image_id(self): + with pytest.raises(ValueError, match="imageId"): + BmsOption(targeting="I", chat_bubble_type="WIDE") + + def test_wide_item_list_requires_minimum_sub_items(self): + main_item = BmsMainWideItem(image_id="img", link_mobile="https://example.com") + sub_items = [ + BmsSubWideItem( + title="1", image_id="img1", link_mobile="https://example.com" + ), + BmsSubWideItem( + title="2", image_id="img2", link_mobile="https://example.com" + ), + ] + with pytest.raises(ValueError, match="최소 3개"): + BmsOption( + targeting="I", + chat_bubble_type="WIDE_ITEM_LIST", + header="헤더", + main_wide_item=main_item, + sub_wide_item_list=sub_items, + ) + + def test_wide_item_list_valid(self): + main_item = BmsMainWideItem(image_id="img", link_mobile="https://example.com") + sub_items = [ + BmsSubWideItem( + title="1", image_id="img1", link_mobile="https://example.com" + ), + BmsSubWideItem( + title="2", image_id="img2", link_mobile="https://example.com" + ), + BmsSubWideItem( + title="3", image_id="img3", link_mobile="https://example.com" + ), + ] + bms = BmsOption( + targeting="I", + chat_bubble_type="WIDE_ITEM_LIST", + header="헤더", + main_wide_item=main_item, + sub_wide_item_list=sub_items, + ) + assert bms.sub_wide_item_list is not None + assert len(bms.sub_wide_item_list) == 3 + + def test_commerce_requires_fields(self): + with pytest.raises(ValueError, match="imageId.*commerce.*buttons"): + BmsOption(targeting="I", chat_bubble_type="COMMERCE") + + def test_commerce_valid(self): + bms = BmsOption( + targeting="I", + chat_bubble_type="COMMERCE", + image_id="img123", + commerce=BmsCommerce(title="상품", regular_price=10000), + buttons=[BmsWebButton(name="구매", link_mobile="https://example.com")], + ) + assert bms.commerce is not None + assert bms.commerce.title == "상품" + + def test_carousel_feed_requires_carousel(self): + with pytest.raises(ValueError, match="carousel"): + BmsOption(targeting="I", chat_bubble_type="CAROUSEL_FEED") + + def test_premium_video_requires_video(self): + with pytest.raises(ValueError, match="video"): + BmsOption(targeting="I", chat_bubble_type="PREMIUM_VIDEO") + + def test_premium_video_valid(self): + bms = BmsOption( + targeting="I", + chat_bubble_type="PREMIUM_VIDEO", + video=BmsVideo(video_url="https://tv.kakao.com/v/123"), + ) + assert bms.video is not None + assert bms.video.video_url == "https://tv.kakao.com/v/123" + + +class TestBms: + def test_bms_without_chat_bubble_type(self): + bms = Bms(targeting="I") + assert bms.targeting == "I" + assert bms.chat_bubble_type is None + + def test_bms_with_text_type(self): + bms = Bms(targeting="I", chat_bubble_type="TEXT") + assert bms.chat_bubble_type == "TEXT" + + def test_bms_serialization(self): + bms = Bms( + targeting="I", + chat_bubble_type="TEXT", + additional_content="추가 내용", + ) + data = bms.model_dump(by_alias=True, exclude_none=True) + assert data["targeting"] == "I" + assert data["chatBubbleType"] == "TEXT" + assert data["additionalContent"] == "추가 내용" + + +class TestBmsFreeE2E: + """E2E tests for BMS Free message sending. + + These tests actually send messages through the SOLAPI API. + Requires SOLAPI_KAKAO_PF_ID environment variable to be set. + """ + + def test_send_bms_text_minimal( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE TEXT type with minimal structure.""" + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] BMS FREE TEXT 최소 구조 테스트입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms(targeting="I", chat_bubble_type="TEXT"), + ), + ) + + try: + response = message_service.send(message) + except Exception as e: + pytest.skip(f"BMS FREE TEXT test skipped: {e}") + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + + def test_send_bms_text_with_buttons( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE TEXT type with buttons and coupon.""" + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] BMS FREE TEXT 전체 필드 테스트입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="TEXT", + adult=False, + buttons=[ + BmsWebButton(name="웹 링크", link_mobile="https://example.com"), + BmsAppButton( + name="앱 링크", + link_mobile="https://example.com", + link_android="exampleapp://path", + link_ios="exampleapp://path", + ), + ], + coupon=BmsCoupon( + title="10% 할인 쿠폰", + description="테스트 쿠폰입니다.", + link_mobile="https://example.com/coupon", + ), + ), + ), + ) + + try: + response = message_service.send(message) + except Exception as e: + pytest.skip(f"BMS FREE TEXT with buttons test skipped: {e}") + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + + def test_send_bms_image( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE IMAGE type with image upload.""" + from pathlib import Path + + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.request.storage import FileTypeEnum + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + try: + file_response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.BMS, + ) + image_id = file_response.file_id + print(f"Uploaded BMS image ID: {image_id}") + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] BMS FREE IMAGE 테스트입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="IMAGE", + image_id=image_id, + ), + ), + ) + + response = message_service.send(message) + except Exception as e: + pytest.skip(f"BMS FREE IMAGE test skipped: {e}") + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + + def test_send_bms_commerce( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE COMMERCE type with product info.""" + from pathlib import Path + + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.request.storage import FileTypeEnum + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + try: + file_response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.BMS, + ) + image_id = file_response.file_id + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="COMMERCE", + image_id=image_id, + commerce=BmsCommerce( + title="테스트 상품", + regular_price=50000, + discount_price=40000, + discount_rate=20, + ), + buttons=[ + BmsWebButton( + name="구매하기", + link_mobile="https://example.com/product", + ), + ], + ), + ), + ) + + response = message_service.send(message) + except Exception as e: + pytest.skip(f"BMS FREE COMMERCE test skipped: {e}") + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + + def test_send_bms_wide( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE WIDE type.""" + from pathlib import Path + + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.request.storage import FileTypeEnum + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + try: + file_response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.BMS_WIDE, + ) + image_id = file_response.file_id + print(f"Uploaded BMS WIDE image ID: {image_id}") + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] BMS FREE WIDE 테스트입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="WIDE", + image_id=image_id, + buttons=[ + BmsWebButton( + name="자세히 보기", + link_mobile="https://example.com", + ), + ], + ), + ), + ) + + response = message_service.send(message) + except Exception as e: + pytest.skip(f"BMS FREE WIDE test skipped: {e}") + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + + def test_send_bms_wide_item_list( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE WIDE_ITEM_LIST type. + + Note: Main item requires 2:1 ratio, sub items require 1:1 ratio. + """ + from pathlib import Path + + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.request.storage import FileTypeEnum + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + main_image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example_wide.jpg" + ) + sub_image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example_square.jpg" + ) + if not main_image_path.exists(): + pytest.skip(f"2:1 ratio test image not found at {main_image_path}") + if not sub_image_path.exists(): + pytest.skip(f"1:1 ratio test image not found at {sub_image_path}") + + try: + main_file_response = message_service.upload_file( + file_path=str(main_image_path), + upload_type=FileTypeEnum.BMS_WIDE_MAIN_ITEM_LIST, + ) + main_image_id = main_file_response.file_id + print(f"Uploaded main image ID: {main_image_id}") + + sub_file_response = message_service.upload_file( + file_path=str(sub_image_path), + upload_type=FileTypeEnum.BMS_WIDE_SUB_ITEM_LIST, + ) + sub_image_id = sub_file_response.file_id + print(f"Uploaded sub image ID: {sub_image_id}") + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="WIDE_ITEM_LIST", + header="와이드 아이템 리스트 테스트", + main_wide_item=BmsMainWideItem( + image_id=main_image_id, + title="메인 아이템", + link_mobile="https://example.com/main", + ), + sub_wide_item_list=[ + BmsSubWideItem( + image_id=sub_image_id, + title="서브 아이템 1", + link_mobile="https://example.com/sub1", + ), + BmsSubWideItem( + image_id=sub_image_id, + title="서브 아이템 2", + link_mobile="https://example.com/sub2", + ), + BmsSubWideItem( + image_id=sub_image_id, + title="서브 아이템 3", + link_mobile="https://example.com/sub3", + ), + ], + buttons=[ + BmsWebButton( + name="더보기", + link_mobile="https://example.com", + ), + ], + ), + ), + ) + + response = message_service.send(message) + except Exception as e: + pytest.skip(f"BMS FREE WIDE_ITEM_LIST test skipped: {e}") + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + + def test_send_bms_carousel_feed( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE CAROUSEL_FEED type.""" + from pathlib import Path + + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.request.storage import FileTypeEnum + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + try: + file_response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.BMS_CAROUSEL_FEED_LIST, + ) + image_id = file_response.file_id + print(f"Uploaded carousel feed image ID: {image_id}") + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="CAROUSEL_FEED", + carousel=BmsCarouselFeedSchema( + items=[ + BmsCarouselFeedItem( + header="첫 번째 카드", + content="캐러셀 피드 테스트 메시지입니다.", + image_id=image_id, + buttons=[ + BmsWebButton( + name="자세히 보기", + link_mobile="https://example.com/1", + ), + ], + ), + BmsCarouselFeedItem( + header="두 번째 카드", + content="두 번째 캐러셀 아이템입니다.", + image_id=image_id, + buttons=[ + BmsWebButton( + name="자세히 보기", + link_mobile="https://example.com/2", + ), + ], + ), + ], + ), + ), + ), + ) + + response = message_service.send(message) + except Exception as e: + pytest.skip(f"BMS FREE CAROUSEL_FEED test skipped: {e}") + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + + def test_send_bms_carousel_commerce( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE CAROUSEL_COMMERCE type.""" + from pathlib import Path + + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.request.storage import FileTypeEnum + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + try: + file_response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.BMS_CAROUSEL_COMMERCE_LIST, + ) + image_id = file_response.file_id + print(f"Uploaded carousel commerce image ID: {image_id}") + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="CAROUSEL_COMMERCE", + carousel=BmsCarouselCommerceSchema( + items=[ + BmsCarouselCommerceItem( + image_id=image_id, + commerce=BmsCommerce( + title="상품 1", + regular_price=50000, + discount_price=40000, + discount_rate=20, + ), + buttons=[ + BmsWebButton( + name="구매하기", + link_mobile="https://example.com/product1", + ), + ], + ), + BmsCarouselCommerceItem( + image_id=image_id, + commerce=BmsCommerce( + title="상품 2", + regular_price=80000, + discount_price=60000, + discount_fixed=20000, + ), + buttons=[ + BmsWebButton( + name="구매하기", + link_mobile="https://example.com/product2", + ), + ], + ), + ], + ), + ), + ), + ) + + response = message_service.send(message) + except Exception as e: + pytest.skip(f"BMS FREE CAROUSEL_COMMERCE test skipped: {e}") + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + + def test_send_bms_premium_video( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE PREMIUM_VIDEO type.""" + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + try: + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] BMS FREE PREMIUM_VIDEO 테스트입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="PREMIUM_VIDEO", + video=BmsVideo( + video_url="https://tv.kakao.com/v/123456789", + ), + buttons=[ + BmsWebButton( + name="영상 보기", + link_mobile="https://tv.kakao.com/v/123456789", + ), + ], + ), + ), + ) + + response = message_service.send(message) + except Exception as e: + pytest.skip(f"BMS FREE PREMIUM_VIDEO test skipped: {e}") + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") diff --git a/uv.lock b/uv.lock index 4d60c0a..71b8abc 100644 --- a/uv.lock +++ b/uv.lock @@ -375,7 +375,7 @@ wheels = [ [[package]] name = "solapi" -version = "5.0.1" +version = "5.0.3" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -386,6 +386,7 @@ dependencies = [ dev = [ { name = "pytest" }, { name = "ruff" }, + { name = "ty" }, ] [package.metadata] @@ -394,6 +395,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.11.4,<3.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.0" }, + { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1" }, ] provides-extras = ["dev"] @@ -445,6 +447,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] +[[package]] +name = "ty" +version = "0.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/dc/b607f00916f5a7c52860b84a66dc17bc6988e8445e96b1d6e175a3837397/ty-0.0.13.tar.gz", hash = "sha256:7a1d135a400ca076407ea30012d1f75419634160ed3b9cad96607bf2956b23b3", size = 4999183 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/df/3632f1918f4c0a33184f107efc5d436ab6da147fd3d3b94b3af6461efbf4/ty-0.0.13-py3-none-linux_armv6l.whl", hash = "sha256:1b2b8e02697c3a94c722957d712a0615bcc317c9b9497be116ef746615d892f2", size = 9993501 }, + { url = "https://files.pythonhosted.org/packages/92/87/6a473ced5ac280c6ce5b1627c71a8a695c64481b99aabc798718376a441e/ty-0.0.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f15cdb8e233e2b5adfce673bb21f4c5e8eaf3334842f7eea3c70ac6fda8c1de5", size = 9860986 }, + { url = "https://files.pythonhosted.org/packages/5d/9b/d89ae375cf0a7cd9360e1164ce017f8c753759be63b6a11ed4c944abe8c6/ty-0.0.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0819e89ac9f0d8af7a062837ce197f0461fee2fc14fd07e2c368780d3a397b73", size = 9350748 }, + { url = "https://files.pythonhosted.org/packages/a8/a6/9ad58518056fab344b20c0bb2c1911936ebe195318e8acc3bc45ac1c6b6b/ty-0.0.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de79f481084b7cc7a202ba0d7a75e10970d10ffa4f025b23f2e6b7324b74886", size = 9849884 }, + { url = "https://files.pythonhosted.org/packages/b1/c3/8add69095fa179f523d9e9afcc15a00818af0a37f2b237a9b59bc0046c34/ty-0.0.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4fb2154cff7c6e95d46bfaba283c60642616f20d73e5f96d0c89c269f3e1bcec", size = 9822975 }, + { url = "https://files.pythonhosted.org/packages/a4/05/4c0927c68a0a6d43fb02f3f0b6c19c64e3461dc8ed6c404dde0efb8058f7/ty-0.0.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00be58d89337c27968a20d58ca553458608c5b634170e2bec82824c2e4cf4d96", size = 10294045 }, + { url = "https://files.pythonhosted.org/packages/b4/86/6dc190838aba967557fe0bfd494c595d00b5081315a98aaf60c0e632aaeb/ty-0.0.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72435eade1fa58c6218abb4340f43a6c3ff856ae2dc5722a247d3a6dd32e9737", size = 10916460 }, + { url = "https://files.pythonhosted.org/packages/04/40/9ead96b7c122e1109dfcd11671184c3506996bf6a649306ec427e81d9544/ty-0.0.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:77a548742ee8f621d718159e7027c3b555051d096a49bb580249a6c5fc86c271", size = 10597154 }, + { url = "https://files.pythonhosted.org/packages/aa/7d/e832a2c081d2be845dc6972d0c7998914d168ccbc0b9c86794419ab7376e/ty-0.0.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da067c57c289b7cf914669704b552b6207c2cc7f50da4118c3e12388642e6b3f", size = 10410710 }, + { url = "https://files.pythonhosted.org/packages/31/e3/898be3a96237a32f05c4c29b43594dc3b46e0eedfe8243058e46153b324f/ty-0.0.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d1b50a01fffa140417fca5a24b658fbe0734074a095d5b6f0552484724474343", size = 9826299 }, + { url = "https://files.pythonhosted.org/packages/bb/eb/db2d852ce0ed742505ff18ee10d7d252f3acfd6fc60eca7e9c7a0288a6d8/ty-0.0.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0f33c46f52e5e9378378eca0d8059f026f3c8073ace02f7f2e8d079ddfe5207e", size = 9831610 }, + { url = "https://files.pythonhosted.org/packages/9e/61/149f59c8abaddcbcbb0bd13b89c7741ae1c637823c5cf92ed2c644fcadef/ty-0.0.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:168eda24d9a0b202cf3758c2962cc295878842042b7eca9ed2965259f59ce9f2", size = 9978885 }, + { url = "https://files.pythonhosted.org/packages/a0/cd/026d4e4af60a80918a8d73d2c42b8262dd43ab2fa7b28d9743004cb88d57/ty-0.0.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d4917678b95dc8cb399cc459fab568ba8d5f0f33b7a94bf840d9733043c43f29", size = 10506453 }, + { url = "https://files.pythonhosted.org/packages/63/06/8932833a4eca2df49c997a29afb26721612de8078ae79074c8fe87e17516/ty-0.0.13-py3-none-win32.whl", hash = "sha256:c1f2ec40daa405508b053e5b8e440fbae5fdb85c69c9ab0ee078f8bc00eeec3d", size = 9433482 }, + { url = "https://files.pythonhosted.org/packages/aa/fd/e8d972d1a69df25c2cecb20ea50e49ad5f27a06f55f1f5f399a563e71645/ty-0.0.13-py3-none-win_amd64.whl", hash = "sha256:8b7b1ab9f187affbceff89d51076038363b14113be29bda2ddfa17116de1d476", size = 10319156 }, + { url = "https://files.pythonhosted.org/packages/2d/c2/05fdd64ac003a560d4fbd1faa7d9a31d75df8f901675e5bed1ee2ceeff87/ty-0.0.13-py3-none-win_arm64.whl", hash = "sha256:1c9630333497c77bb9bcabba42971b96ee1f36c601dd3dcac66b4134f9fa38f0", size = 9808316 }, +] + [[package]] name = "typing-extensions" version = "4.12.2"