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 2dfd575..8d3bfd2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,42 @@ -.idea/ -venv/ +# Python __pycache__/ -*.pyc -config.ini -*.real.py +*.py[cod] +*$py.class + +# Poetry +.venv/ +dist/ + +# IDE +.vscode/ +.idea/ + +# 환경 변수 +.env + +# 로그 파일 +*.log + +# 운영체제 관련 파일 +.DS_Store +Thumbs.db + +# 테스트 관련 +.pytest_cache/ +.coverage + +# 빌드 관련 +build/ +*.egg-info/ + +# 기타 +*.bak +*.swp +*.swo +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/LICENSE b/LICENSE new file mode 100644 index 0000000..3806bf9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +MIT License +----------- + +Copyright (c) 2025~ SOLAPI Inc (https://solapi.com) +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index efcb067..a9c1b6b 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,30 @@ # Solapi SDK for Python -[![Python 3.10.2](https://img.shields.io/badge/python-3.10.2-blue.svg)](https://www.python.org/downloads/release/python-3102/) -![Python Supported version](https://img.shields.io/badge/python-%3E%3D3.7-orange) +[![Python 3.9.21](https://img.shields.io/badge/python-3.9.21-blue.svg)](https://www.python.org/downloads) +![Python Supported version](https://img.shields.io/badge/python-%3E%3D3.9-orange) -### SOLAPI SDK 이용을 위해 아래 라이브러리 설치를 필요로 합니다. -- requests -- configparser +You can send text messages(SMS, LMS, MMS), Kakao friendtalk(include notification friendtalk) in Korea using this package. +This package is 100% compatible with SOLAPI family services (CoolSMS, Purplebook, etc.). -### 예제 유형 별 참고사항 +Installing +To use the SDK, simply use pip package manager CLI. Type the following into a terminal window. -- 일반적인 사용예제는 examples/modules 폴더를 참고 해주세요. -- python 인터프리터를 통해 한 파일 안에서 구동되는 예제들은 examples/scripts 폴더를 참고해주세요. +```shell +pip install solapi +``` + +## Usage + +See [examples repository](https://github.com/solapi/solapi-python/tree/main/examples) + +## Opening Issues + +If you encounter a bug with the SOLAPI SDK for Python we would like to hear about it. +Search the [existing issues](https://github.com/solapi/solapi-python/issues) and try to make sure your problem doesn’t +already exist before opening a new issue, It’s helpful if you include the version of the SDK you are using. +Please include a stack trace and reduced repro case when appropriate, too. + +## License + +Licensed under the MIT License. diff --git a/examples/balance/get_balance.py b/examples/balance/get_balance.py new file mode 100644 index 0000000..29e8bc1 --- /dev/null +++ b/examples/balance/get_balance.py @@ -0,0 +1,17 @@ +from solapi import SolapiMessageService + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + # 잔액을 조회합니다 + balance_response = message_service.get_balance() + + print("잔액 조회 성공!") + print(f"현재 잔액: {balance_response.balance}원") + print(f"포인트: {balance_response.point}P") + +except Exception as e: + print(f"잔액 조회 실패: {str(e)}") diff --git a/examples/group/get_groups.py b/examples/group/get_groups.py new file mode 100644 index 0000000..07099c1 --- /dev/null +++ b/examples/group/get_groups.py @@ -0,0 +1,23 @@ +from solapi import SolapiMessageService + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 메시지 그룹을 생성하고 조회합니다 +try: + # 그룹 목록을 조회합니다 + groups_response = message_service.get_groups() + print("메시지 그룹 목록:") + for group_id, group in groups_response.group_list.items(): + print("----") + print(f"\nGroup ID: {group_id}") + print(f"생성 시간: {group.date_created}") + print(f"메시지 수: {group.count.total}") + print(f"상태: {group.status}") + print(f"처리 로그: {group.log}") + print("----") + +except Exception as e: + print(f"그룹 조회 실패: {str(e)}") diff --git a/src/examples/modules/testImage.jpg b/examples/images/example.jpg similarity index 100% rename from src/examples/modules/testImage.jpg rename to examples/images/example.jpg 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/messages/get_messages.py b/examples/messages/get_messages.py new file mode 100644 index 0000000..b8ce3ce --- /dev/null +++ b/examples/messages/get_messages.py @@ -0,0 +1,19 @@ +from solapi import SolapiMessageService + +# from solapi.model.request.messages.get_messages import GetMessagesRequest + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + response = message_service.get_messages( + # 메시지를 조회할 때 아래와 같이 조건을 지정할 수 있습니다. + # GetMessagesRequest( + # start_date="2025-01-01", + # end_date="2025-01-03" + # ) + ) + print(response) +except Exception as e: + print(e) 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/examples/simple/send_kakao_alimtalk.py b/examples/simple/send_kakao_alimtalk.py new file mode 100644 index 0000000..ffd59e1 --- /dev/null +++ b/examples/simple/send_kakao_alimtalk.py @@ -0,0 +1,37 @@ +from solapi import SolapiMessageService +from solapi.model import RequestMessage +from solapi.model.kakao.kakao_option import KakaoOption + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 카카오 알림톡 발송을 위한 옵션을 생성합니다. +kakao_option = KakaoOption( + pf_id="계정에 등록된 카카오 비즈니스 채널ID", + template_id="계정에 등록된 카카오 알림톡 템플릿 ID", + # 만약에 템플릿에 변수가 있다면 아래와 같이 설정합니다. + # 값은 반드시 문자열로 넣어주셔야 합니다! + # variables={ + # "#{name}": "홍길동", + # "#{age}": "30" + # } +) + +# 단일 메시지를 생성합니다 +message = RequestMessage( + from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) + to="수신번호", # 수신번호 + kakao_options=kakao_option, +) + +# 메시지를 발송합니다 +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}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") diff --git a/examples/simple/send_kakao_bms.py b/examples/simple/send_kakao_bms.py new file mode 100644 index 0000000..682e3c3 --- /dev/null +++ b/examples/simple/send_kakao_bms.py @@ -0,0 +1,41 @@ +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 카카오 알림톡 발송을 위한 옵션을 생성합니다. +kakao_option = KakaoOption( + pf_id="계정에 등록된 카카오 비즈니스 채널ID", + template_id="계정에 등록된 카카오 브랜드 메시지 템플릿 ID", + # 만약에 템플릿에 변수가 있다면 아래와 같이 설정합니다. + # 값은 반드시 문자열로 넣어주셔야 합니다! + # variables={ + # "#{name}": "홍길동", + # "#{age}": "30" + # } + # 브랜드 메시지 발송 대상자 설정, M, N 타입은 카카오측의 별도 인허가를 받은 대상만 사용할 수 있습니다. + # M: 마케팅 수신 동의 대상자 및 카카오 채널 친구 + # N: 마케팅 수신 동의 대상자 및 카카오 채널 친구는 제외한 대상자 + # I: 카카오 채널 친구 + bms=Bms(targeting="M"), +) + +# 단일 메시지를 생성합니다 +message = RequestMessage( + from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) + to="수신번호", # 수신번호 + kakao_options=kakao_option, +) + +# 메시지를 발송합니다 +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}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") diff --git a/examples/simple/send_many.py b/examples/simple/send_many.py new file mode 100644 index 0000000..d619387 --- /dev/null +++ b/examples/simple/send_many.py @@ -0,0 +1,36 @@ +from solapi import SolapiMessageService +from solapi.model import RequestMessage, SendRequestConfig + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 여러 메시지를 생성합니다 +messages = [ + RequestMessage(from_="발신번호", to="수신번호1", text="첫 번째 메시지입니다."), + RequestMessage(from_="발신번호", to="수신번호2", text="두 번째 메시지입니다."), + RequestMessage(from_="발신번호", to="수신번호3", text="세 번째 메시지입니다."), +] + +# SendRequestConfig를 사용하여 중복 수신번호 허용 설정 +config = SendRequestConfig( + allow_duplicates=True # 중복 수신번호 허용 +) + +# 메시지를 발송합니다 +try: + response = message_service.send(messages, config) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered}") + + # 실패한 메시지가 있는 경우 + if response.failed_message_list: + print("\n실패한 메시지 목록:") + for failed in response.failed_message_list: + print(f"수신번호: {failed.message.to}") + print(f"실패 사유: {failed.error.message}\n") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") diff --git a/examples/simple/send_mms.py b/examples/simple/send_mms.py new file mode 100644 index 0000000..ec2973b --- /dev/null +++ b/examples/simple/send_mms.py @@ -0,0 +1,42 @@ +from os.path import abspath + +from solapi import SolapiMessageService +from solapi.model import RequestMessage +from solapi.model.request.storage import FileTypeEnum + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 이미지 파일을 업로드합니다 +try: + # 이미지 파일 업로드 (MMS 타입으로 지정) + file_response = message_service.upload_file( + file_path=abspath( + "../images/example.jpg" + ), # 실제 이미지 파일 경로로 변경해주세요 + upload_type=FileTypeEnum.MMS, + ) + + print("파일 업로드 성공!") + print(f"File ID: {file_response.file_id}") + + # MMS 메시지를 생성하고 발송합니다 + message = RequestMessage( + from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) + to="수신번호", # 수신번호 + # subject="MMS 제목", # MMS 제목, 제목을 지정하지 않는다면 필요하지 않습니다. + text="MMS 메시지 내용입니다.", + image_id=file_response.file_id, # 업로드된 파일의 ID를 지정 + ) + + # 메시지를 발송합니다 + response = message_service.send(message) + print("\nMMS 발송 성공!") + 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"MMS 발송 실패: {str(e)}") diff --git a/examples/simple/send_sms.py b/examples/simple/send_sms.py new file mode 100644 index 0000000..7f1859a --- /dev/null +++ b/examples/simple/send_sms.py @@ -0,0 +1,25 @@ +from solapi import SolapiMessageService +from solapi.model import RequestMessage + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 단일 메시지를 생성합니다 +message = RequestMessage( + from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) + to="수신번호", # 수신번호 + text="안녕하세요! SOLAPI Python SDK를 사용한 SMS 발송 예제입니다.", +) + +# 메시지를 발송합니다 +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}") + print(f"실패한 메시지 개수: {response.group_info.count.registered_failed}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") diff --git a/examples/simple/send_sms_with_reservation.py b/examples/simple/send_sms_with_reservation.py new file mode 100644 index 0000000..b542b81 --- /dev/null +++ b/examples/simple/send_sms_with_reservation.py @@ -0,0 +1,32 @@ +from datetime import datetime + +from solapi import SolapiMessageService +from solapi.model import RequestMessage, SendRequestConfig + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 단일 메시지를 생성합니다 +message = RequestMessage( + from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) + to="수신번호", # 수신번호 + text="안녕하세요! SOLAPI Python SDK를 사용한 SMS 발송 예제입니다.", +) + +# 예약 발송을 위한 설정을 추가합니다 +request_config = SendRequestConfig(scheduled_date=datetime(2025, 4, 2, 13, 0, 0)) +# 혹은.. +# request_config = SendRequestConfig(scheduled_date="2025-04-02 13:00:00") + +# 메시지를 발송합니다 +try: + response = message_service.send(message, request_config) + 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}") + print(f"실패한 메시지 개수: {response.group_info.count.registered_failed}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") diff --git a/examples/simple/send_voice_message.py b/examples/simple/send_voice_message.py new file mode 100644 index 0000000..12c5db7 --- /dev/null +++ b/examples/simple/send_voice_message.py @@ -0,0 +1,38 @@ +from solapi import SolapiMessageService +from solapi.model import RequestMessage, VoiceOption + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +""" +단일 메시지를 생성합니다 +header_message를 사용하는 경우, 반드시 아무 버튼이나 눌러야 text 메시지가 재생됩니다. +text 메시지가 재생된 이후, reply_range에 명시된 번호(1~9) 혹은 counselor_number에 값이 있을 경우 0번을 눌러야 tail_message가 재생됩니다. +자세한 사항은 아래 링크를 참고해주세요! + +https://developers.solapi.com/references/voice +""" +message = RequestMessage( + from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) + to="수신번호", # 수신번호 + text="안녕하세요! SOLAPI Python SDK를 사용한 음성 메시지 발송 예제입니다.", + voice_options=VoiceOption( + voice_type="FEMALE", + header_message="안녕하세요!", + tail_message="안녕하세요!", + reply_range=1, + ), +) + +# 메시지를 발송합니다 +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}") + print(f"실패한 메시지 개수: {response.group_info.count.registered_failed}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") diff --git a/examples/storage/upload_file.py b/examples/storage/upload_file.py new file mode 100644 index 0000000..ee9bcea --- /dev/null +++ b/examples/storage/upload_file.py @@ -0,0 +1,40 @@ +from solapi import SolapiMessageService +from solapi.model.request.storage import FileTypeEnum + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + + +def upload_file_example(file_path: str, file_type: FileTypeEnum): + """ + 파일을 업로드하는 예제 함수입니다. + + Args: + file_path (str): 업로드할 파일의 경로 + file_type (FileTypeEnum): 파일 타입 (MMS, KAKAO, DOCUMENT 등) + """ + try: + # 파일 업로드 + response = message_service.upload_file( + file_path=file_path, upload_type=file_type + ) + + print("파일 업로드 성공!") + print(f"File ID: {response.file_id}") + print(f"File Name: {response.name}") + print(f"File Created: {response.date_created}") + + return response.file_id + + except Exception as e: + print(f"파일 업로드 실패: {str(e)}") + return None + + +# 예제 실행 +if __name__ == "__main__": + # MMS 이미지 업로드 예제 + # 현재 예제는 images 폴더에 있는 example.jpg 파일을 불러오지만, 실제 예제 사용시에는 이미지 파일의 경로를 변경해주세요 + mms_file_id = upload_file_example("../images/example.jpg", FileTypeEnum.MMS) diff --git a/examples/webhook/django_example/.python-version b/examples/webhook/django_example/.python-version new file mode 100644 index 0000000..bd28b9c --- /dev/null +++ b/examples/webhook/django_example/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/examples/webhook/django_example/README.md b/examples/webhook/django_example/README.md new file mode 100644 index 0000000..5b2ca9c --- /dev/null +++ b/examples/webhook/django_example/README.md @@ -0,0 +1,20 @@ +# SOLAPI Django Webhook 예제 + +- 해당 예제는 Django에서 SOLAPI에서 제공하는 webhook을 통해 파라미터를 수신할 때 solapi 모듈을 이용하여 메시지 리포트, 그룹 리포트 데이터를 받아오는 예제입니다. +- 해당 예제는 uv를 통해 패키지를 관리하고 있습니다. 따라서 사전에 [uv](https://docs.astral.sh/uv/getting-started/installation/)가 설치되어 있어야합니다. + +## Getting Started + +예제를 다운로드 받은 직후, 아래의 명령어를 Django 프로젝트 경로에서 입력해주세요. + +```bash +uv sync +``` + +그다음 Django 서버를 실행하려면 아래의 명령어를 실행해주세요. + +```bash +uv run manage.py runserver +``` + +이후에는 `POST http://localhost:8080/webhook/single-report` 또는 `POST http://localhost:8080/webhook/group-report`로 웹훅 데이터를 수신하여 테스트해 볼 수 있습니다. \ No newline at end of file diff --git a/examples/webhook/django_example/db.sqlite3 b/examples/webhook/django_example/db.sqlite3 new file mode 100644 index 0000000..b23e22f Binary files /dev/null and b/examples/webhook/django_example/db.sqlite3 differ diff --git a/src/__init__.py b/examples/webhook/django_example/django_example/__init__.py similarity index 100% rename from src/__init__.py rename to examples/webhook/django_example/django_example/__init__.py diff --git a/examples/webhook/django_example/django_example/app/group_report_controller.py b/examples/webhook/django_example/django_example/app/group_report_controller.py new file mode 100644 index 0000000..854ec5a --- /dev/null +++ b/examples/webhook/django_example/django_example/app/group_report_controller.py @@ -0,0 +1,49 @@ +import json + +from django.http import HttpResponseNotAllowed, JsonResponse +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.csrf import csrf_exempt + +from solapi.model.webhook.group_report import GroupReportPayload + + +@method_decorator(csrf_exempt, name="dispatch") +class GroupReportWebhookController(View): + """ + POST 로만 받는 그룹 리포트(Group Report) 웹훅 엔드포인트 + CSRF 예외 처리(@csrf_exempt) 를 걸어야 외부에서 POST 요청이 들어올 때 403 에러가 나지 않습니다. + """ + + http_method_names = ["post", "options"] + + def post(self, request, *args, **kwargs): + try: + payload = json.loads(request.body.decode("utf-8")) + except json.JSONDecodeError: + return JsonResponse({"error": "invalid JSON"}, status=400) + + message_response = GroupReportPayload.model_validate(payload) + group_report = message_response.root[0] + + # 이후 필요한 프로그래밍 처리.. 아래 방식처럼 데이터를 추출해서 사용할 수 있습니다. + print(group_report.data.group_id) + + # 혹은.. + print(group_report.data.date_created) + + # 또는.. + print(group_report.data.log) + + # 200을 리턴해야 합니다. (200이 리턴되지 않으면 특정 시간 간격을 두고 총 5번이 호출됩니다) + return JsonResponse(group_report.model_dump(), status=200) + + def options(self, request, *args, **kwargs): + response = JsonResponse({"status": "options OK"}) + response["Access-Control-Allow-Origin"] = "*" + response["Access-Control-Allow-Methods"] = "POST, OPTIONS" + response["Access-Control-Allow-Headers"] = "Content-Type" + return response + + def http_method_not_allowed(self, request, *args, **kwargs): + return HttpResponseNotAllowed(["POST", "OPTIONS"]) diff --git a/examples/webhook/django_example/django_example/app/single_report_controller.py b/examples/webhook/django_example/django_example/app/single_report_controller.py new file mode 100644 index 0000000..8da5419 --- /dev/null +++ b/examples/webhook/django_example/django_example/app/single_report_controller.py @@ -0,0 +1,49 @@ +import json + +from django.http import HttpResponseNotAllowed, JsonResponse +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.csrf import csrf_exempt + +from solapi.model.webhook.single_report import SingleReportPayload + + +@method_decorator(csrf_exempt, name="dispatch") +class SingleReportWebhookController(View): + """ + POST 로만 받는 메시지 리포트(Single Report) 웹훅 엔드포인트 + CSRF 예외 처리(@csrf_exempt) 를 걸어야 외부에서 POST 요청이 들어올 때 403 에러가 나지 않습니다. + """ + + http_method_names = ["post", "options"] + + def post(self, request, *args, **kwargs): + try: + payload = json.loads(request.body.decode("utf-8")) + except json.JSONDecodeError: + return JsonResponse({"error": "invalid JSON"}, status=400) + + message_response = SingleReportPayload.model_validate(payload) + single_report = message_response.root[0] + + # 이후 필요한 프로그래밍 처리.. 아래 방식처럼 데이터를 추출해서 사용할 수 있습니다. + print(single_report.data.kakao_options.pf_id) + + # 혹은.. + print(single_report.data.message_id) + + # 또는.. + print(single_report.data.naver_options) + + # 200을 리턴해야 합니다. (200이 리턴되지 않으면 특정 시간 간격을 두고 총 5번이 호출됩니다) + return JsonResponse(single_report.model_dump(), status=200) + + def options(self, request, *args, **kwargs): + response = JsonResponse({"status": "options OK"}) + response["Access-Control-Allow-Origin"] = "*" + response["Access-Control-Allow-Methods"] = "POST, OPTIONS" + response["Access-Control-Allow-Headers"] = "Content-Type" + return response + + def http_method_not_allowed(self, request, *args, **kwargs): + return HttpResponseNotAllowed(["POST", "OPTIONS"]) diff --git a/examples/webhook/django_example/django_example/asgi.py b/examples/webhook/django_example/django_example/asgi.py new file mode 100644 index 0000000..ceb53aa --- /dev/null +++ b/examples/webhook/django_example/django_example/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for django_example project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_example.settings") + +application = get_asgi_application() diff --git a/examples/webhook/django_example/django_example/settings.py b/examples/webhook/django_example/django_example/settings.py new file mode 100644 index 0000000..fc38602 --- /dev/null +++ b/examples/webhook/django_example/django_example/settings.py @@ -0,0 +1,124 @@ +""" +Django settings for django_example project. + +Generated by 'django-admin startproject' using Django 4.2.21. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-%k)_hv-u=%1%zcwkvkmzobk1hkr55ri#^#)*hc)m66*a1_)q$v" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + +APPEND_SLASH = False + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "django_example.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "django_example.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = "ko-kr" + +TIME_ZONE = "Asia/Seoul" + +USE_I18N = True + +USE_TZ = False + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/examples/webhook/django_example/django_example/urls.py b/examples/webhook/django_example/django_example/urls.py new file mode 100644 index 0000000..ad11ff2 --- /dev/null +++ b/examples/webhook/django_example/django_example/urls.py @@ -0,0 +1,32 @@ +""" +URL configuration for django_example project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import path + +from .app.group_report_controller import ( + GroupReportWebhookController, +) +from .app.single_report_controller import ( + SingleReportWebhookController, +) + +urlpatterns = [ + path("admin/", admin.site.urls), + path("webhook/single-report", SingleReportWebhookController.as_view()), + path("webhook/group-report", GroupReportWebhookController.as_view()), +] diff --git a/examples/webhook/django_example/django_example/wsgi.py b/examples/webhook/django_example/django_example/wsgi.py new file mode 100644 index 0000000..a6b3d52 --- /dev/null +++ b/examples/webhook/django_example/django_example/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for django_example project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_example.settings") + +application = get_wsgi_application() diff --git a/examples/webhook/django_example/manage.py b/examples/webhook/django_example/manage.py new file mode 100755 index 0000000..b3f0b0f --- /dev/null +++ b/examples/webhook/django_example/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_example.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/examples/webhook/django_example/pyproject.toml b/examples/webhook/django_example/pyproject.toml new file mode 100644 index 0000000..926aff4 --- /dev/null +++ b/examples/webhook/django_example/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "django_example" +version = "0.1.0" +description = "Django example for SOLAPI webhook" +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "django>=4.2.0", + "solapi>=5.0.1" +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +# [tool.hatch.metadata] +# allow-direct-references = true diff --git a/examples/webhook/django_example/uv.lock b/examples/webhook/django_example/uv.lock new file mode 100644 index 0000000..f375cb4 --- /dev/null +++ b/examples/webhook/django_example/uv.lock @@ -0,0 +1,92 @@ +version = 1 +revision = 1 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "asgiref" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, +] + +[[package]] +name = "django" +version = "4.2.21" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version < '3.10'" }, + { name = "sqlparse", marker = "python_full_version < '3.10'" }, + { name = "tzdata", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/bb/2fad5edc1af2945cb499a2e322ac28e4714fc310bd5201ed1f5a9f73a342/django-4.2.21.tar.gz", hash = "sha256:b54ac28d6aa964fc7c2f7335138a54d78980232011e0cd2231d04eed393dcb0d", size = 10424638 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/4f/aeaa3098da18b625ed672f3da6d1cd94e188d1b2cc27c2c841b2f9666282/django-4.2.21-py3-none-any.whl", hash = "sha256:1d658c7bf5d31c7d0cac1cab58bc1f822df89255080fec81909256c30e6180b3", size = 7993839 }, +] + +[[package]] +name = "django" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version >= '3.10'" }, + { name = "sqlparse", marker = "python_full_version >= '3.10'" }, + { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/10/0d546258772b8f31398e67c85e52c66ebc2b13a647193c3eef8ee433f1a8/django-5.2.1.tar.gz", hash = "sha256:57fe1f1b59462caed092c80b3dd324fd92161b620d59a9ba9181c34746c97284", size = 10818735 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/92/7448697b5838b3a1c6e1d2d6a673e908d0398e84dc4f803a2ce11e7ffc0f/django-5.2.1-py3-none-any.whl", hash = "sha256:a9b680e84f9a0e71da83e399f1e922e1ab37b2173ced046b541c72e1589a5961", size = 8301833 }, +] + +[[package]] +name = "django-example" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "django", version = "4.2.21", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "django", version = "5.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.metadata] +requires-dist = [{ name = "django", specifier = ">=4.2.21" }] + +[[package]] +name = "sqlparse" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415 }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4794770 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,51 @@ +[project] +name = "solapi" +version = "5.0.3" +description = "SOLAPI SDK for Python" +authors = [ + { name = "SOLAPI Team", email = "contact@solapi.com" } +] +classifiers = [ + 'Programming Language :: Python', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + 'Intended Audience :: Developers', + 'Intended Audience :: Information Technology', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Internet', +] +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "httpx (>=0.28.1,<0.29.0)", + "pydantic (>=2.11.4,<3.0.0)" +] + +[project.optional-dependencies] +dev = [ + "ruff>=0.11.0", + "pytest>=7.0.0", + "ty>=0.0.1" +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build] +packages = ["solapi"] + +[tool.uv.workspace] +members = ["examples/webhook/django_example"] + +[tool.uv.sources] +solapi = { workspace = true } diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..ce81e9c --- /dev/null +++ b/ruff.toml @@ -0,0 +1,60 @@ +# 최대 줄 길이 설정 +line-length = 88 +# 들여쓰기 너비 +indent-width = 4 +# Python 버전 타겟 설정 +target-version = "py39" + +[lint] +# 기본적으로 Pyflakes(F)와 pycodestyle(E)의 일부 규칙 활성화 +select = ["E4", "E7", "E9", "F"] +# 추가로 많이 사용되는 규칙들 +extend-select = [ + "B", # flake8-bugbear + "I", # isort + "UP", # pyupgrade + "N", # pep8-naming +] +# 무시할 규칙 +ignore = [] +# 언더스코어로 시작하는 변수는 미사용 변수로 간주하지 않음 +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] + +[format] +# 문자열에 큰따옴표 사용 +quote-style = "double" +# 들여쓰기에 스페이스 사용 +indent-style = "space" +# 마법의 후행 쉼표 존중 +skip-magic-trailing-comma = false +# 자동으로 적절한 줄 끝 감지 +line-ending = "auto" diff --git a/solapi/__init__.py b/solapi/__init__.py new file mode 100644 index 0000000..b21b65c --- /dev/null +++ b/solapi/__init__.py @@ -0,0 +1,3 @@ +from .services.message_service import SolapiMessageService + +__all__ = ["SolapiMessageService"] diff --git a/solapi/error/MessageNotReceiveError.py b/solapi/error/MessageNotReceiveError.py new file mode 100644 index 0000000..bd775ab --- /dev/null +++ b/solapi/error/MessageNotReceiveError.py @@ -0,0 +1,10 @@ +from solapi.model.response.send_message_response import FailedMessage + + +class MessageNotReceivedError(Exception): + def __init__(self, failed_messages: list[FailedMessage]): + self.failed_messages = failed_messages + + def __str__(self): + # TODO: i18n needed + return "메시지 접수에 실패했습니다.\n자세한 내용은 MessageNotReceivedError의 failed_messages 프로퍼티를 참조해 주세요." diff --git a/src/examples/__init__.py b/solapi/lib/__init__.py similarity index 100% rename from src/examples/__init__.py rename to solapi/lib/__init__.py diff --git a/solapi/lib/authenticator.py b/solapi/lib/authenticator.py new file mode 100644 index 0000000..d4d21ca --- /dev/null +++ b/solapi/lib/authenticator.py @@ -0,0 +1,42 @@ +import datetime +import hashlib +import hmac +import time +import uuid +from typing import TypedDict + + +class AuthenticationParameter(TypedDict): + api_key: str + api_secret: str + + +class Authenticator: + def __init__(self, api_key="", api_secret_key=""): + self.api_key = api_key + self.api_secret_key = api_secret_key + + @staticmethod + def unique_id() -> str: + return uuid.uuid1().hex + + @staticmethod + def get_iso_datetime() -> str: + utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone + utc_offset = datetime.timedelta(seconds=-utc_offset_sec) + return ( + datetime.datetime.now() + .replace(tzinfo=datetime.timezone(offset=utc_offset)) + .isoformat() + ) + + @staticmethod + def get_signature(key: str, msg: str) -> str: + return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest() + + def get_auth_info(self) -> str: + date = self.get_iso_datetime() + salt = self.unique_id() + data = date + salt + signature = self.get_signature(self.api_secret_key, data) + return f"HMAC-SHA256 ApiKey={self.api_key}, Date={date}, salt={salt}, signature={signature}" diff --git a/solapi/lib/fetcher.py b/solapi/lib/fetcher.py new file mode 100644 index 0000000..6b74048 --- /dev/null +++ b/solapi/lib/fetcher.py @@ -0,0 +1,69 @@ +from enum import Enum +from typing import Any, Optional, TypedDict + +import httpx +from httpx import Response + +from solapi.lib.authenticator import AuthenticationParameter, Authenticator + + +class RequestMethod(str, Enum): + GET = "GET" + POST = "POST" + PUT = "PUT" + DELETE = "DELETE" + + +class RequestParameterType(TypedDict): + method: RequestMethod + url: str + + +def default_fetcher( + auth_parameter: AuthenticationParameter, + request: RequestParameterType, + data: Optional[Any] = None, +): + """ + Args: + auth_parameter: API Key, Api Secret Key Dictionary. 반드시 활성화된 유효한 API Key, API Secret Key를 입력하셔야 합니다. + request: HTTP Request를 위한 파라미터, 정확한 API URL, HTTP Method를 입력해주세요! + data: 발송 혹은 API 요청 시 전달해야 할 파라미터 목록, HTTP GET API를 이용하면 None 타입으로 지정됩니다. + + Returns: + 정상 작동할 경우 JSON 데이터를 반환합니다. + """ + authorization_header_data = Authenticator( + auth_parameter["api_key"], auth_parameter["api_secret"] + ).get_auth_info() + headers = { + "Authorization": authorization_header_data, + "Content-Type": "application/json", + "Connection": "keep-alive", + } + + transport = httpx.HTTPTransport(retries=3) + + with httpx.Client(transport=transport) as client: + response: Response = client.request( + method=request["method"], + url=request["url"], + headers=headers, + json=data, + ) + + # 4xx 에러 처리: 클라이언트 오류일 경우 + if 400 <= response.status_code < 500: + error_response: dict[str, Any] = response.json() + raise Exception( + error_response.get("errorCode", "UnknownError"), + error_response.get("errorMessage", "An Error occurred"), + ) + # 5xx 에러 처리: 서버 오류일 경우 + elif response.status_code >= 500: + raise Exception("UnknownError", response.text) + + try: + return response.json() + except Exception as exc: + raise Exception(response.text) from exc diff --git a/solapi/lib/string_date_transfer.py b/solapi/lib/string_date_transfer.py new file mode 100644 index 0000000..ea27406 --- /dev/null +++ b/solapi/lib/string_date_transfer.py @@ -0,0 +1,115 @@ +import re +import time +from datetime import datetime, timedelta, timezone +from typing import Union + + +class InvalidDateError(Exception): + """날짜 형식이 유효하지 않을 때 발생하는 예외""" + + def __init__(self, message="Invalid date format", *args): + self.message = message + super().__init__(message, *args) + + def __str__(self): + return self.message + + +def format_iso(date: datetime) -> str: + """ + datetime 객체를 ISO 8601 형식의 문자열로 변환 + + Args: + date: 변환할 datetime 객체 + + Returns: + ISO 8601 형식의 문자열 (예: '2023-01-01T12:00:00Z') + """ + utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone + utc_offset = timedelta(seconds=-utc_offset_sec) + local_tz = timezone(offset=utc_offset) + + if date.tzinfo is None: + date = date.replace(tzinfo=local_tz) + else: + date = date.astimezone(local_tz) + + return date.isoformat() + + +def parse_iso(date_string: str) -> datetime: + """ + ISO 8601 형식의 문자열을 datetime 객체로 변환 + + Args: + date_string: 변환할 ISO 8601 형식의 문자열 + + Returns: + 변환된 datetime 객체 + + Raises: + InvalidDateError: 날짜 형식이 유효하지 않을 경우 + """ + try: + # ISO 8601 형식 검증을 위한 간단한 정규식 + iso_pattern = re.compile( + r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?(([+-]\d{2}:\d{2})|Z)?)?$" + ) + if not iso_pattern.match(date_string): + raise ValueError("Invalid ISO format") + + # 타임존 정보가 없는 경우 처리 + if ( + "Z" not in date_string + and "+" not in date_string + and "-" not in date_string[10:] + ): + date_string += "Z" + + return datetime.fromisoformat(date_string.replace("Z", "+00:00")) + except ValueError as e: + raise InvalidDateError("Invalid Date") from e + + +def string_date_transfer(value: Union[str, datetime]): + """ + 일반 문자열 날짜가 있을 경우 datetime 타입으로 변환해주는 함수 + + Args: + value: 일반 문자열 날짜 또는 datetime 타입의 날짜 + + Returns: + datetime 객체 + + Raises: + InvalidDateError: 날짜 형식이 유효하지 않을 경우 + """ + if isinstance(value, str): + try: + value = parse_iso(value) + except InvalidDateError: # parse_iso가 실패한 경우 + try: + # "YYYY-MM-DD HH:MM:SS" 형식 시도 + value = datetime.strptime(value, "%Y-%m-%d %H:%M:%S") + except ValueError as e: + # 두 형식 모두 실패한 경우 원래 에러 발생 + raise InvalidDateError( + "Invalid Date format. Expected ISO 8601 or YYYY-MM-DD HH:MM:SS" + ) from e + return value + + +def format_with_transfer(value: Union[str, datetime]) -> str: + """ + string_date_transfer와 format_iso를 한번에 실행하는 함수 + + Args: + value: datetime 타입의 날짜 또는 ISO 형식의 문자열 + + Returns: + ISO 8601 형식의 문자열 + + Raises: + InvalidDateError: 날짜 형식이 유효하지 않을 경우 + """ + return format_iso(string_date_transfer(value)) 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/__init__.py b/solapi/model/__init__.py new file mode 100644 index 0000000..8243ebe --- /dev/null +++ b/solapi/model/__init__.py @@ -0,0 +1,19 @@ +from .kakao.kakao_option import KakaoOption +from .message_type import MessageType +from .request.groups.get_groups import GetGroupsRequest +from .request.kakao.bms import Bms +from .request.message import Message as RequestMessage +from .request.send_message_request import SendRequestConfig +from .request.voice.voice_option import VoiceOption +from .response.message import Message as ResponseMessage + +__all__ = [ + "RequestMessage", + "ResponseMessage", + "SendRequestConfig", + "KakaoOption", + "GetGroupsRequest", + "MessageType", + "Bms", + "VoiceOption", +] diff --git a/src/examples/modules/__init__.py b/solapi/model/kakao/__init__.py similarity index 100% rename from src/examples/modules/__init__.py rename to solapi/model/kakao/__init__.py 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/kakao/kakao_button.py b/solapi/model/kakao/kakao_button.py new file mode 100644 index 0000000..820db5d --- /dev/null +++ b/solapi/model/kakao/kakao_button.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class KakaoButton(BaseModel): + link_mo: Optional[str] = None + link_pc: Optional[str] = None + button_name: Optional[str] = None + # TODO: 추후 타입 추가 필요 + button_type: Optional[str] = None + link_and: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/kakao/kakao_option.py b/solapi/model/kakao/kakao_option.py new file mode 100644 index 0000000..1c977ea --- /dev/null +++ b/solapi/model/kakao/kakao_option.py @@ -0,0 +1,33 @@ +from collections.abc import Mapping +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator +from pydantic.alias_generators import to_camel + +from solapi.model.request.kakao.bms import Bms + + +class KakaoOption(BaseModel): + pf_id: Optional[str] = None + template_id: Optional[str] = None + variables: Optional[dict[str, str]] = None + disable_sms: bool = False + image_id: Optional[str] = None + bms: Optional[Bms] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + @field_validator("variables", mode="before") + @classmethod + def stringify_values(cls, v: Mapping[str, object]): + if isinstance(v, Mapping): + # 키값을 #{변수명} 형태로 변환하고 모든 value를 str로 캐스팅 + processed_dict = {} + for k, val in v.items(): + # 키가 이미 #{변수명} 형태가 아니면 자동으로 감싸기 + if not (k.startswith("#{") and k.endswith("}")): + processed_key = f"#{{{k}}}" + else: + processed_key = k + processed_dict[processed_key] = str(val) + return processed_dict diff --git a/solapi/model/message_type.py b/solapi/model/message_type.py new file mode 100644 index 0000000..652b5e2 --- /dev/null +++ b/solapi/model/message_type.py @@ -0,0 +1,62 @@ +from enum import Enum + + +class MessageType(Enum): + """ + 메시지 유형(단문 문자, 장문 문자, 알림톡 등) + SMS: 단문 문자 + LMS: 장문 문자 + MMS: 사진 문자 + ATA: 알림톡 + CTA: 친구톡 + CTI: 사진 한장이 포함된 친구톡 + NSA: 네이버 스마트알림(톡톡) + RCS_SMS: RCS 단문 문자 + RCS_LMS: RCS 장문 문자 + RCS_MMS: RCS 사진 문자 + RCS_TPL: RCS 템플릿 + RCS_ITPL: RCS 이미지 템플릿 + 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" + LMS = "LMS" + MMS = "MMS" + ATA = "ATA" + CTA = "CTA" + CTI = "CTI" + NSA = "NSA" + RCS_SMS = "RCS_SMS" + RCS_LMS = "RCS_LMS" + RCS_MMS = "RCS_MMS" + RCS_TPL = "RCS_TPL" + RCS_ITPL = "RCS_ITPL" + 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 + + def __repr__(self): + return repr(self.value) diff --git a/solapi/model/naver/naver_button.py b/solapi/model/naver/naver_button.py new file mode 100644 index 0000000..f020783 --- /dev/null +++ b/solapi/model/naver/naver_button.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class NaverButton(BaseModel): + button_name: Optional[str] = None + button_type: Optional[str] = None + link_mo: Optional[str] = None + link_pc: Optional[str] = None + link_and: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) diff --git a/solapi/model/naver/naver_option.py b/solapi/model/naver/naver_option.py new file mode 100644 index 0000000..8de9ee4 --- /dev/null +++ b/solapi/model/naver/naver_option.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from solapi.model.naver.naver_button import NaverButton + + +class NaverOption(BaseModel): + talk_id: Optional[str] = None + template_id: Optional[str] = None + disable_sms: Optional[bool] = None + variables: Optional[dict[str, str]] = None + buttons: Optional[list[NaverButton]] = None + + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) diff --git a/src/examples/modules/group/__init__.py b/solapi/model/rcs/__init__.py similarity index 100% rename from src/examples/modules/group/__init__.py rename to solapi/model/rcs/__init__.py diff --git a/solapi/model/rcs/rcs_options.py b/solapi/model/rcs/rcs_options.py new file mode 100644 index 0000000..8663a32 --- /dev/null +++ b/solapi/model/rcs/rcs_options.py @@ -0,0 +1,88 @@ +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class RcsMmsType(str, Enum): + """ + 사진 문자 타입. 타입: "M3", "S3", "M4", "S4", "M5", "S5", "M6", "S6" (M: 중간 사이즈. S: 작은 사이즈. 숫자: 사진 개수) + """ + + S3 = "S3" + S4 = "S4" + S5 = "S5" + S6 = "S6" + M3 = "M3" + M4 = "M4" + M5 = "M5" + M6 = "M6" + + def __str__(self) -> str: + return self.value + + def __repr__(self): + return repr(self.value) + + +class RcsButtonType(str, Enum): + """ + 'WL'(웹링크), 'ML'(지도[좌표]), 'MQ'(지도[쿼리]), 'MR'(위치공유), + 'CA'(캘린더생성), 'CL'(복사), 'DL'(전화걸기), 'MS'(메시지보내기) + """ + + WL = "WL" + ML = "ML" + MR = "MR" + CA = "CA" + CL = "CL" + DL = "DL" + MS = "MS" + + def __str__(self) -> str: + return self.value + + def __repr__(self): + return repr(self.value) + + +class RcsButton(BaseModel): + button_type: RcsButtonType + button_name: str + link: Optional[str] = None + latitude: Optional[str] = None + longitude: Optional[str] = None + label: Optional[str] = None + query: Optional[str] = None + title: Optional[str] = None + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + text: Optional[str] = None + phone: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class RcsAdditionalBody(BaseModel): + title: str + description: str + image_id: Optional[str] = None + buttons: Optional[list[RcsButton]] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class RcsOption(BaseModel): + brand_id: Optional[str] = None + template_id: Optional[str] = None + copy_allowed: Optional[bool] = None + variables: Optional[dict[str, str]] = None + mms_type: Optional[RcsMmsType] = None + commercial_type: Optional[bool] = None + disable_sms: Optional[bool] = False + additional_body: Optional[list[RcsAdditionalBody]] = None + buttons: Optional[list[RcsButton]] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/request/__init__.py b/solapi/model/request/__init__.py new file mode 100644 index 0000000..7d920d8 --- /dev/null +++ b/solapi/model/request/__init__.py @@ -0,0 +1,2 @@ +# NOTE: Python SDK가 업데이트 될 때마다 Version도 갱신해야 함! +VERSION = "python/5.0.3" diff --git a/solapi/model/request/groups/get_groups.py b/solapi/model/request/groups/get_groups.py new file mode 100644 index 0000000..eb43537 --- /dev/null +++ b/solapi/model/request/groups/get_groups.py @@ -0,0 +1,42 @@ +from datetime import datetime +from enum import Enum +from typing import Optional, Union + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class GetGroupsRequest(BaseModel): + start_key: Optional[str] = None + limit: int = 20 + start_date: Optional[Union[str, datetime]] = None + end_date: Optional[Union[str, datetime]] = None + group_id: Optional[str] = Field(default=None, exclude=True) + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class GetGroupsCriteriaType(str, Enum): + group_id = "groupId" + date_created = "dateCreated" + scheduled_date = "scheduledDate" + + def __str__(self) -> str: + return self.value + + def __repr__(self): + return repr(self.value) + + # TODO: count.total, count.sentTotal 추후에 추가해야 함 + + +class GetGroupsFinalizeRequest(BaseModel): + start_key: Optional[str] = None + limit: int = 20 + start_date: Optional[Union[str, datetime]] = None + end_date: Optional[Union[str, datetime]] = None + criteria: Optional[str] = None + cond: Optional[str] = None + value: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/request/kakao/bms.py b/solapi/model/request/kakao/bms.py new file mode 100644 index 0000000..f17e6b6 --- /dev/null +++ b/solapi/model/request/kakao/bms.py @@ -0,0 +1,53 @@ +from typing import 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_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: 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/kakao/kakao_option.py b/solapi/model/request/kakao/kakao_option.py new file mode 100644 index 0000000..42f583a --- /dev/null +++ b/solapi/model/request/kakao/kakao_option.py @@ -0,0 +1,19 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from solapi.model.kakao.kakao_button import KakaoButton +from solapi.model.request.kakao.bms import Bms + + +class KakaoOption(BaseModel): + pf_id: str + template_id: Optional[str] = None + variables: Optional[dict[str, str]] = None + disable_sms: bool = False + image_id: Optional[str] = None + buttons: Optional[list[KakaoButton]] = None + bms: Optional[Bms] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/request/message.py b/solapi/model/request/message.py new file mode 100644 index 0000000..c5fa214 --- /dev/null +++ b/solapi/model/request/message.py @@ -0,0 +1,85 @@ +from typing import Any, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic.alias_generators import to_camel + +from solapi.model import KakaoOption +from solapi.model.message_type import MessageType +from solapi.model.rcs.rcs_options import RcsOption +from solapi.model.request.voice.voice_option import VoiceOption + + +class FileIdsType(BaseModel): + file_ids: Optional[list[str]] = None + + +class Message(BaseModel): + from_: Optional[str] = Field( + default=None, serialization_alias="from", validation_alias="from" + ) + to: Union[str, list[str]] + text: Optional[str] = None + image_id: Optional[str] = Field( + default=None, serialization_alias="imageId", validation_alias="imageId" + ) + country: str = "82" + message_id: Optional[str] = Field( + default=None, serialization_alias="messageId", validation_alias="messageId" + ) + group_id: Optional[str] = Field( + default=None, serialization_alias="groupId", validation_alias="groupId" + ) + type: Optional[MessageType] = None + auto_type_detect: Optional[bool] = Field( + default=True, + serialization_alias="autoTypeDetect", + validation_alias="autoTypeDetect", + ) + subject: Optional[str] = None + replacements: Optional[list[dict[str, Any]]] = None + custom_fields: Optional[dict[str, str]] = Field( + default=None, + serialization_alias="customFields", + validation_alias="customFields", + ) + kakao_options: Optional[KakaoOption] = Field( + default=None, + serialization_alias="kakaoOptions", + validation_alias="kakaoOptions", + ) + rcs_options: Optional[RcsOption] = Field( + default=None, serialization_alias="rcsOptions", validation_alias="rcsOptions" + ) + fax_options: Optional[FileIdsType] = Field( + default=None, serialization_alias="faxOptions", validation_alias="faxOptions" + ) + voice_options: Optional[VoiceOption] = Field( + default=None, + serialization_alias="voiceOptions", + validation_alias="voiceOptions", + ) + + @field_validator("from_", mode="before") + @classmethod + def normalize_from_phone_number(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + return v.replace("-", "") + + @field_validator("to", mode="before") + @classmethod + def normalize_to_phone_number( + cls, v: Union[str, list[str]] + ) -> Union[str, list[str]]: + if isinstance(v, str): + return v.replace("-", "") + elif isinstance(v, list): + return [phone.replace("-", "") for phone in v] + return v + + model_config = ConfigDict( + extra="ignore", + populate_by_name=True, + alias_generator=to_camel, + use_enum_values=True, + ) diff --git a/solapi/model/request/messages/get_messages.py b/solapi/model/request/messages/get_messages.py new file mode 100644 index 0000000..85246ee --- /dev/null +++ b/solapi/model/request/messages/get_messages.py @@ -0,0 +1,34 @@ +from datetime import datetime +from typing import Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic.alias_generators import to_camel + +from solapi.lib.string_date_transfer import format_with_transfer +from solapi.model.message_type import MessageType + + +class GetMessagesRequest(BaseModel): + start_key: Optional[str] = None + limit: Optional[int] = 20 + message_id: Optional[str] = None + message_ids: Optional[list[str]] = None + group_id: Optional[str] = None + from_: Optional[str] = Field(default=None, serialization_alias="from") + to: Optional[str] = None + type: Optional[MessageType] = None + status_code: Optional[str] = None + date_type: Optional[str] = "CREATED" # or "UPDATED" + start_date: Optional[Union[str, datetime]] = None + end_date: Optional[Union[str, datetime]] = None + + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) + + @field_validator("start_date", "end_date", mode="before") + @classmethod + def format_dates( + cls, value: Union[str, datetime, None] + ) -> Union[str, datetime, None]: + if isinstance(value, str): + return format_with_transfer(value) + return value diff --git a/solapi/model/request/send_message_request.py b/solapi/model/request/send_message_request.py new file mode 100644 index 0000000..a71ad0d --- /dev/null +++ b/solapi/model/request/send_message_request.py @@ -0,0 +1,33 @@ +import platform +from datetime import datetime +from typing import Optional, Union + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from solapi.model.request import VERSION +from solapi.model.request.message import Message + + +class SendRequestConfig(BaseModel): + app_id: Optional[str] = None + allow_duplicates: bool = False + show_message_list: bool = False + scheduled_date: Optional[Union[str, datetime]] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class SendMessageRequest(BaseModel): + messages: list[Message] + scheduled_date: Optional[str] = None + show_message_list: Optional[bool] = None + allow_duplicates: bool = False + app_id: Optional[str] = None + + agent: dict[str, str] = { + "sdkVersion": VERSION, + "osPlatform": f"{platform.platform()} | {platform.python_version()}", + } + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/request/storage.py b/solapi/model/request/storage.py new file mode 100644 index 0000000..dff6bbd --- /dev/null +++ b/solapi/model/request/storage.py @@ -0,0 +1,25 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + + +class FileTypeEnum(str, Enum): + MMS = "MMS" + 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): + type: FileTypeEnum + file: str + name: Optional[str] = None + link: Optional[str] = None diff --git a/solapi/model/request/voice/voice_option.py b/solapi/model/request/voice/voice_option.py new file mode 100644 index 0000000..e3fbba2 --- /dev/null +++ b/solapi/model/request/voice/voice_option.py @@ -0,0 +1,26 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict, model_validator +from pydantic.alias_generators import to_camel + + +class VoiceOption(BaseModel): + voice_type: Literal["FEMALE", "MALE"] + header_message: Optional[str] = None + tail_message: Optional[str] = None + reply_range: Optional[Literal[1, 2, 3, 4, 5, 6, 7, 8, 9]] = None + counselor_number: Optional[str] = None + + @model_validator(mode="after") + def check_exclusive_fields(self) -> "VoiceOption": + if self.reply_range is not None and self.counselor_number is not None: + raise ValueError( + "reply_range와 counselor_number는 같이 사용할 수 없습니다." + ) + return self + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + extra="ignore", + ) diff --git a/src/examples/modules/kakaotalk/__init__.py b/solapi/model/response/__init__.py similarity index 100% rename from src/examples/modules/kakaotalk/__init__.py rename to solapi/model/response/__init__.py diff --git a/solapi/model/response/balance/get_balance.py b/solapi/model/response/balance/get_balance.py new file mode 100644 index 0000000..28cc123 --- /dev/null +++ b/solapi/model/response/balance/get_balance.py @@ -0,0 +1,8 @@ +from typing import Optional + +from pydantic import BaseModel + + +class GetBalanceResponse(BaseModel): + balance: Optional[float] = None + point: Optional[float] = None diff --git a/solapi/model/response/common_response.py b/solapi/model/response/common_response.py new file mode 100644 index 0000000..162a88f --- /dev/null +++ b/solapi/model/response/common_response.py @@ -0,0 +1,106 @@ +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class CountResponse(BaseModel): + total: int + sent_total: int + sent_success: int + sent_pending: int + sent_replacement: int + refund: int + registered_failed: int + registered_success: int + + model_config = ConfigDict( + extra="ignore", alias_generator=to_camel, populate_by_name=True + ) + + +class CommonCashResponse(BaseModel): + requested: float + replacement: float + refund: float + sum: float + + model_config = ConfigDict(extra="ignore") + + +class CommonPriceTypeResponse(BaseModel): + sms: Optional[dict[str, float]] = None + lms: Optional[dict[str, float]] = None + mms: Optional[dict[str, float]] = None + ata: Optional[dict[str, float]] = None + cta: Optional[dict[str, float]] = None + cti: Optional[dict[str, float]] = None + nsa: Optional[dict[str, float]] = None + rcs_sms: Optional[dict[str, float]] = None + rcs_lms: Optional[dict[str, float]] = None + rcs_mms: Optional[dict[str, float]] = None + rcs_tpl: Optional[dict[str, float]] = None + rcs_itpl: Optional[dict[str, float]] = None + rcs_ltpl: Optional[dict[str, float]] = None + fax: Optional[dict[str, float]] = None + voice: Optional[dict[str, float]] = None + + model_config = ConfigDict(extra="ignore") + + +class EachTypePriceResponse(BaseModel): + sms: Optional[float] = None + lms: Optional[float] = None + mms: Optional[float] = None + ata: Optional[float] = None + cta: Optional[float] = None + cti: Optional[float] = None + nsa: Optional[float] = None + rcs_sms: Optional[float] = None + rcs_lms: Optional[float] = None + rcs_mms: Optional[float] = None + rcs_tpl: Optional[float] = None + rcs_itpl: Optional[float] = None + rcs_ltpl: Optional[float] = None + fax: Optional[float] = None + voice: Optional[float] = None + + model_config = ConfigDict(extra="ignore") + + +class AppResponse(BaseModel): + profit: Optional[EachTypePriceResponse] = None + app_id: Optional[str] = None + + model_config = ConfigDict( + extra="ignore", alias_generator=to_camel, populate_by_name=True + ) + + +class GroupMessageResponse(BaseModel): + count: CountResponse + count_for_charge: CommonPriceTypeResponse + balance: CommonCashResponse + point: CommonCashResponse + app: AppResponse + log: list[dict[str, Any]] + status: str + allow_duplicates: bool + is_refunded: bool + account_id: str + master_account_id: Optional[str] + api_version: str + group_id: str + price: dict[str, EachTypePriceResponse] + date_created: Optional[datetime] + date_updated: Optional[datetime] + scheduled_date: Optional[datetime] = None + date_sent: Optional[datetime] = None + date_completed: Optional[datetime] = None + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + extra="ignore", + ) diff --git a/solapi/model/response/groups/get_group_messages.py b/solapi/model/response/groups/get_group_messages.py new file mode 100644 index 0000000..f5e53f5 --- /dev/null +++ b/solapi/model/response/groups/get_group_messages.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from solapi.model.response.message import Message + + +class GetGroupMessagesResponse(BaseModel): + start_key: Optional[str] = None + next_key: Optional[str] = None + limit: int + message_list: dict[str, Message] = {} + + model_config = ConfigDict( + alias_generator=to_camel, populate_by_name=True, extra="ignore" + ) diff --git a/solapi/model/response/groups/get_groups.py b/solapi/model/response/groups/get_groups.py new file mode 100644 index 0000000..d7e889b --- /dev/null +++ b/solapi/model/response/groups/get_groups.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from solapi.model.response.common_response import GroupMessageResponse + + +class GetGroupsResponse(BaseModel): + start_key: Optional[str] = None + limit: Optional[int] = None + next_key: Optional[str] = None + group_list: dict[str, GroupMessageResponse] + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/response/message.py b/solapi/model/response/message.py new file mode 100644 index 0000000..d74e8c6 --- /dev/null +++ b/solapi/model/response/message.py @@ -0,0 +1,71 @@ +from datetime import datetime +from typing import Any, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from solapi.model import KakaoOption +from solapi.model.message_type import MessageType +from solapi.model.rcs.rcs_options import RcsOption + + +class FileIdsType(BaseModel): + file_ids: Optional[list[str]] = None + + +class Message(BaseModel): + from_: Optional[str] = Field( + default=None, serialization_alias="from", validation_alias="from" + ) + to: Union[str, list[str]] + text: Optional[str] = None + image_id: Optional[str] = Field( + default=None, serialization_alias="imageId", validation_alias="imageId" + ) + country: str = "82" + message_id: Optional[str] = Field( + default=None, serialization_alias="messageId", validation_alias="messageId" + ) + group_id: Optional[str] = Field( + default=None, serialization_alias="groupId", validation_alias="groupId" + ) + type: Optional[MessageType] = None + auto_type_detect: Optional[bool] = Field( + default=True, + serialization_alias="autoTypeDetect", + validation_alias="autoTypeDetect", + ) + subject: Optional[str] = None + replacements: Optional[list[dict[str, Any]]] = None + custom_fields: Optional[dict[str, str]] = Field( + default=None, + serialization_alias="customFields", + validation_alias="customFields", + ) + kakao_options: Optional[KakaoOption] = Field( + default=None, + serialization_alias="kakaoOptions", + validation_alias="kakaoOptions", + ) + rcs_options: Optional[RcsOption] = Field( + default=None, serialization_alias="rcsOptions", validation_alias="rcsOptions" + ) + fax_options: Optional[FileIdsType] = Field( + default=None, serialization_alias="faxOptions", validation_alias="faxOptions" + ) + date_created: Optional[datetime] = Field( + default=None, serialization_alias="dateCreated", validation_alias="dateCreated" + ) + date_updated: Optional[datetime] = Field( + default=None, serialization_alias="dateUpdated", validation_alias="dateUpdated" + ) + log: Optional[list[dict[str, Any]]] = None + status_code: Optional[str] = Field( + default=None, serialization_alias="statusCode", validation_alias="statusCode" + ) + + model_config = ConfigDict( + extra="ignore", + populate_by_name=True, + alias_generator=to_camel, + ) diff --git a/solapi/model/response/messages/get_messages.py b/solapi/model/response/messages/get_messages.py new file mode 100644 index 0000000..91fcb02 --- /dev/null +++ b/solapi/model/response/messages/get_messages.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from solapi.model.response.message import Message + + +class GetMessagesResponse(BaseModel): + limit: int + message_list: dict[str, Message] + start_key: Optional[str] = None + next_key: Optional[str] = None + + model_config = ConfigDict( + alias_generator=to_camel, populate_by_name=True, extra="ignore" + ) diff --git a/solapi/model/response/send_message_response.py b/solapi/model/response/send_message_response.py new file mode 100644 index 0000000..61b6fbe --- /dev/null +++ b/solapi/model/response/send_message_response.py @@ -0,0 +1,39 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from solapi.model.response.common_response import GroupMessageResponse + + +class MessageItem(BaseModel): + message_id: str + status_code: str + status_message: str + custom_fields: Optional[dict[str, str]] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class FailedMessage(BaseModel): + to: Optional[str] + from_: Optional[str] = Field( + ..., serialization_alias="from", validation_alias="from" + ) + type: str + status_message: str = Field(..., alias="statusMessage") + country: Optional[str] + message_id: str = Field(..., alias="messageId") + status_code: str = Field(..., alias="statusCode") + account_id: str = Field(..., alias="accountId") + custom_fields: Optional[dict[str, str]] = Field(default=None, alias="customFields") + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + +class SendMessageResponse(BaseModel): + failed_message_list: Optional[list[FailedMessage]] = None + group_info: GroupMessageResponse + message_list: Optional[list[MessageItem]] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/response/storage.py b/solapi/model/response/storage.py new file mode 100644 index 0000000..10259e0 --- /dev/null +++ b/solapi/model/response/storage.py @@ -0,0 +1,30 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class KakaoFileUploadResponseProperty(BaseModel): + daou: Optional[str] = None + biztalk: Optional[str] = None + + model_config = ConfigDict(extra="ignore") + + +class FileUploadResponse(BaseModel): + kakao: Optional[KakaoFileUploadResponseProperty] = None + type: str + original_name: str + link: Optional[str] = None + file_id: str + name: str + url: str + account_id: str + references: Optional[list[str]] = None + date_created: Optional[datetime] = None + date_updated: Optional[datetime] = None + + model_config = ConfigDict( + alias_generator=to_camel, populate_by_name=True, extra="ignore" + ) diff --git a/solapi/model/webhook/group_report.py b/solapi/model/webhook/group_report.py new file mode 100644 index 0000000..86b697c --- /dev/null +++ b/solapi/model/webhook/group_report.py @@ -0,0 +1,20 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict, RootModel +from pydantic.alias_generators import to_camel + +from solapi.model.response.common_response import GroupMessageResponse + + +class GroupReport(BaseModel): + event_data_id: Optional[str] = None + data: Optional[GroupMessageResponse] = None + retry_count: Optional[int] = None + + model_config = ConfigDict( + populate_by_name=True, alias_generator=to_camel, extra="ignore" + ) + + +class GroupReportPayload(RootModel[list[GroupReport]]): + pass diff --git a/solapi/model/webhook/single_report.py b/solapi/model/webhook/single_report.py new file mode 100644 index 0000000..4356abd --- /dev/null +++ b/solapi/model/webhook/single_report.py @@ -0,0 +1,80 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, RootModel +from pydantic.alias_generators import to_camel + +from solapi.model.kakao.kakao_option import KakaoOption +from solapi.model.naver.naver_option import NaverOption +from solapi.model.rcs.rcs_options import RcsOption + + +class SingleReportData(BaseModel): + message_id: Optional[str] = Field( + default=None, validation_alias="messageId", serialization_alias="messageId" + ) + group_id: Optional[str] = Field( + default=None, validation_alias="groupId", serialization_alias="groupId" + ) + type: Optional[str] = None + to: Optional[str] = None + from_: Optional[str] = Field( + default=None, validation_alias="from", serialization_alias="from" + ) + status_code: Optional[str] = Field( + default=None, validation_alias="statusCode", serialization_alias="statusCode" + ) + date_processed: Optional[str] = Field( + default=None, + validation_alias="dateProcessed", + serialization_alias="dateProcessed", + ) + date_reported: Optional[str] = Field( + default=None, + validation_alias="dateReported", + serialization_alias="dateReported", + ) + date_received: Optional[str] = Field( + default=None, + validation_alias="dateReceived", + serialization_alias="dateReceived", + ) + network_code: Optional[str] = Field( + default=None, validation_alias="networkCode", serialization_alias="networkCode" + ) + kakao_options: Optional[KakaoOption] = Field( + default=None, + validation_alias="kakaoOptions", + serialization_alias="kakaoOptions", + ) + rcs_options: Optional[RcsOption] = Field( + default=None, validation_alias="rcsOptions", serialization_alias="rcsOptions" + ) + naver_options: Optional[NaverOption] = Field( + default=None, + validation_alias="naverOptions", + serialization_alias="naverOptions", + ) + custom_fields: Optional[dict] = Field( + default={}, validation_alias="customFields", serialization_alias="customFields" + ) + status_message: Optional[str] = Field( + default=None, + validation_alias="statusMessage", + serialization_alias="statusMessage", + ) + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + +class SingleReport(BaseModel): + event_data_id: str = None + data: SingleReportData + retry_count: Optional[int] = None + + model_config = ConfigDict( + populate_by_name=True, alias_generator=to_camel, extra="ignore" + ) + + +class SingleReportPayload(RootModel[list[SingleReport]]): + pass diff --git a/src/examples/modules/naver/__init__.py b/solapi/services/__init__.py similarity index 100% rename from src/examples/modules/naver/__init__.py rename to solapi/services/__init__.py diff --git a/solapi/services/message_service.py b/solapi/services/message_service.py new file mode 100644 index 0000000..0d8a5b9 --- /dev/null +++ b/solapi/services/message_service.py @@ -0,0 +1,317 @@ +import base64 +from datetime import datetime +from pathlib import Path +from typing import Optional, Union +from urllib.parse import urlencode + +from solapi.error.MessageNotReceiveError import MessageNotReceivedError +from solapi.lib.authenticator import AuthenticationParameter +from solapi.lib.fetcher import RequestMethod, default_fetcher +from solapi.lib.string_date_transfer import format_with_transfer +from solapi.model.request.groups.get_groups import ( + GetGroupsCriteriaType, + GetGroupsFinalizeRequest, + GetGroupsRequest, +) +from solapi.model.request.message import Message as RequestMessage +from solapi.model.request.messages.get_messages import GetMessagesRequest +from solapi.model.request.send_message_request import ( + SendMessageRequest, + SendRequestConfig, +) +from solapi.model.request.storage import FileTypeEnum, FileUploadRequest +from solapi.model.response.balance.get_balance import GetBalanceResponse +from solapi.model.response.common_response import GroupMessageResponse +from solapi.model.response.groups.get_group_messages import GetGroupMessagesResponse +from solapi.model.response.groups.get_groups import GetGroupsResponse +from solapi.model.response.messages.get_messages import GetMessagesResponse +from solapi.model.response.send_message_response import SendMessageResponse +from solapi.model.response.storage import FileUploadResponse + + +class SolapiMessageService: + """Solapi Message Service for handling message-related operations. + + This class provides methods to interact with the Solapi API for sending various types of messages + (SMS, LMS, MMS, 알림톡, 친구톡, RCS, etc.), managing message groups, uploading files, + and checking account balance. + """ + + def __init__(self, api_key: str, api_secret: str): + """Initialize the Solapi Message Service. + + Args: + api_key: The API key for authentication. + api_secret: The API secret for authentication. + """ + self.base_url = "https://api.solapi.com" + self.auth_info: AuthenticationParameter = { + "api_key": api_key, + "api_secret": api_secret, + } + + def send( + self, + messages: Union[list[RequestMessage], RequestMessage], + request_config: Optional[SendRequestConfig] = None, + ) -> SendMessageResponse: + """Send one or more messages using the Solapi API. + + This method sends various types of messages (SMS, LMS, MMS, 알림톡, 친구톡, RCS, etc.) to recipients. + It supports sending a single message or multiple messages in one request (대량 발송). + + Args: + messages: A single RequestMessage object or a list of RequestMessage objects to send. + Each RequestMessage object contains recipient (수신번호), sender (발신번호), content (내용), + and other message details. + request_config: Optional configuration for the send request. + Can include app_id, allow_duplicates (중복 수신번호 허용), + scheduled_date (예약 발송), and other settings. + + Returns: + SendMessageResponse: The response from the API containing message status, group info, + and other details about the sent messages. + + Raises: + TypeError: If messages parameter is not a RequestMessage object or list of RequestMessage objects. + ValueError: If no valid messages are provided. + MessageNotReceivedError: If all messages failed to be registered. + """ + payload = [] + if isinstance(messages, RequestMessage): + payload.append(messages) + elif isinstance(messages, list): + for message in messages: + if isinstance(message, RequestMessage) is not True: + raise TypeError( + "The messages parameter must be an instance of RequestMessage." + ) + + payload.extend(messages) + + if len(payload) == 0: + raise ValueError("The data must have at least one message.") + + request = SendMessageRequest(messages=payload) + if request_config is not None: + request.app_id = request_config.app_id + request.allow_duplicates = request_config.allow_duplicates + request.show_message_list = request_config.show_message_list + + if request_config.scheduled_date is not None and ( + request_config.scheduled_date != "" + or isinstance(request_config.scheduled_date, datetime) + ): + request.scheduled_date = format_with_transfer( + request_config.scheduled_date + ) + response = default_fetcher( + self.auth_info, + request={ + "method": RequestMethod.POST, + "url": f"{self.base_url}/messages/v4/send-many/detail", + }, + data=request.model_dump(exclude_none=True, by_alias=True), + ) + deserialized_response: SendMessageResponse = SendMessageResponse.model_validate( + response + ) + + count = deserialized_response.group_info.count + failed_messages = deserialized_response.failed_message_list + registered_failed_count = count.registered_failed + if ( + failed_messages is not None + and len(failed_messages) > 0 + and count.total == registered_failed_count + ): + raise MessageNotReceivedError(failed_messages) from ValueError + + return deserialized_response + + def upload_file( + self, file_path: str, upload_type: FileTypeEnum = FileTypeEnum.MMS + ) -> FileUploadResponse: + """Upload a file to the Solapi storage service. + + This method uploads a file (such as an image) to be used in MMS (사진 문자) or other message types. + The file is encoded in base64 before being sent to the API. + + Args: + file_path: The path to the file to be uploaded (파일 경로). + upload_type: The type of file being uploaded, defaults to MMS (사진 문자용). + Other possible values are defined in FileTypeEnum. + + Returns: + FileUploadResponse: The response from the API containing the file ID (파일 ID) and other details. + + Raises: + FileNotFoundError: If the specified file path does not exist. + """ + path = Path(file_path) + with open(path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()) + + request = FileUploadRequest( + file=str(encoded_string)[2:-1], + type=upload_type, + ).model_dump(exclude_none=True) + response = default_fetcher( + self.auth_info, + request={ + "url": f"{self.base_url}/storage/v1/files", + "method": RequestMethod.POST, + }, + data=request, + ) + return FileUploadResponse.model_validate(response) + + def cancel_scheduled_message(self, group_id: str) -> GroupMessageResponse: + """Cancel a reserved message. + + This method cancels a message that was previously scheduled to be sent. + It requires the group_id of the message to be canceled. + """ + response = default_fetcher( + self.auth_info, + request={ + "url": f"{self.base_url}/messages/v4/groups/{group_id}/schedule", + "method": RequestMethod.DELETE, + }, + ) + return GroupMessageResponse.model_validate(response) + + def get_groups(self, query: Optional[GetGroupsRequest] = None) -> GetGroupsResponse: + """Retrieve message groups (메시지 그룹) from the Solapi API. + + This method fetches message groups based on the provided query parameters. + If no query is provided, it returns all message groups. + 메시지 그룹은 대량 발송 시 생성되는 메시지들의 묶음입니다. + + Args: + query: Optional query parameters to filter the groups. + Can include group_id (그룹 ID), criteria, cond, value, and other filters. + + Returns: + GetGroupsResponse: The response from the API containing the list of message groups + and other metadata. + """ + request = GetGroupsFinalizeRequest() + if query is not None: + request = request.model_copy(update=query.model_dump(exclude_unset=True)) + if query.group_id is not None and query.group_id != "": + request.criteria = GetGroupsCriteriaType.group_id + request.cond = "eq" + request.value = query.group_id + + encoded_request = urlencode(request.model_dump(exclude_none=True)) + if encoded_request != "": + encoded_request = "?" + encoded_request + + response = default_fetcher( + self.auth_info, + request={ + "url": f"{self.base_url}/messages/v4/groups{encoded_request}", + "method": RequestMethod.GET, + }, + ) + return GetGroupsResponse.model_validate(response) + + def get_group(self, group_id: str) -> GroupMessageResponse: + """Retrieve a specific message group (메시지 그룹) by its ID. + + This method fetches detailed information about a single message group. + 특정 그룹 ID로 메시지 그룹 정보를 조회합니다. + + Args: + group_id: The unique identifier (그룹 ID) of the message group to retrieve. + + Returns: + GroupMessageResponse: The response from the API containing the message group details. + """ + response = default_fetcher( + self.auth_info, + request={ + "url": f"{self.base_url}/messages/v4/groups/{group_id}", + "method": RequestMethod.GET, + }, + ) + return GroupMessageResponse.model_validate(response) + + def get_group_messages(self, group_id: str) -> GetGroupMessagesResponse: + """Retrieve all messages within a specific group (그룹 내 메시지 목록 조회). + + This method fetches all messages that belong to the specified message group. + 특정 메시지 그룹에 속한 모든 메시지를 조회합니다. + + Args: + group_id: The unique identifier (그룹 ID) of the message group whose messages should be retrieved. + + Returns: + GetGroupMessagesResponse: The response from the API containing the list of messages + in the group and other metadata. + """ + response = default_fetcher( + self.auth_info, + request={ + "url": f"{self.base_url}/messages/v4/groups/{group_id}/messages", + "method": RequestMethod.GET, + }, + ) + return GetGroupMessagesResponse.model_validate(response) + + def get_messages( + self, query: Optional[GetMessagesRequest] = None + ) -> GetMessagesResponse: + """Retrieve messages based on query parameters (메시지 목록 조회). + + This method fetches messages that match the specified query parameters. + If no query is provided, it returns messages based on default parameters. + 조건에 맞는 메시지 목록을 조회합니다. + + Args: + query: Optional query parameters to filter the messages. + Can include criteria for message status (상태), date range (기간), + recipient (수신번호), sender (발신번호), etc. + + Returns: + GetMessagesResponse: The response from the API containing the list of messages + and other metadata. + """ + request = GetMessagesRequest() + if query is not None: + request = request.model_copy(update=query.model_dump(exclude_unset=True)) + + encoded_request = urlencode( + request.model_dump(exclude_none=True, by_alias=True) + ) + if encoded_request != "": + encoded_request = "?" + encoded_request + + response = default_fetcher( + self.auth_info, + request={ + "url": f"{self.base_url}/messages/v4/list-old{encoded_request}", + "method": RequestMethod.GET, + }, + ) + return GetMessagesResponse.model_validate(response) + + def get_balance(self) -> GetBalanceResponse: + """Retrieve the account balance information (잔액 조회). + + This method fetches the current balance and point information for the account. + 계정의 현재 잔액과 포인트 정보를 조회합니다. + + Returns: + GetBalanceResponse: The response from the API containing the account balance details + including cash balance (잔액) and point balance (포인트). + """ + response = default_fetcher( + self.auth_info, + request={ + "url": f"{self.base_url}/cash/v1/balance", + "method": RequestMethod.GET, + }, + ) + return GetBalanceResponse.model_validate(response) diff --git a/src/examples/modules/README.md b/src/examples/modules/README.md deleted file mode 100644 index 6be2e4f..0000000 --- a/src/examples/modules/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# SOLAPI Python 기본 예제 - -src/lib/config.ini 파일을 생성하신 다음, 아래와 같이 설정 후 examples 아래 예제 코드를 실행해 보세요. - -``` -[AUTH] -# 계정의 API Key와 API Secret을 입력해주세요 -api_key = [API KEY] -api_secret = [API SECRET] - -[SERVER] -domain = api.solapi.com -protocol = https -prefix = -``` - -### 문자 발송 예제 - -``` -sms/send_sms.py SMS 발송 예제(해외 발송 포함) -sms/send_lms.py LMS 발송 예제 -sms/send_mms.py MMS 발송 예제 -sms/allow_duplicates.py 수신번호 중복 허용 예제 -rcs/send_rcs_sms.py RCS SMS 발송 예제(버튼 포함) -rcs/send_rcs_lms.py RCS LMS 발송 에제(버튼 포함) -``` - -### 카카오톡(알림톡, 친구톡) 발송 예제 - -``` -kakaotalk/send_alimtalk.py 알림톡 발송 예제 -kakaotalk/send_chingutalk.py 친구톡 발송 예제 -``` - -### 네이버톡톡 발송 예제 - -``` -send_naver.py 네이버톡톡 예제 -``` - -### 메시지 조회 - -``` -message_list.py 메시지 목록 조회 -``` - -### 기타 - -``` -senderid 발신번호 등록 및 인증 예제 -group 그룹발송 및 예약발송 예제 -storage MMS, 친구톡, RCS 파일(이미지) 관리 -``` diff --git a/src/examples/modules/group/README.md b/src/examples/modules/group/README.md deleted file mode 100644 index 349d5e9..0000000 --- a/src/examples/modules/group/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# 그룹 추가 및 그룹 내 대량 발송 예제 - -create_group.py를 제외한 나머지 예제들은 모두 groupId가 있는 것을 전제로 제작 되었습니다. -그룹 발송 시작 전 반드시 groupId를 체크 부탁드리며, groupId가 없는 경우 create_group.py 파일을 참고해주세요. - -### 발송 관련 예제 - -``` -add_group_message.py -> 그룹에 메시지 추가 예제, 즉시 발송 안됨 -add_group_message_with_image -> 그룹에 사진문자 추가 예제, 즉시 발송 안됨 -send_group_message.py -> 대기 상태인 그룹 건 즉시 발송 예제 -``` - -### 예약발송 관련 예제 - -``` -set_reservation_group.py -> 예약발송 설정 예제 -cancel_reservation_group -> 예약발송 취소 예제 -``` - -### 기타 - -``` -create_group.py -> 그룹 생성 예제 -delete_group.py -> 그룹 비활성화 예제 -get_group_info.py -> 단 건 그룹 조회 예제 -get_group_list.py -> 여러 그룹 건 조회 예제 -``` \ No newline at end of file diff --git a/src/examples/modules/group/add_group_message.py b/src/examples/modules/group/add_group_message.py deleted file mode 100644 index 28a7181..0000000 --- a/src/examples/modules/group/add_group_message.py +++ /dev/null @@ -1,29 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - data = { - 'messages': [ - { - 'to': '수신번호 입력', - 'from': '발신번호 입력', - 'text': '예제 메시지' - }, - { - 'to': '수신번호2 입력', - 'from': '발신번호2 입력', - 'text': '예제 메시지2' - }, - { - # 해외 문자 - 'country': '국가번호 입력', - 'to': '현지 수신번호', - 'from': '발신번호 입력', - 'text': 'Hello There' - } - ] - } - res = message.put('/messages/v4/groups/[INPUT_GROUP_ID]/messages', data=data) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/add_group_message_with_image.py b/src/examples/modules/group/add_group_message_with_image.py deleted file mode 100644 index 6e94c30..0000000 --- a/src/examples/modules/group/add_group_message_with_image.py +++ /dev/null @@ -1,28 +0,0 @@ -import json -from src.lib import message - -''' -사진 문자 그룹 추가 예제 -''' -if __name__ == '__main__': - # [GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - # [IMAGE_ID] 에 이미지 아이디를 넣어주세요 - data = { - 'messages': json.dumps([ - { - 'to': '수신번호 입력', - 'from': '발신번호 입력', - 'text': '발송 예제 #1', - 'imageId': '[IMAGE_ID]' - }, - { - 'to': '수신번호2 입력', - 'from': '발신번호2 입력', - 'text': '발송 예제 #2', - 'imageId': '[IMAGE_ID]' - } - ]) - } - res = message.put('/messages/v4/groups/[GROUP_ID]/messages', data=data) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/cancel_reservation_group.py b/src/examples/modules/group/cancel_reservation_group.py deleted file mode 100644 index f1c1888..0000000 --- a/src/examples/modules/group/cancel_reservation_group.py +++ /dev/null @@ -1,11 +0,0 @@ -import json -from src.lib import message - -''' -예약 발송 취소 예제, 이미 예약 발송이 설정되어 있어야 합니다. -''' -if __name__ == '__main__': - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - res = message.delete('/messages/v4/groups/[INPUT_GROUP_ID]/schedule', data=None) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/create_group.py b/src/examples/modules/group/create_group.py deleted file mode 100644 index 87a614b..0000000 --- a/src/examples/modules/group/create_group.py +++ /dev/null @@ -1,6 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - res = message.post('/messages/v4/groups', data=message.default_agent) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/delete_group_info.py b/src/examples/modules/group/delete_group_info.py deleted file mode 100644 index 99a1d52..0000000 --- a/src/examples/modules/group/delete_group_info.py +++ /dev/null @@ -1,8 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - res = message.delete('/messages/v4/groups/[INPUT_GROUP_ID]') - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/delete_group_message.py b/src/examples/modules/group/delete_group_message.py deleted file mode 100644 index 4c0a9e4..0000000 --- a/src/examples/modules/group/delete_group_message.py +++ /dev/null @@ -1,17 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # [INPUT_MESSAGE_ID] 에 메세지 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - data = {'messageIds': - [ - # 배열로 id를 입력하여 한 요청에 여러개의 메세지를 제거할 수 있습니다. - '[INPUT_MESSAGE_ID]', - '[INPUT_MESSAGE_ID]' - ] - } - res = message.delete('/messages/v4/groups/[INPUT_GROUP_ID]/messages', - data=data) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/get_group_info.py b/src/examples/modules/group/get_group_info.py deleted file mode 100644 index 955067e..0000000 --- a/src/examples/modules/group/get_group_info.py +++ /dev/null @@ -1,8 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - res = message.get('/messages/v4/groups/[INPUT_GROUP_ID]') - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/get_group_list.py b/src/examples/modules/group/get_group_list.py deleted file mode 100644 index 9c57557..0000000 --- a/src/examples/modules/group/get_group_list.py +++ /dev/null @@ -1,6 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - res = message.get('/messages/v4/groups') - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/get_group_message.py b/src/examples/modules/group/get_group_message.py deleted file mode 100644 index 631ee69..0000000 --- a/src/examples/modules/group/get_group_message.py +++ /dev/null @@ -1,8 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - res = message.get('/messages/v4/groups/[INPUT_GROUP_ID]/messages') - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/send_group_message.py b/src/examples/modules/group/send_group_message.py deleted file mode 100644 index c6cc443..0000000 --- a/src/examples/modules/group/send_group_message.py +++ /dev/null @@ -1,8 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - res = message.post('/messages/v4/groups/[INPUT_GROUP_ID]/send', data={}) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/set_reservation_group.py b/src/examples/modules/group/set_reservation_group.py deleted file mode 100644 index fb52e8f..0000000 --- a/src/examples/modules/group/set_reservation_group.py +++ /dev/null @@ -1,24 +0,0 @@ -import json -import time -import datetime -from src.lib import message - -''' -예약 발송 예제, 미리 그룹이 추가되어 있어야 합니다. -''' -if __name__ == '__main__': - utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone - utc_offset = datetime.timedelta(seconds=-utc_offset_sec) - - # 희망하시는 예약 날짜를 넣어주세요 - # datetime(년, 월, 일, 시, 분, 초) - scheduledDate = datetime.datetime(2022, 2, 8, 0, 0, 0).replace( - tzinfo=datetime.timezone(offset=utc_offset)).isoformat() - data = { - 'scheduledDate': scheduledDate - } - - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - res = message.post('/messages/v4/groups/[INPUT_GROUP_ID]/schedule', data=data) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/kakaotalk/send_alimtalk.py b/src/examples/modules/kakaotalk/send_alimtalk.py deleted file mode 100644 index a2bb0a7..0000000 --- a/src/examples/modules/kakaotalk/send_alimtalk.py +++ /dev/null @@ -1,73 +0,0 @@ -import json -from src.lib import message - -''' -알림톡 발송 예제 -한번 요청으로 10,000건의 알림톡 발송이 가능합니다. -''' -if __name__ == '__main__': - data = { - 'messages': [ - # 변수가 있는 경우 - { - 'to': '01000000001', - 'from': '029302266', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx', - # 변수: 값 형식으로 모든 변수에 대한 변수값 입력 - 'variables': { - '#{변수1}': '변수1의 값', - '#{변수2}': '변수2의 값', - '#{버튼링크1}': '버튼링크1의 값', - '#{버튼링크2}': '버튼링크2의 값', - '#{강조문구}': '강조문구의 값' - } - } - }, - # 변수가 없는 경우 - { - 'to': '01000000001', - 'from': '029302266', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx', - 'variables': {} # 변수가 없는 경우에도 입력 - } - }, - { - 'to': ['01000000002', '01000000003'], # array 사용으로 동일한 내용을 여러 수신번호에 전송 가능 - 'from': '029302266', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx', - 'variables': { - '#{변수1}': '변수1의 값', - '#{변수2}': '변수2의 값', - '#{버튼링크1}': '버튼링크1의 값', - '#{버튼링크2}': '버튼링크2의 값', - '#{강조문구}': '강조문구의 값' - } - } - }, - { - 'to': ['01000000002', '01000000003'], # array 사용으로 동일한 내용을 여러 수신번호에 전송 가능 - 'from': '029302266', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx', - 'disableSms': True, # 해당 값을 True로 표시할 경우 문자로의 대체 발송이 진행되지 않습니다. - 'variables': { - '#{변수1}': '변수1의 값', - '#{변수2}': '변수2의 값', - '#{버튼링크1}': '버튼링크1의 값', - '#{버튼링크2}': '버튼링크2의 값', - '#{강조문구}': '강조문구의 값' - } - } - } - # 한 번의 시도로 최대 10,000건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/kakaotalk/send_chingutalk.py b/src/examples/modules/kakaotalk/send_chingutalk.py deleted file mode 100644 index 4d2a3ee..0000000 --- a/src/examples/modules/kakaotalk/send_chingutalk.py +++ /dev/null @@ -1,72 +0,0 @@ -import json -from src.lib import message - -''' -한번 요청으로 1만건의 친구톡 발송이 가능합니다. -카카오톡채널 친구로 추가되어 있어야 친구톡 발송이 가능합니다. -템플릿 등록없이 버튼을 포함하여 자유롭게 메시지 전송이 가능합니다. -''' -if __name__ == '__main__': - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', - 'text': '카카오톡채널 친구로 추가되어 있어야 친구톡 발송이 가능합니다.', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'adFlag': True # 광고 표기 여부(기본값 False) - } - }, - { - 'to': ['01000000002', '01000000003'], # array 사용으로 동일한 내용을 여러 수신번호에 전송 가능 - 'from': '029302266', - 'text': '카카오톡채널 친구로 추가되어 있어야 친구톡 발송이 가능합니다.', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx' - } - }, - { - 'to': '01000000004', - 'from': '029302266', - 'text': '버튼은 최대 5개까지 추가 가능하며 내용과 마찬가지로 자유롭게 입력이 가능합니다.', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'buttons': [ - { - 'buttonType': 'WL', # 웹링크 - 'buttonName': '버튼 이름', - 'linkMo': 'https://m.example.com', - 'linkPc': 'https://example.com' # 템플릿 등록 시 모바일링크만 입력하였다면 linkPc 값은 입력하시면 안됩니다. - }, - { - 'buttonType': 'AL', # 앱링크 - 'buttonName': '실행 버튼', - 'linkAnd': 'examplescheme://', - 'linkIos': 'examplescheme://' - }, - { - 'buttonType': 'BK', # 봇키워드(챗봇에게 키워드를 전달합니다. 버튼이름의 키워드가 그대로 전달됩니다.) - 'buttonName': '봇키워드' - }, - { - 'buttonType': 'MD', # 상담요청하기 (상담요청하기 버튼을 누르면 메시지 내용이 상담원에게 그대로 전달됩니다.) - 'buttonName': '상담요청하기' - }, - { - 'buttonType': 'BC', # 상담톡 서비스 사용 시에만 가능합니다. - 'buttonName': '상담요청하기' - }, - { - 'buttonType': 'BT', # 챗봇 사용 시 가능 - 'buttonName': '챗본 문의' - } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/message_list.py b/src/examples/modules/message_list.py deleted file mode 100644 index 1430fce..0000000 --- a/src/examples/modules/message_list.py +++ /dev/null @@ -1,57 +0,0 @@ -import requests -import json -from src.lib import config, auth - -''' -모든 메시지를 조회하는 예제 -''' -if __name__ == '__main__': - res = requests.get(config.get_url('/messages/v4/list'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # limit - res = requests.get(config.get_url('/messages/v4/list?limit=10'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # messageId - res = requests.get(config.get_url('/messages/v4/list?messageId=XXXXXXX'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # groupId - res = requests.get(config.get_url('/messages/v4/list?groupId=XXXXXXX'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 수신번호로 조회 - res = requests.get(config.get_url('/messages/v4/list?to=01000000001'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 발신번호로 조회 - res = requests.get(config.get_url('/messages/v4/list?from=029302266'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 메시지타입으로 조회 - res = requests.get(config.get_url('/messages/v4/list?type=SMS'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 메시지 상태 코드로 조회 - res = requests.get(config.get_url('/messages/v4/list?statusCode=4000'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 페이지 처리 - page1 = requests.get(config.get_url('/messages/v4/list?limit=5'), - headers=auth.get_headers(config.api_key, config.api_secret)).json() - print(json.dumps(page1, indent=2, ensure_ascii=False)) - if 'nextKey' in res: - # 읽어올 데이터가 더 있음 - startKey = res['nextKey'] - page2 = requests.get(config.get_url('/messages/v4/list?limit=5&startKey=%s' % startKey), - headers=auth.get_headers(config.api_key, config.api_secret)).json() - print(json.dumps(page2, indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/naver/send_naver.py b/src/examples/modules/naver/send_naver.py deleted file mode 100644 index d569e9d..0000000 --- a/src/examples/modules/naver/send_naver.py +++ /dev/null @@ -1,58 +0,0 @@ -import json -from src import lib as message - -# 한 번 요청으로 10,000건의 네이버 톡톡(스마트알림) 발송이 가능합니다. -# 등록되어 있는 템플릿의 변수 부분을 제외한 나머지 부분(상수)은 100% 일치해야 합니다. -# 템플릿 내용이 "#{이름}님 가입을 환영합니다."으로 등록되어 있는 경우 변수 #{이름}을 홍길동으로 치환하여 "홍길동님 가입을 환영합니다."로 입력해 주세요. -if __name__ == '__main__': - data = { - 'messages': [ - { - 'type': 'NSA', - 'to': '01000000001', - 'from': '029302266', - 'text': '홍길동님 가입을 환영합니다.', - 'naverOptions': { - 'talkId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx' - } - }, - { - 'type': 'NSA', - 'to': ['01000000002', '01000000003'], # array 사용으로 동일한 내용을 여러 수신번호에 전송 가능 - 'from': '029302266', - 'text': '모두님 가입을 환영합니다.', - 'naverOptions': { - 'talkId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx' - } - }, - { - 'type': 'NSA', - 'to': '01000000004', - 'from': '029302266', - 'text': '버튼은 최대 5개까지 추가 가능하며 템플릿 내용은 검수 받은 내용 그대로 입력되어야 하며, 버튼 URL은 자유롭게 입력 가능합니다.', - 'naverOptions': { - 'talkId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx', - 'buttons': [ - { - 'buttonType': 'WL', # 웹링크 - 'buttonCode': 'btn1', # 버튼 코드를 입력하세요. (템플릿 상세보기에서 확인 가능) - 'linkMo': 'https://m.example.com', # URL은 자유롭게 입력 가능 - 'linkPc': 'https://example.com' # URL은 자유롭게 입력 가능 - }, - { - 'buttonType': 'AL', # 앱링크 - 'buttonCode': 'btn2', # 버튼 코드를 입력하세요. (템플릿 상세보기에서 확인 가능) - 'linkAnd': 'examplescheme://', # 안드로이드 - 'linkIos': 'examplescheme://' # iOS - } - ] - } - } - # 최대 10,000건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/images/sample1.png b/src/examples/modules/rcs/images/sample1.png deleted file mode 100644 index 50c7128..0000000 Binary files a/src/examples/modules/rcs/images/sample1.png and /dev/null differ diff --git a/src/examples/modules/rcs/images/sample2.png b/src/examples/modules/rcs/images/sample2.png deleted file mode 100644 index 14b354b..0000000 Binary files a/src/examples/modules/rcs/images/sample2.png and /dev/null differ diff --git a/src/examples/modules/rcs/images/sample3.png b/src/examples/modules/rcs/images/sample3.png deleted file mode 100644 index 7dca86a..0000000 Binary files a/src/examples/modules/rcs/images/sample3.png and /dev/null differ diff --git a/src/examples/modules/rcs/images/sample4.png b/src/examples/modules/rcs/images/sample4.png deleted file mode 100644 index 8eb7217..0000000 Binary files a/src/examples/modules/rcs/images/sample4.png and /dev/null differ diff --git a/src/examples/modules/rcs/images/sample5.png b/src/examples/modules/rcs/images/sample5.png deleted file mode 100644 index 7b653bd..0000000 Binary files a/src/examples/modules/rcs/images/sample5.png and /dev/null differ diff --git a/src/examples/modules/rcs/images/sample6.png b/src/examples/modules/rcs/images/sample6.png deleted file mode 100644 index 3ddfc6b..0000000 Binary files a/src/examples/modules/rcs/images/sample6.png and /dev/null differ diff --git a/src/examples/modules/rcs/send_rcs_lms.py b/src/examples/modules/rcs/send_rcs_lms.py deleted file mode 100644 index bb2ce04..0000000 --- a/src/examples/modules/rcs/send_rcs_lms.py +++ /dev/null @@ -1,43 +0,0 @@ -import json -from src.lib import message - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', # 반드시 RCSBizCenter에 등록된 발신번호 입력 - 'subject': 'LMS 제목', - 'text': 'RCS LMS를 발송합니다.', - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - } - }, - # 버튼이 포함된 RCS LMS를 발송합니다. 버튼은 최대 3개까지 추가 가능합니다. - { - 'to': ['01000000002', '01000000003'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'subject': 'LMS 제목', - 'text': '버튼이 포함된 RCS LMS를 발송합니다. 버튼은 최대 3개까지 추가 가능합니다.', - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'buttons': [ - {'buttonType': 'WL', 'buttonName': '홈페이지 바로가기', 'link': 'https://nurigo.net'} - , {'buttonType': 'ML', 'buttonName': '지도 위치 표시', 'latitude': '37.280342669603684', - 'longitude': '127.11824209721874', 'label': '누리고', 'link': 'https://nurigo.net'} - , {'buttonType': 'MQ', 'buttonName': '지도 검색', 'link': 'https://nurigo.net', 'query': '(주)누리고'} - # , { 'buttonType': 'MR', 'buttonName': '나의 현재 위치' } - # , { 'buttonType': 'CA', 'buttonName': '캘린더 일정 생성', 'title': '제목', 'startTime': '2021-06-19T00:00:00.000Z', 'endTime': '2021-06-19T09:00:00.000Z', 'text': '메모' } - # , { 'buttonType': 'CL', 'buttonName': '텍스트 복사', 'text': '복사할 텍스트 내용' } - # , { 'buttonType': 'DL', 'buttonName': '전화 걸기', 'phone': '01012345678' } - # , { 'buttonType': 'MS', 'buttonName': '메시지 보내기', 'phone': '01012345678', 'text': '보낼 메시지 내용' } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/send_rcs_mms.py b/src/examples/modules/rcs/send_rcs_mms.py deleted file mode 100644 index cb1fbad..0000000 --- a/src/examples/modules/rcs/send_rcs_mms.py +++ /dev/null @@ -1,48 +0,0 @@ -import json -from src.lib import message, storage - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - res = storage.upload_rcs_image('../testImage.jpg').json() - fileId = res['fileId'] - - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', # 반드시 RCSBizCenter에 등록된 발신번호 입력 - 'subject': 'MMS 제목', - 'text': 'RCS MMS를 발송합니다.', - 'imageId': fileId, - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - } - }, - # 버튼이 포함된 RCS MMS를 발송합니다. 버튼은 최대 2개까지 추가 가능합니다. - { - 'to': ['01000000002', '01000000003'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'subject': 'MMS 제목', - 'text': '버튼이 포함된 RCS MMS를 발송합니다. 버튼은 최대 2개까지 추가 가능합니다.', - 'imageId': fileId, - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'buttons': [ - {'buttonType': 'WL', 'buttonName': '홈페이지 바로가기', 'link': 'https://nurigo.net'} - , {'buttonType': 'ML', 'buttonName': '지도 위치 표시', 'latitude': '37.280342669603684', - 'longitude': '127.11824209721874', 'label': '누리고', 'link': 'https://nurigo.net'} - # , { 'buttonType': 'MQ', 'buttonName': '지도 검색', 'link': 'https://nurigo.net', 'query': '(주)누리고' } - # , { 'buttonType': 'MR', 'buttonName': '나의 현재 위치' } - # , { 'buttonType': 'CA', 'buttonName': '캘린더 일정 생성', 'title': '제목', 'startTime': '2021-06-19T00:00:00.000Z', 'endTime': '2021-06-19T09:00:00.000Z', 'text': '메모' } - # , { 'buttonType': 'CL', 'buttonName': '텍스트 복사', 'text': '복사할 텍스트 내용' } - # , { 'buttonType': 'DL', 'buttonName': '전화 걸기', 'phone': '01012345678' } - # , { 'buttonType': 'MS', 'buttonName': '메시지 보내기', 'phone': '01012345678', 'text': '보낼 메시지 내용' } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/send_rcs_mms_m3.py b/src/examples/modules/rcs/send_rcs_mms_m3.py deleted file mode 100644 index 550a714..0000000 --- a/src/examples/modules/rcs/send_rcs_mms_m3.py +++ /dev/null @@ -1,59 +0,0 @@ -import json -from src.lib import message, storage - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - res = storage.upload_rcs_image('images/sample1.png').json() - sample1 = res['fileId'] - res = storage.upload_rcs_image('images/sample2.png').json() - sample2 = res['fileId'] - res = storage.upload_rcs_image('images/sample3.png').json() - sample3 = res['fileId'] - - # 카드 3개 발송 예제(각 카드별 버튼 2개까지 가능) - data = { - 'messages': [ - { - 'to': ['01000000001', '01000000002'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'subject': 'Sample 1', - 'text': 'MMS M3 타입 발송 예제입니다.', - 'imageId': sample1, - 'rcsOptions': { - 'mmsType': 'M3', # M3 ~ M6 (총 이미지 1M를 넘을 수 없음) - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'buttons': [ - {'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'} - # , { 'buttonType': 'ML', 'buttonName': '지도 위치 표시', 'latitude': '37.280342669603684', 'longitude': '127.11824209721874', 'label': '누리고', 'link': 'https://nurigo.net' } - # , { 'buttonType': 'MQ', 'buttonName': '지도 검색', 'link': 'https://nurigo.net', 'query': '(주)누리고' } - # , { 'buttonType': 'MR', 'buttonName': '나의 현재 위치' } - # , { 'buttonType': 'CA', 'buttonName': '캘린더 일정 생성', 'title': '제목', 'startTime': '2021-06-19T00:00:00.000Z', 'endTime': '2021-06-19T09:00:00.000Z', 'text': '메모' } - # , { 'buttonType': 'CL', 'buttonName': '텍스트 복사', 'text': '복사할 텍스트 내용' } - # , { 'buttonType': 'DL', 'buttonName': '전화 걸기', 'phone': '01012345678' } - # , { 'buttonType': 'MS', 'buttonName': '메시지 보내기', 'phone': '01012345678', 'text': '보낼 메시지 내용' } - ], - 'additionalBody': [ - { - 'imageId': sample2, - 'title': 'Sample 2', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample3, - 'title': 'Sample 3', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/send_rcs_mms_m6.py b/src/examples/modules/rcs/send_rcs_mms_m6.py deleted file mode 100644 index e0b6826..0000000 --- a/src/examples/modules/rcs/send_rcs_mms_m6.py +++ /dev/null @@ -1,86 +0,0 @@ -import json -from src.lib import message, storage - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - res = storage.upload_rcs_image('images/sample1.png').json() - sample1 = res['fileId'] - res = storage.upload_rcs_image('images/sample2.png').json() - sample2 = res['fileId'] - res = storage.upload_rcs_image('images/sample3.png').json() - sample3 = res['fileId'] - res = storage.upload_rcs_image('images/sample4.png').json() - sample4 = res['fileId'] - res = storage.upload_rcs_image('images/sample5.png').json() - sample5 = res['fileId'] - res = storage.upload_rcs_image('images/sample6.png').json() - sample6 = res['fileId'] - - # 카드 6개 발송 예제(각 카드별 버튼 2개까지 가능) - data = { - 'messages': [ - { - 'to': ['01000000001', '01000000002'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'subject': 'Sample 1', - 'text': 'MMS M6 타입 발송 예제입니다.', - 'imageId': sample1, - 'rcsOptions': { - 'mmsType': 'M6', # M3 ~ M6 (총 이미지 1M를 넘을 수 없음) - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'buttons': [ - {'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'} - # , { 'buttonType': 'ML', 'buttonName': '지도 위치 표시', 'latitude': '37.280342669603684', 'longitude': '127.11824209721874', 'label': '누리고', 'link': 'https://nurigo.net' } - # , { 'buttonType': 'MQ', 'buttonName': '지도 검색', 'link': 'https://nurigo.net', 'query': '(주)누리고' } - # , { 'buttonType': 'MR', 'buttonName': '나의 현재 위치' } - # , { 'buttonType': 'CA', 'buttonName': '캘린더 일정 생성', 'title': '제목', 'startTime': '2021-06-19T00:00:00.000Z', 'endTime': '2021-06-19T09:00:00.000Z', 'text': '메모' } - # , { 'buttonType': 'CL', 'buttonName': '텍스트 복사', 'text': '복사할 텍스트 내용' } - # , { 'buttonType': 'DL', 'buttonName': '전화 걸기', 'phone': '01012345678' } - # , { 'buttonType': 'MS', 'buttonName': '메시지 보내기', 'phone': '01012345678', 'text': '보낼 메시지 내용' } - ], - 'additionalBody': [ - { - 'imageId': sample2, - 'title': 'Sample 2', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample3, - 'title': 'Sample 3', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample4, - 'title': 'Sample 4', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample5, - 'title': 'Sample 5', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample6, - 'title': 'Sample 6', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/send_rcs_mms_s3.py b/src/examples/modules/rcs/send_rcs_mms_s3.py deleted file mode 100644 index 765bf92..0000000 --- a/src/examples/modules/rcs/send_rcs_mms_s3.py +++ /dev/null @@ -1,59 +0,0 @@ -import json -from src.lib import message, storage - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - res = storage.upload_rcs_image('images/sample1.png').json() - sample1 = res['fileId'] - res = storage.upload_rcs_image('images/sample2.png').json() - sample2 = res['fileId'] - res = storage.upload_rcs_image('images/sample3.png').json() - sample3 = res['fileId'] - - # 카드 3개 발송 예제(각 카드별 버튼 2개까지 가능) - data = { - 'messages': [ - { - 'to': ['01000000001', '01000000002'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'subject': 'Sample 1', - 'text': 'MMS S3 타입 발송 예제입니다.', - 'imageId': sample1, - 'rcsOptions': { - 'mmsType': 'S3', # S3 ~ S6 (총 이미지 1M를 넘을 수 없음) - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'buttons': [ - {'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'} - # , { 'buttonType': 'ML', 'buttonName': '지도 위치 표시', 'latitude': '37.280342669603684', 'longitude': '127.11824209721874', 'label': '누리고', 'link': 'https://nurigo.net' } - # , { 'buttonType': 'MQ', 'buttonName': '지도 검색', 'link': 'https://nurigo.net', 'query': '(주)누리고' } - # , { 'buttonType': 'MR', 'buttonName': '나의 현재 위치' } - # , { 'buttonType': 'CA', 'buttonName': '캘린더 일정 생성', 'title': '제목', 'startTime': '2021-06-19T00:00:00.000Z', 'endTime': '2021-06-19T09:00:00.000Z', 'text': '메모' } - # , { 'buttonType': 'CL', 'buttonName': '텍스트 복사', 'text': '복사할 텍스트 내용' } - # , { 'buttonType': 'DL', 'buttonName': '전화 걸기', 'phone': '01012345678' } - # , { 'buttonType': 'MS', 'buttonName': '메시지 보내기', 'phone': '01012345678', 'text': '보낼 메시지 내용' } - ], - 'additionalBody': [ - { - 'imageId': sample2, - 'title': 'Sample 2', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample3, - 'title': 'Sample 3', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/send_rcs_mms_s6.py b/src/examples/modules/rcs/send_rcs_mms_s6.py deleted file mode 100644 index 173c2e7..0000000 --- a/src/examples/modules/rcs/send_rcs_mms_s6.py +++ /dev/null @@ -1,86 +0,0 @@ -import json -from src.lib import message, storage - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - res = storage.upload_rcs_image('images/sample1.png').json() - sample1 = res['fileId'] - res = storage.upload_rcs_image('images/sample2.png').json() - sample2 = res['fileId'] - res = storage.upload_rcs_image('images/sample3.png').json() - sample3 = res['fileId'] - res = storage.upload_rcs_image('images/sample4.png').json() - sample4 = res['fileId'] - res = storage.upload_rcs_image('images/sample5.png').json() - sample5 = res['fileId'] - res = storage.upload_rcs_image('images/sample6.png').json() - sample6 = res['fileId'] - - # 카드 6개 발송 예제(각 카드별 버튼 2개까지 가능) - data = { - 'messages': [ - { - 'to': ['01000000001', '01000000002'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'subject': 'Sample 1', - 'text': 'MMS S6 타입 발송 예제입니다.', - 'imageId': sample1, - 'rcsOptions': { - 'mmsType': 'S6', # S3 ~ S6 (총 이미지 1M를 넘을 수 없음) - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'buttons': [ - {'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'} - # , { 'buttonType': 'ML', 'buttonName': '지도 위치 표시', 'latitude': '37.280342669603684', 'longitude': '127.11824209721874', 'label': '누리고', 'link': 'https://nurigo.net' } - # , { 'buttonType': 'MQ', 'buttonName': '지도 검색', 'link': 'https://nurigo.net', 'query': '(주)누리고' } - # , { 'buttonType': 'MR', 'buttonName': '나의 현재 위치' } - # , { 'buttonType': 'CA', 'buttonName': '캘린더 일정 생성', 'title': '제목', 'startTime': '2021-06-19T00:00:00.000Z', 'endTime': '2021-06-19T09:00:00.000Z', 'text': '메모' } - # , { 'buttonType': 'CL', 'buttonName': '텍스트 복사', 'text': '복사할 텍스트 내용' } - # , { 'buttonType': 'DL', 'buttonName': '전화 걸기', 'phone': '01012345678' } - # , { 'buttonType': 'MS', 'buttonName': '메시지 보내기', 'phone': '01012345678', 'text': '보낼 메시지 내용' } - ], - 'additionalBody': [ - { - 'imageId': sample2, - 'title': 'Sample 2', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample3, - 'title': 'Sample 3', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample4, - 'title': 'Sample 4', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample5, - 'title': 'Sample 5', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample6, - 'title': 'Sample 6', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/send_rcs_sms.py b/src/examples/modules/rcs/send_rcs_sms.py deleted file mode 100644 index b4e63f5..0000000 --- a/src/examples/modules/rcs/send_rcs_sms.py +++ /dev/null @@ -1,39 +0,0 @@ -import json -from src.lib import message - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', # 반드시 RCSBizCenter에 등록된 발신번호 입력 - 'text': 'RCS SMS를 발송합니다.', - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - } - }, - { - 'to': ['01000000002', '01000000003'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': 'RCS SMS를 발송합니다.', - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'buttons': [ - {'buttonType': 'WL', 'buttonName': '홈페이지 바로가기', 'link': 'https://nurigo.net'} - # , { 'buttonType': 'ML', 'buttonName': '지도 위치 표시', 'latitude': '37.280342669603684', 'longitude': '127.11824209721874', 'label': '누리고', 'link': 'https://nurigo.net' } - # , { 'buttonType': 'MQ', 'buttonName': '지도 검색', 'link': 'https://nurigo.net', 'query': '(주)누리고' } - # , { 'buttonType': 'MR', 'buttonName': '나의 현재 위치' } - # , { 'buttonType': 'CA', 'buttonName': '캘린더 일정 생성', 'title': '제목', 'startTime': '2021-06-19T00:00:00.000Z', 'endTime': '2021-06-19T09:00:00.000Z', 'text': '메모' } - # , { 'buttonType': 'CL', 'buttonName': '텍스트 복사', 'text': '복사할 텍스트 내용' } - # , { 'buttonType': 'DL', 'buttonName': '전화 걸기', 'phone': '01012345678' } - # , { 'buttonType': 'MS', 'buttonName': '메시지 보내기', 'phone': '01012345678', 'text': '보낼 메시지 내용' } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/send_rcs_tpl.py b/src/examples/modules/rcs/send_rcs_tpl.py deleted file mode 100644 index 54aa13d..0000000 --- a/src/examples/modules/rcs/send_rcs_tpl.py +++ /dev/null @@ -1,41 +0,0 @@ -import json -from src.lib import message - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', # 반드시 RCSBizCenter에 등록된 발신번호 입력 - 'text': '템플릿 기반 RCS를 발송합니다.', - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'templateId': 'RC01TP210727075536693B1z23GR7xWQ', - 'variables': { - '{{변수1}}': '변수1값', - '{{변수2}}': '변수2값', - '{{변수3}}': '변수3값' - } - } - }, - { - 'to': ['01000000002', '01000000003'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': '템플릿 기반 RCS를 발송합니다.', - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'templateId': 'RC01TP210727075536693B1z23GR7xWQ', - 'variables': { - '{{변수1}}': '변수1값', - '{{변수2}}': '변수2값', - '{{변수3}}': '변수3값' - } - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/senderid/README.md b/src/examples/modules/senderid/README.md deleted file mode 100644 index 983f093..0000000 --- a/src/examples/modules/senderid/README.md +++ /dev/null @@ -1,18 +0,0 @@ -## 발신번호 등록 - -Step 1) 발신번호를 등록(생성)합니다. -create_number.py 파일의 phoneNumber 변수값을 발신번호로 사용할 전화번호로 입력해 주세요. - -## 발신번호 인증 - -Step 2) 전화번호 인증 - -* 해당 전화번호로 전화를 걸어 인증번호를 알려줍니다. -* 리턴되는 Transaction ID와 인증번호(Token)를 기록해두어 다음 Step에서 사용합니다. - -Step 3) 인증번호 확인 Trasaction ID 와 인증번호로 인증을 확인합니다. - -## 활성화된 발신번호 조회 - -Step 3 까지 진행했을 때 특별한 오류가 없었다면 활성화된 발신번호 목록에서 확인 가능합니다. - diff --git a/src/examples/modules/senderid/__init__.py b/src/examples/modules/senderid/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/examples/modules/senderid/create_number.py b/src/examples/modules/senderid/create_number.py deleted file mode 100644 index 00fc99b..0000000 --- a/src/examples/modules/senderid/create_number.py +++ /dev/null @@ -1,10 +0,0 @@ -import json -from src.lib import message - -''' -STEP 1) 발신번호 추가 예제 -다음 과정으로 request_voicecall.py 파일을 참고 해주세요. -''' -if __name__ == '__main__': - res = message.post("/senderid/v1/numbers", {'phoneNumber': '01000000001'}) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/senderid/get_active_numbers.py b/src/examples/modules/senderid/get_active_numbers.py deleted file mode 100644 index 5e25ae6..0000000 --- a/src/examples/modules/senderid/get_active_numbers.py +++ /dev/null @@ -1,9 +0,0 @@ -import json -from src.lib import message - -''' -활성화 된 발신번호 목록을 조회하는 예제 -''' -if __name__ == '__main__': - res = message.get("/senderid/v1/numbers/active") - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/senderid/request_voicecall.py b/src/examples/modules/senderid/request_voicecall.py deleted file mode 100644 index 8e14c02..0000000 --- a/src/examples/modules/senderid/request_voicecall.py +++ /dev/null @@ -1,28 +0,0 @@ -import json -from src.lib import message - -''' -Step 2) 인증번호 요청 -해당 전화번호로 전화를 걸어 인증번호를 알려줍니다. -리턴되는 Transaction ID와 인증번호(Token)를 기록해두어 다음 Step에서 사용합니다. -다음 과정으로 verify_token.py 파일을 참고 해주세요. -''' -if __name__ == '__main__': - # 인증받을 등록된 전화번호 입력 - phoneNumber = '01000000001' - - authInfo = { - 'authType': 'ARS', - 'extras': { - 'phoneNumber': phoneNumber - } - } - - headers = { - 'x-mfa-data': json.dumps(authInfo) - } - - res = message.put("/senderid/v1/numbers/%s/authenticate" % phoneNumber, {}, headers) - jsonData = res.json() - print(jsonData) - print('Transaction ID:', jsonData['mfa']['transactionId']) diff --git a/src/examples/modules/senderid/verify_token.py b/src/examples/modules/senderid/verify_token.py deleted file mode 100644 index e78e81e..0000000 --- a/src/examples/modules/senderid/verify_token.py +++ /dev/null @@ -1,33 +0,0 @@ -import json -from src.lib import message - -''' -Step 3) 인증번호 확인 -Step 2 과정에서 획득한 정보를 모두 입력하여 인증 받습니다. -이 과정이 모두 끝나면 정상적으로 발신번호를 이용하실 수 있습니다. -''' -if __name__ == '__main__': - # 전화번호 입력 - phoneNumber = '01000000001' - - # Transaction ID 입력 - transactionId = 'cc4f482bcf167f69f2b15fcfd044f509' - - # 음성으로 전달받은 인증번호 - token = '7894' - - authInfo = { - 'authType': 'ARS', - 'extras': { - 'phoneNumber': phoneNumber - }, - 'transactionId': transactionId, - 'token': token - } - - headers = { - 'x-mfa-data': json.dumps(authInfo) - } - - res = message.put("/senderid/v1/numbers/%s/authenticate" % phoneNumber, {}, headers) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/sms/__init__.py b/src/examples/modules/sms/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/examples/modules/sms/allow_duplicates.py b/src/examples/modules/sms/allow_duplicates.py deleted file mode 100644 index 65a2b64..0000000 --- a/src/examples/modules/sms/allow_duplicates.py +++ /dev/null @@ -1,25 +0,0 @@ -import json -from src.lib import message - -''' -중복 수신번호 허용 예제 -''' -if __name__ == '__main__': - data = { - 'allowDuplicates': True, # 수신번호 중복 입력을 허용합니다. - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', - 'text': '동일한 수신번호로 발송 #1' - }, - { - 'to': '01000000001', - 'from': '029302266', - 'text': '동일한 수신번호로 발송 #2' # 동일한 내용 입력 시 수신된 문자는 하나로 보여질 수 있습니다. - }, - # 한 번 요청으로 최대 10,000건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/sms/send_global_sms.py b/src/examples/modules/sms/send_global_sms.py deleted file mode 100644 index 2eb094a..0000000 --- a/src/examples/modules/sms/send_global_sms.py +++ /dev/null @@ -1,17 +0,0 @@ -import json -from src.lib import message - -''' -해외 문자발송 예제 -''' -if __name__ == '__main__': - data = { - 'message': { - 'to': '', # 보내실 수신번호를 국가번호를 제외하여 입력해주세요. - 'from': '029302266', # 반드시 사용자 개인이 등록한 번호로만 발송 가능합니다. - 'text': 'NURIGO Verification Code: 1234', # 기본적으로 NURIGO, 1234 등을 변경하여 발송하시는 것을 권장드립니다. - 'country': '' # 보내실 국가 코드를 입력해주세요, 예) 미국, 캐나다 -> '1', 중국 -> '86', 일본 -> '81'... - }, - } - res = message.send_one(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/sms/send_lms.py b/src/examples/modules/sms/send_lms.py deleted file mode 100644 index bbdb074..0000000 --- a/src/examples/modules/sms/send_lms.py +++ /dev/null @@ -1,41 +0,0 @@ -import json -from src.lib import message - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이상 입력되면 자동으로 LMS타입의 문자메시지가 발송됩니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'to': '01000000002', - 'from': '029302266', - 'subject': 'LMS 제목', # 제목을 지정할 수 있습니다. - 'text': '한글 45자, 영자 90자 이상 입력되면 자동으로 LMS타입의 문자메시지가 발송됩니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'type': 'LMS', # 타입을 명시할 수 있습니다. - 'to': '01000000003', - 'from': '029302266', - 'text': '내용이 짧아도 LMS로 발송됩니다.' - }, - { - 'to': '01000000004', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하는 자동으로 SMS타입의 문자가 발송됩니다.' - }, - { - 'to': ['01000000005', '01000000006'], # 수신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'subject': 'LMS 제목', - 'text': '한글 45자, 영자 90자 이상 입력되면 자동으로 LMS타입의 문자메시지가 발송됩니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/sms/send_mms.py b/src/examples/modules/sms/send_mms.py deleted file mode 100644 index a16a79a..0000000 --- a/src/examples/modules/sms/send_mms.py +++ /dev/null @@ -1,36 +0,0 @@ -import json -from src.lib import message, storage - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - res = storage.upload_image('../testImage.jpg').json() - fileId = res['fileId'] - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', - 'subject': 'MMS 제목', - 'imageId': fileId, - 'text': '이미지 아이디가 입력되면 MMS로 발송됩니다.' - }, - { - 'to': '01000000002', - 'from': '029302266', - 'subject': 'MMS 제목', - 'imageId': fileId, - 'text': '동일한 이미지 아이디가 입력되면 동일한 이미지가 MMS로 발송됩니다.' - }, - { - 'to': ['01000000003', '01000000004'], # array로 입력하면 여러명에게 동일한 내용으로 발송됩니다. - 'from': '029302266', - 'subject': 'MMS 제목', - 'imageId': fileId, - 'text': '동일한 이미지 아이디가 입력되면 동일한 이미지가 MMS로 발송됩니다.' - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/sms/send_sms.py b/src/examples/modules/sms/send_sms.py deleted file mode 100644 index 4fce702..0000000 --- a/src/examples/modules/sms/send_sms.py +++ /dev/null @@ -1,40 +0,0 @@ -import json -from src.lib import message - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 추가됩니다.' - }, - { - 'to': '01000000002', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이상 입력되면 자동으로 LMS타입의 문자메시자가 발송됩니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'type': 'SMS', - 'to': '01000000003', - 'from': '029302266', - 'text': 'SMS 타입에 한글 45자, 영자 90자 이상 입력되면 오류가 발생합니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'to': ['01000000004', '01000000005'], # 수신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.' - }, - { - 'country': '1', # 미국(1), 일본(81), 중국(86) 등 국가번호 입력 - 'to': '01000000006', # 수신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.' - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/storage/__init__.py b/src/examples/modules/storage/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/examples/modules/storage/delete_image.py b/src/examples/modules/storage/delete_image.py deleted file mode 100644 index b906537..0000000 --- a/src/examples/modules/storage/delete_image.py +++ /dev/null @@ -1,12 +0,0 @@ -import requests -import json -from src.lib import auth, config - -''' -이미지 파일 삭제 예제 -''' -if __name__ == '__main__': - # [FILE_ID]에 fileId를 넣어 주세요. - res = requests.delete(config.get_url('/storage/v1/files/[FILE_ID]'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/storage/get_image_info.py b/src/examples/modules/storage/get_image_info.py deleted file mode 100644 index d8db776..0000000 --- a/src/examples/modules/storage/get_image_info.py +++ /dev/null @@ -1,12 +0,0 @@ -import requests -import json -from src.lib import auth, config - -''' -이미지 파일 단 건 조회 예제 -''' -if __name__ == '__main__': - # [FILE_ID] 에 fileId를 넣어주세요 - res = requests.get(config.get_url('/storage/v1/files/[FILE_ID]'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/storage/get_image_list.py b/src/examples/modules/storage/get_image_list.py deleted file mode 100644 index 6d94a24..0000000 --- a/src/examples/modules/storage/get_image_list.py +++ /dev/null @@ -1,10 +0,0 @@ -import requests -import json -from src.lib import auth, config - -''' -이미지 파일 리스트 조회 예제 -''' -if __name__ == '__main__': - res = requests.get(config.get_url('/storage/v1/files'), headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/storage/testImage.jpg b/src/examples/modules/storage/testImage.jpg deleted file mode 100644 index 61e20b6..0000000 Binary files a/src/examples/modules/storage/testImage.jpg and /dev/null differ diff --git a/src/examples/modules/storage/upload_image.py b/src/examples/modules/storage/upload_image.py deleted file mode 100644 index b2c91c4..0000000 --- a/src/examples/modules/storage/upload_image.py +++ /dev/null @@ -1,10 +0,0 @@ -import json -import src.lib.storage as storage - -''' -이미지 파일 업로드 예제 -''' -if __name__ == '__main__': - # 이미지를 바꾸시려면 testImage.jpg 대신 사용하실 이미지가 있는 파일 경로를 넣어주세요 - res = storage.upload_image('testImage.jpg') - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/scripts/README.md b/src/examples/scripts/README.md deleted file mode 100644 index 2303cb4..0000000 --- a/src/examples/scripts/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Python Script 예제 - -### 발송 관련 예제 - -``` -send_messages.py -> 메시지 발송 예제 -set_scheduled_message -> 메시지 예약발송 예제 -``` - -### 조회 관련 예제 - -``` -get_balances.py -> 잔액 조회 예제 -get_messages.py -> 메시지 리스트 조회(query parameter를 통해 단 건 조회 가능) -``` - -각 기능 별 상세한 설명은 [개발 문서 페이지](https://docs.solapi.com/api-reference/overview) 를 참고하세요. \ No newline at end of file diff --git a/src/examples/scripts/get_balances.py b/src/examples/scripts/get_balances.py deleted file mode 100644 index e325c41..0000000 --- a/src/examples/scripts/get_balances.py +++ /dev/null @@ -1,61 +0,0 @@ -import json -import time -import datetime -import uuid -import hmac -import hashlib -import requests - -# 아래 값은 필요시 수정 -protocol = 'https' -domain = 'api.solapi.com' -prefix = '' - - -def unique_id(): - return str(uuid.uuid1().hex) - - -def get_iso_datetime(): - utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone - utc_offset = datetime.timedelta(seconds=-utc_offset_sec) - return datetime.datetime.now().replace(tzinfo=datetime.timezone(offset=utc_offset)).isoformat() - - -def get_signature(key, msg): - return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest() - - -def get_headers(api_key, api_secret): - date = get_iso_datetime() - salt = unique_id() - combined_string = date + salt - - return { - 'Authorization': 'HMAC-SHA256 ApiKey=' + api_key + ', Date=' + date + ', salt=' + salt + ', signature=' + - get_signature(api_secret, combined_string), - 'Content-Type': 'application/json; charset=utf-8' - } - - -def get_url(path): - url = '%s://%s' % (protocol, domain) - if prefix != '': - url = url + prefix - url = url + path - return url - - -''' -잔액 조회 예제 -''' -if __name__ == '__main__': - # 반드시 관리 콘솔 내 발급 받으신 API KEY, API SECRET KEY를 입력해주세요. - api_key = 'INPUT YOUR API KEY' - api_secret = 'INPUT YOUR SECRET KEY' - - res = requests.get(get_url('/cash/v1/balance'), - headers=get_headers(api_key, api_secret)) - - # 실제 충전금액 및 포인트는 response 내 각각 balance, point 데이터로 확인할 수 있습니다. - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/scripts/get_messages.py b/src/examples/scripts/get_messages.py deleted file mode 100644 index fec72ec..0000000 --- a/src/examples/scripts/get_messages.py +++ /dev/null @@ -1,105 +0,0 @@ -import json -import time -import datetime -import uuid -import hmac -import hashlib -import requests - -# 아래 값은 필요시 수정 -protocol = 'https' -domain = 'api.solapi.com' -prefix = '' - - -def unique_id(): - return str(uuid.uuid1().hex) - - -def get_iso_datetime(): - utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone - utc_offset = datetime.timedelta(seconds=-utc_offset_sec) - return datetime.datetime.now().replace(tzinfo=datetime.timezone(offset=utc_offset)).isoformat() - - -def get_signature(key, msg): - return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest() - - -def get_headers(api_key, api_secret): - date = get_iso_datetime() - salt = unique_id() - combined_string = date + salt - - return { - 'Authorization': 'HMAC-SHA256 ApiKey=' + api_key + ', Date=' + date + ', salt=' + salt + ', signature=' + - get_signature(api_secret, combined_string), - 'Content-Type': 'application/json; charset=utf-8' - } - - -def get_url(path): - url = '%s://%s' % (protocol, domain) - if prefix != '': - url = url + prefix - url = url + path - return url - - -''' -메시지 조회 예제(알림톡, 일반 문자 등 모두 포함) -''' -if __name__ == '__main__': - # 반드시 관리 콘솔 내 발급 받으신 API KEY, API SECRET KEY를 입력해주세요 - api_key = 'INPUT YOUR API KEY' - api_secret = 'INPUT YOUR SECRET KEY' - - res = requests.get(get_url('/messages/v4/list'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # limit - res = requests.get(get_url('/messages/v4/list?limit=10'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # messageId - res = requests.get(get_url('/messages/v4/list?messageId=XXXXXXX'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # groupId - res = requests.get(get_url('/messages/v4/list?groupId=XXXXXXX'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 수신번호로 조회 - res = requests.get(get_url('/messages/v4/list?to=01000000001'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 발신번호로 조회 - res = requests.get(get_url('/messages/v4/list?from=029302266'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 메시지타입으로 조회 - res = requests.get(get_url('/messages/v4/list?type=SMS'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 메시지 상태 코드로 조회 - res = requests.get(get_url('/messages/v4/list?statusCode=4000'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 페이지 처리 - page1 = requests.get(get_url('/messages/v4/list?limit=5'), - headers=get_headers(api_key, api_secret)).json() - print(json.dumps(page1, indent=2, ensure_ascii=False)) - if 'nextKey' in res: - # 읽어올 데이터가 더 있음 - startKey = res['nextKey'] - page2 = requests.get(get_url('/messages/v4/list?limit=5&startKey=%s' % startKey), - headers=get_headers(api_key, api_secret)).json() - print(json.dumps(page2, indent=2, ensure_ascii=False)) diff --git a/src/examples/scripts/send_messages.py b/src/examples/scripts/send_messages.py deleted file mode 100644 index 0ecfcb9..0000000 --- a/src/examples/scripts/send_messages.py +++ /dev/null @@ -1,120 +0,0 @@ -import json -import time -import datetime -import uuid -import hmac -import hashlib -import requests -import platform - -# 아래 값은 필요시 수정 -protocol = 'https' -domain = 'api.solapi.com' -prefix = '' - - -def unique_id(): - return str(uuid.uuid1().hex) - - -def get_iso_datetime(): - utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone - utc_offset = datetime.timedelta(seconds=-utc_offset_sec) - return datetime.datetime.now().replace(tzinfo=datetime.timezone(offset=utc_offset)).isoformat() - - -def get_signature(key, msg): - return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest() - - -def get_headers(api_key, api_secret): - date = get_iso_datetime() - salt = unique_id() - combined_string = date + salt - - return { - 'Authorization': 'HMAC-SHA256 ApiKey=' + api_key + ', Date=' + date + ', salt=' + salt + ', signature=' + - get_signature(api_secret, combined_string), - 'Content-Type': 'application/json; charset=utf-8' - } - - -def get_url(path): - url = '%s://%s' % (protocol, domain) - if prefix != '': - url = url + prefix - url = url + path - return url - - -def send_many(parameter): - # 반드시 관리 콘솔 내 발급 받으신 API KEY, API SECRET KEY를 입력해주세요 - api_key = 'INPUT YOUR API KEY' - api_secret = 'INPUT YOUR SECRET KEY' - parameter['agent'] = { - 'sdkVersion': 'python/4.2.0', - 'osPlatform': platform.platform() + " | " + platform.python_version() - } - - return requests.post(get_url('/messages/v4/send-many'), headers=get_headers(api_key, api_secret), json=parameter) - - -''' -한번 요청으로 1만건의 메시지 발송이 가능합니다. -해당 파일을 통해 별도 import 없이 발송 테스트가 가능합니다. -from 데이터의 경우 반드시 관리 콘솔 내 등록하신 발신번호를 넣으셔야 정상 발송 가능합니다. -''' -if __name__ == '__main__': - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 추가됩니다.' - }, - { - 'to': '01000000002', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이상 입력되면 자동으로 LMS타입의 문자메시자가 발송됩니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'type': 'SMS', - 'to': '01000000003', - 'from': '029302266', - 'text': 'SMS 타입에 한글 45자, 영자 90자 이상 입력되면 오류가 발생합니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'to': ['01000000004', '01000000005'], # 수신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.' - }, - # 해외발송 - { - 'country': '1', # 미국(1), 일본(81), 중국(86) 등 국가번호 입력, 국가 번호에 띄어쓰기가 있는 경우 붙여서 기입해야 합니다. 예) +1 809 -> '1809' - 'to': '01000000006', # 수신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.' - }, - # 알림톡 발송 - { - 'to': '01000000004', - 'from': '029302266', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx', - # 변수: 값 형식으로 모든 변수에 대한 변수값 입력 - 'variables': { - '#{변수1}': '변수1의 값', - '#{변수2}': '변수2의 값', - '#{버튼링크1}': '버튼링크1의 값', - '#{버튼링크2}': '버튼링크2의 값', - '#{강조문구}': '강조문구의 값' - } - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/scripts/set_scheduled_message.py b/src/examples/scripts/set_scheduled_message.py deleted file mode 100644 index 10679fd..0000000 --- a/src/examples/scripts/set_scheduled_message.py +++ /dev/null @@ -1,151 +0,0 @@ -import json -import time -import datetime -import uuid -import hmac -import hashlib -import requests -import platform - -# 아래 값은 필요시 수정 -protocol = 'https' -domain = 'api.solapi.com' -prefix = '' - -# 반드시 관리 콘솔 내 발급 받으신 API KEY, API SECRET KEY를 입력해주세요 -api_key = 'INPUT YOUR API KEY' -api_secret = 'INPUT YOUR SECRET KEY' - - -def unique_id(): - return str(uuid.uuid1().hex) - - -def get_iso_datetime(): - utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone - utc_offset = datetime.timedelta(seconds=-utc_offset_sec) - return datetime.datetime.now().replace(tzinfo=datetime.timezone(offset=utc_offset)).isoformat() - - -def get_signature(key, msg): - return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest() - - -def get_headers(api_key, api_secret): - date = get_iso_datetime() - salt = unique_id() - combined_string = date + salt - - return { - 'Authorization': 'HMAC-SHA256 ApiKey=' + api_key + ', Date=' + date + ', salt=' + salt + ', signature=' + - get_signature(api_secret, combined_string), - 'Content-Type': 'application/json; charset=utf-8' - } - - -def get_url(path): - url = '%s://%s' % (protocol, domain) - if prefix != '': - url = url + prefix - url = url + path - return url - - -def get(url): - return requests.get(get_url(url), headers=get_headers(api_key, api_secret)) - - -def post(url, parameter): - return requests.post(get_url(url), headers=get_headers(api_key, api_secret), json=parameter) - - -def put(url, parameter): - return requests.put(get_url(url), headers=get_headers(api_key, api_secret), json=parameter) - - -def delete(url): - return requests.delete(get_url(url), headers=get_headers(api_key, api_secret)) - - -''' -예약발송 예제 -''' -if __name__ == '__main__': - # STEP 1 그룹 추가 - addGroupResponse = post('/messages/v4/groups', parameter={ - 'sdkVersion': 'python/4.2.0', - 'osPlatform': platform.platform() + " | " + platform.python_version() - }) - groupResponse: dict = addGroupResponse.json() - groupId = groupResponse['groupId'] - - # STEP 2 발송할 메시지 데이터 추가 - messagesDict = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 추가됩니다.' - }, - { - 'to': '01000000002', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이상 입력되면 자동으로 LMS타입의 문자메시자가 발송됩니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'type': 'SMS', - 'to': '01000000003', - 'from': '029302266', - 'text': 'SMS 타입에 한글 45자, 영자 90자 이상 입력되면 오류가 발생합니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'to': ['01000000004', '01000000005'], # 수신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.' - }, - # 해외발송 - { - 'country': '1', # 미국(1), 일본(81), 중국(86) 등 국가번호 입력, 국가 번호에 띄어쓰기가 있는 경우 붙여서 기입해야 합니다. 예) +1 809 -> '1809' - 'to': '01000000006', # 수신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.' - }, - # 알림톡 발송 - { - 'to': '01000000004', - 'from': '029302266', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx', - # 변수: 값 형식으로 모든 변수에 대한 변수값 입력 - 'variables': { - '#{변수1}': '변수1의 값', - '#{변수2}': '변수2의 값', - '#{버튼링크1}': '버튼링크1의 값', - '#{버튼링크2}': '버튼링크2의 값', - '#{강조문구}': '강조문구의 값' - } - } - } - # ... - # 1만건까지 추가 가능 - ] - } - put('/messages/v4/groups/%s/messages' % groupId, parameter=messagesDict) - - # STEP 3 예약 발송일 등록 - utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone - utc_offset = datetime.timedelta(seconds=-utc_offset_sec) - - # 희망하시는 예약 날짜를 넣어주세요 년 - # datetime(년, 월, 일, 시, 분, 초) - scheduledDate = datetime.datetime(2022, 2, 8, 15, 0, 0).replace( - tzinfo=datetime.timezone(offset=utc_offset)).isoformat() - scheduledDateDict = { - 'scheduledDate': scheduledDate - } - setScheduledGroupResponse = post('/messages/v4/groups/%s/schedule' % groupId, parameter=scheduledDateDict) - print(json.dumps(json.loads(setScheduledGroupResponse.text), indent=2, ensure_ascii=False)) - - # 예약 발송 취소를 희망하실 경우 아래 코드를 추가해주세요. - # delete('/messages/v4/groups/%s/schedule' % groupId) diff --git a/src/lib/__init__.py b/src/lib/__init__.py deleted file mode 100644 index 127027b..0000000 --- a/src/lib/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .message import * -from .config import * -from .auth import * -from .storage import * - -__all__ = ['message', 'config', 'auth', 'storage'] diff --git a/src/lib/auth.py b/src/lib/auth.py deleted file mode 100644 index 738bbfc..0000000 --- a/src/lib/auth.py +++ /dev/null @@ -1,30 +0,0 @@ -import time -import datetime -import uuid -import hmac -import hashlib - - -def unique_id(): - return str(uuid.uuid1().hex) - - -def get_iso_datetime(): - utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone - utc_offset = datetime.timedelta(seconds=-utc_offset_sec) - return datetime.datetime.now().replace(tzinfo=datetime.timezone(offset=utc_offset)).isoformat() - - -def get_signature(key='', msg=''): - return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest() - - -def get_headers(api_key='', api_secret_key=''): - date = get_iso_datetime() - salt = unique_id() - data = date + salt - return { - 'Authorization': 'HMAC-SHA256 ApiKey=' + api_key + ', Date=' + date + ', salt=' + salt + ', signature=' + - get_signature(api_secret_key, data), - 'Content-Type': 'application/json; charset=utf-8' - } diff --git a/src/lib/config-dist.ini b/src/lib/config-dist.ini deleted file mode 100644 index 46aab27..0000000 --- a/src/lib/config-dist.ini +++ /dev/null @@ -1,9 +0,0 @@ -[AUTH] -# 계정의 API Key와 API Secret을 입력해주세요 -api_key = [API KEY] -api_secret = [API SECRET] - -[SERVER] -domain = api.solapi.com -protocol = https -prefix = diff --git a/src/lib/config.py b/src/lib/config.py deleted file mode 100644 index 4cb8663..0000000 --- a/src/lib/config.py +++ /dev/null @@ -1,20 +0,0 @@ -import os -import configparser - -lib_dir = os.path.dirname(__file__) -env_config = configparser.ConfigParser() -env_config.read(lib_dir + '/config.ini') - -api_key = env_config['AUTH']['api_key'] -api_secret = env_config['AUTH']['api_secret'] -protocol = env_config['SERVER']['protocol'] -domain = env_config['SERVER']['domain'] -prefix = env_config['SERVER']['prefix'] and env_config['SERVER']['prefix'] or '' - - -def get_url(path): - url = '%s://%s' % (protocol, domain) - if prefix != '': - url = url + prefix - url = url + path - return url diff --git a/src/lib/message.py b/src/lib/message.py deleted file mode 100644 index 83b64fc..0000000 --- a/src/lib/message.py +++ /dev/null @@ -1,48 +0,0 @@ -import requests -import platform -import src.lib.auth as auth -import src.lib.config as config - -default_agent = { - 'sdkVersion': 'python/4.2.0', - 'osPlatform': platform.platform() + " | " + platform.python_version() -} - - -def send_many(data): - data['agent'] = default_agent - return requests.post(config.get_url('/messages/v4/send-many'), - headers=auth.get_headers(config.api_key, config.api_secret), json=data) - - -def send_one(data): - data['agent'] = default_agent - return requests.post(config.get_url('/messages/v4/send'), - headers=auth.get_headers(config.api_key, config.api_secret), - json=data) - - -def post(path, data): - return requests.post(config.get_url(path), headers=auth.get_headers(config.api_key, config.api_secret), json=data) - - -def put(path, data, headers=None): - if headers is None: - headers = {} - headers.update(auth.get_headers(config.api_key, config.api_secret)) - return requests.put(config.get_url(path), headers=headers, json=data) - - -def get(path, headers=None): - if headers is None: - headers = {} - headers.update(auth.get_headers(config.api_key, config.api_secret)) - return requests.get(config.get_url(path), headers=headers) - - -def delete(path, data): - if data is None: - return requests.delete(config.get_url(path), headers=auth.get_headers(config.api_key, config.api_secret)) - else: - return requests.delete(config.get_url(path), headers=auth.get_headers(config.api_key, config.api_secret), - json=data) diff --git a/src/lib/storage.py b/src/lib/storage.py deleted file mode 100644 index 7837508..0000000 --- a/src/lib/storage.py +++ /dev/null @@ -1,37 +0,0 @@ -import requests -import base64 -import src.lib.config as config -import src.lib.auth as auth - - -def upload_image(path): - with open(path, "rb") as image_file: - encoded_string = base64.b64encode(image_file.read()) - data = { - 'file': str(encoded_string)[2:-1], - 'type': 'MMS' - } - headers = auth.get_headers(config.api_key, config.api_secret) - return requests.post(config.get_url('/storage/v1/files'), headers=headers, json=data) - - -def upload_rcs_image(path): - with open(path, "rb") as image_file: - encoded_string = base64.b64encode(image_file.read()) - data = { - 'file': str(encoded_string)[2:-1], - 'type': 'RCS' - } - headers = auth.get_headers(config.api_key, config.api_secret) - return requests.post(config.get_url('/storage/v1/files'), headers=headers, json=data) - - -def upload_kakao_image(path): - with open(path, "rb") as image_file: - encoded_string = base64.b64encode(image_file.read()) - data = { - 'file': str(encoded_string)[2:-1], - 'type': 'KAKAO' - } - headers = auth.get_headers(config.api_key, config.api_secret) - return requests.post(config.get_url('/storage/v1/files'), headers=headers, json=data) diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..06403a8 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,64 @@ +# SOLAPI Python SDK Tests + +This directory contains unit tests for the SOLAPI Python SDK. The tests cover all the functionality demonstrated in the examples folder. + +## Prerequisites + +- Python 3.9 or higher +- pytest + +## Setup + +1. Install the required dependencies: + ```bash + pip install pytest + ``` + +2. Set up environment variables for API credentials and test phone numbers: + ```bash + export SOLAPI_API_KEY="your_api_key" + export SOLAPI_API_SECRET="your_api_secret" + export SOLAPI_SENDER="your_registered_sender_number" + export SOLAPI_RECIPIENT="recipient_phone_number" + export SOLAPI_KAKAO_PF_ID="your_kakao_business_channel_id" + export SOLAPI_KAKAO_TEMPLATE_ID="your_kakao_template_id" + ``` + + Alternatively, you can modify the `conftest.py` file to hardcode these values for testing purposes. + +## Running Tests + +To run all tests: +```bash +pytest +``` + +To run a specific test file: +```bash +pytest tests/test_balance.py +``` + +To run a specific test: +```bash +pytest tests/test_simple_send.py::TestSimpleSend::test_send_sms +``` + +To run tests with verbose output: +```bash +pytest -v +``` + +## Test Files + +- `test_balance.py`: Tests for checking account balance +- `test_group.py`: Tests for message group operations +- `test_messages.py`: Tests for retrieving message information +- `test_simple_send.py`: Tests for sending various types of messages (SMS, MMS, Kakao Alimtalk, etc.) +- `test_storage.py`: Tests for file upload operations + +## Notes + +- The tests are designed to work with valid API credentials. If you provide invalid credentials, the tests will fail. +- Some tests (like Kakao Alimtalk) require specific setup in your SOLAPI account. +- The MMS and storage tests require the example image file to exist at `examples/images/example.jpg`. +- The tests are designed to be independent of each other, but they may create resources (like message groups) in your SOLAPI account. \ No newline at end of file diff --git a/src/examples/modules/rcs/__init__.py b/tests/__init__.py similarity index 100% rename from src/examples/modules/rcs/__init__.py rename to tests/__init__.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a9cdaa9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,65 @@ +import os + +import pytest + +from solapi import SolapiMessageService + + +@pytest.fixture +def api_credentials(): + """ + Fixture to provide API credentials. + + Returns: + tuple: (api_key, api_secret) + """ + api_key = os.environ.get("SOLAPI_API_KEY", "YOUR_API_KEY") + api_secret = os.environ.get("SOLAPI_API_SECRET", "YOUR_API_SECRET") + return api_key, api_secret + + +@pytest.fixture +def message_service(api_credentials): + """ + Fixture to provide a configured SolapiMessageService instance. + + Args: + api_credentials: Tuple of (api_key, api_secret) + + Returns: + SolapiMessageService: Configured service instance + """ + api_key, api_secret = api_credentials + return SolapiMessageService(api_key=api_key, api_secret=api_secret) + + +@pytest.fixture +def test_phone_numbers(): + """ + Fixture to provide test phone numbers. + + Returns: + dict: Dictionary containing sender and recipient phone numbers + """ + return { + "sender": os.environ.get("SOLAPI_SENDER", "발신번호"), + "recipient": os.environ.get("SOLAPI_RECIPIENT", "수신번호"), + } + + +@pytest.fixture +def test_kakao_options(): + """ + Fixture to provide test Kakao options. + + Returns: + dict: Dictionary containing Kakao channel ID and template ID + """ + return { + "pf_id": os.environ.get( + "SOLAPI_KAKAO_PF_ID", "계정에 등록된 카카오 비즈니스 채널ID" + ), + "template_id": os.environ.get( + "SOLAPI_KAKAO_TEMPLATE_ID", "계정에 등록된 카카오 알림톡 템플릿 ID" + ), + } diff --git a/tests/test_balance.py b/tests/test_balance.py new file mode 100644 index 0000000..8f9e2c6 --- /dev/null +++ b/tests/test_balance.py @@ -0,0 +1,33 @@ +from solapi.model.response.balance.get_balance import GetBalanceResponse + + +class TestBalance: + """Test cases for balance-related functionality.""" + + def test_get_balance(self, message_service): + """ + Test getting account balance. + + This test verifies that the get_balance method returns a valid response + with balance and point information. + + Args: + message_service: The SolapiMessageService fixture + """ + # Get balance + response = message_service.get_balance() + + # Verify response type + assert isinstance(response, GetBalanceResponse) + + # Verify response has required fields + assert hasattr(response, "balance") + assert hasattr(response, "point") + + # Verify balance and point are numeric values + assert isinstance(response.balance, (int, float)) + assert isinstance(response.point, (int, float)) + + # Print balance information for verification + print(f"Current balance: {response.balance} KRW") + print(f"Current points: {response.point} points") 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/tests/test_group.py b/tests/test_group.py new file mode 100644 index 0000000..868ebd3 --- /dev/null +++ b/tests/test_group.py @@ -0,0 +1,107 @@ +import pytest + +from solapi.model.response.groups.get_group_messages import GetGroupMessagesResponse +from solapi.model.response.groups.get_groups import GetGroupsResponse +from solapi.services.message_service import SolapiMessageService + + +class TestGroup: + """Test cases for group-related functionality.""" + + def test_get_groups(self, message_service: SolapiMessageService): + """ + Test getting message groups. + + This test verifies that the get_groups method returns a valid response + with group information. + + Args: + message_service: The SolapiMessageService fixture + """ + # Get groups + response = message_service.get_groups() + + # Verify response type + assert isinstance(response, GetGroupsResponse) + + # Verify response has required fields + assert hasattr(response, "group_list") + + # Print group information for verification + print(f"Number of groups: {len(response.group_list)}") + + # If there are groups, verify the structure of the first group + if response.group_list: + group_id = next(iter(response.group_list)) + group = response.group_list[group_id] + + print(f"Group ID: {group_id}") + print(f"Created: {group.date_created}") + print(f"Status: {group.status}") + + # Test get_group method with this group ID + self.test_get_group(message_service, group_id) + + def test_get_group(self, message_service: SolapiMessageService, group_id=None): + """ + Test getting a specific message group. + + This test verifies that the get_group method returns a valid response + with detailed information about a specific group. + + Args: + message_service: The SolapiMessageService fixture + group_id: Optional group ID to test. If not provided, the test will be skipped. + """ + if group_id is None: + pytest.skip("No group ID provided") + + # Get specific group + response = message_service.get_group(group_id) + + # Verify response has required fields + assert hasattr(response, "group_id") + assert hasattr(response, "date_created") + assert hasattr(response, "status") + + # Print group details for verification + print(f"Group details - ID: {response.group_id}") + print(f"Group details - Created: {response.date_created}") + print(f"Group details - Status: {response.status}") + + # Test get_group_messages method with this group ID + self.test_get_group_messages(message_service, group_id) + + def test_get_group_messages(self, message_service, group_id=None): + """ + Test getting messages in a specific group. + + This test verifies that the get_group_messages method returns a valid response + with messages belonging to a specific group. + + Args: + message_service: The SolapiMessageService fixture + group_id: Optional group ID to test. If not provided, the test will be skipped. + """ + if group_id is None: + pytest.skip("No group ID provided") + + # Get messages in the group + response = message_service.get_group_messages(group_id) + + # Verify response type + assert isinstance(response, GetGroupMessagesResponse) + + # Verify response has required fields + assert hasattr(response, "message_list") + + # Print message information for verification + print(f"Number of messages in group: {len(response.message_list)}") + + # If there are messages, verify the structure of the first message + if response.message_list: + for message in response.message_list.values(): + print(f"Message ID: {message.message_id}") + print(f"Status: {message.status_code}") + print(f"To: {message.to}") + print(f"From: {message.from_}") diff --git a/tests/test_messages.py b/tests/test_messages.py new file mode 100644 index 0000000..e66a825 --- /dev/null +++ b/tests/test_messages.py @@ -0,0 +1,79 @@ +from datetime import datetime, timedelta + +from solapi.model.request.messages.get_messages import GetMessagesRequest +from solapi.model.response.messages.get_messages import GetMessagesResponse + + +class TestMessages: + """Test cases for message-related functionality.""" + + def test_get_messages(self, message_service): + """ + Test getting messages without any filters. + + This test verifies that the get_messages method returns a valid response + with message information. + + Args: + message_service: The SolapiMessageService fixture + """ + # Get messages + response = message_service.get_messages() + + # Verify response type + assert isinstance(response, GetMessagesResponse) + + # Verify response has required fields + assert hasattr(response, "message_list") + + # Print message information for verification + print(f"Messages in response: {len(response.message_list)}") + + # If there are messages, verify the structure of the first message + if response.message_list: + for message in response.message_list.values(): + print(f"Message ID: {message.message_id}") + print(f"Status: {message.status_code}") + print(f"To: {message.to}") + print(f"From: {message.from_}") + + def test_get_messages_with_date_filter(self, message_service): + """ + Test getting messages with date filters. + + This test verifies that the get_messages method with date filters + returns a valid response with filtered message information. + + Args: + message_service: The SolapiMessageService fixture + """ + # Create date range for the last 7 days + end_date = datetime.now() + start_date = end_date - timedelta(days=7) + + # Format dates as strings + start_date_str = start_date.strftime("%Y-%m-%d") + end_date_str = end_date.strftime("%Y-%m-%d") + + # Create request with date filter + request = GetMessagesRequest(start_date=start_date_str, end_date=end_date_str) + + # Get messages with filter + response = message_service.get_messages(request) + + # Verify response type + assert isinstance(response, GetMessagesResponse) + + # Verify response has required fields + assert hasattr(response, "message_list") + + # Print message information for verification + print(f"Messages in response: {len(response.message_list)}") + + # If there are messages, verify the structure of the first message + if response.message_list: + for message in response.message_list.values(): + print(f"Message ID: {message.message_id}") + print(f"Status: {message.status_code}") + print(f"To: {message.to}") + print(f"From: {message.from_}") diff --git a/tests/test_simple_send.py b/tests/test_simple_send.py new file mode 100644 index 0000000..cc7fd9f --- /dev/null +++ b/tests/test_simple_send.py @@ -0,0 +1,314 @@ +from datetime import datetime, timedelta +from pathlib import Path + +import pytest + +from solapi.model import RequestMessage, SendRequestConfig +from solapi.model.kakao.kakao_option import KakaoOption +from solapi.model.request.storage import FileTypeEnum +from solapi.model.response.send_message_response import SendMessageResponse +from solapi.services.message_service import SolapiMessageService + + +class TestSimpleSend: + """Test cases for simple message sending functionality.""" + + def test_send_sms(self, message_service, test_phone_numbers): + """ + Test sending a simple SMS message. + + This test verifies that the send method with a simple SMS message + returns a valid response. + + Args: + message_service: The SolapiMessageService fixture + test_phone_numbers: Dictionary with sender and recipient phone numbers + """ + # Create message + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] SOLAPI Python SDK를 사용한 SMS 발송 테스트입니다.", + ) + + # Send message + response = message_service.send(message) + + # Verify response type + assert isinstance(response, SendMessageResponse) + + # Verify response has required fields + assert hasattr(response, "group_info") + assert hasattr(response.group_info, "group_id") + assert hasattr(response.group_info, "count") + + # Verify message was sent successfully + assert response.group_info.count.total > 0 + assert response.group_info.count.registered_success > 0 + + # Print response information for verification + print(f"Group ID: {response.group_info.group_id}") + print(f"Total messages: {response.group_info.count.total}") + print(f"Successful messages: {response.group_info.count.registered_success}") + print(f"Failed messages: {response.group_info.count.registered_failed}") + + assert response.group_info.group_id is not None + + def test_send_mms(self, message_service, test_phone_numbers): + """ + Test sending an MMS message with an image. + + This test verifies that the upload_file and send methods for MMS + return valid responses. + + Args: + message_service: The SolapiMessageService fixture + test_phone_numbers: Dictionary with sender and recipient phone numbers + """ + # Get the path to the example image + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + + # Skip test if image doesn't exist + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + # Upload image file + file_response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.MMS, + ) + + # Verify file upload response + assert hasattr(file_response, "file_id") + print(f"Uploaded file ID: {file_response.file_id}") + + # Create MMS message + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] SOLAPI Python SDK를 사용한 MMS 발송 테스트입니다.", + subject="MMS 테스트", + image_id=file_response.file_id, + ) + + # Send message + response = message_service.send(message) + + # Verify response type + assert isinstance(response, SendMessageResponse) + + # Verify response has required fields + assert hasattr(response, "group_info") + assert hasattr(response.group_info, "group_id") + assert hasattr(response.group_info, "count") + + # Verify message was sent successfully + assert response.group_info.count.total > 0 + assert response.group_info.count.registered_success > 0 + + # Print response information for verification + print(f"Group ID: {response.group_info.group_id}") + print(f"Total messages: {response.group_info.count.total}") + print(f"Successful messages: {response.group_info.count.registered_success}") + print(f"Failed messages: {response.group_info.count.registered_failed}") + + assert response.group_info.group_id is not None + + def test_send_kakao_alimtalk( + self, message_service, test_phone_numbers, test_kakao_options + ): + """ + Test sending a Kakao Alimtalk message. + + This test verifies that the send method with Kakao options + returns a valid response. + + Args: + message_service: The SolapiMessageService fixture + test_phone_numbers: Dictionary with sender and recipient phone numbers + test_kakao_options: Dictionary with Kakao channel ID and template ID + """ + # Create Kakao options + kakao_option = KakaoOption( + pf_id=test_kakao_options["pf_id"], + template_id=test_kakao_options["template_id"], + ) + + # Create message + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + kakao_options=kakao_option, + ) + + # Send message + try: + response = message_service.send(message) + + # Verify response type + assert isinstance(response, SendMessageResponse) + + # Verify response has required fields + assert hasattr(response, "group_info") + assert hasattr(response.group_info, "group_id") + assert hasattr(response.group_info, "count") + + # Verify message was sent successfully + assert response.group_info.count.total > 0 + assert response.group_info.count.registered_success > 0 + + # Print response information for verification + print(f"Group ID: {response.group_info.group_id}") + print(f"Total messages: {response.group_info.count.total}") + print( + f"Successful messages: {response.group_info.count.registered_success}" + ) + print(f"Failed messages: {response.group_info.count.registered_failed}") + + assert response.group_info.group_id is not None + except Exception as e: + # This test may fail if Kakao template is not properly set up + pytest.skip(f"Kakao Alimtalk test skipped: {str(e)}") + + def test_send_many(self, message_service, test_phone_numbers): + """ + Test sending multiple messages at once. + + This test verifies that the send method with multiple messages + returns a valid response. + + Args: + message_service: The SolapiMessageService fixture + test_phone_numbers: Dictionary with sender and recipient phone numbers + """ + # Create multiple messages + messages = [ + RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트1] SOLAPI Python SDK를 사용한 대량 발송 테스트입니다.", + ), + RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트2] SOLAPI Python SDK를 사용한 대량 발송 테스트입니다.", + ), + RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트3] SOLAPI Python SDK를 사용한 대량 발송 테스트입니다.", + ), + ] + + # Create config with duplicates allowed + config = SendRequestConfig(allow_duplicates=True) + + # Send messages + response = message_service.send(messages, config) + + # Verify response type + assert isinstance(response, SendMessageResponse) + + # Verify response has required fields + assert hasattr(response, "group_info") + assert hasattr(response.group_info, "group_id") + assert hasattr(response.group_info, "count") + + # Verify messages were sent successfully + assert response.group_info.count.total == 3 + assert response.group_info.count.registered_success > 0 + + # Print response information for verification + print(f"Group ID: {response.group_info.group_id}") + print(f"Total messages: {response.group_info.count.total}") + print(f"Successful messages: {response.group_info.count.registered_success}") + print(f"Failed messages: {response.group_info.count.registered_failed}") + + # Check for failed messages + if response.failed_message_list: + print("\nFailed messages:") + for failed in response.failed_message_list: + print(f"To: {failed.message.to}") + print(f"Error: {failed.error.message}") + + assert response.group_info.group_id is not None + + def test_send_with_reservation(self, message_service, test_phone_numbers): + """ + Test sending a message with a future reservation time. + + This test verifies that the send method with a scheduled date + returns a valid response. + + Args: + message_service: The SolapiMessageService fixture + test_phone_numbers: Dictionary with sender and recipient phone numbers + """ + # Create message + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] SOLAPI Python SDK를 사용한 예약 발송 테스트입니다.", + scheduled_date=datetime.now() + timedelta(minutes=10), + ) + + # Send message + response = message_service.send(message) + + # Verify response type + assert isinstance(response, SendMessageResponse) + + # Verify response has required fields + assert hasattr(response, "group_info") + assert hasattr(response.group_info, "group_id") + assert hasattr(response.group_info, "count") + + # Verify message was sent successfully + assert response.group_info.count.total > 0 + assert response.group_info.count.registered_success > 0 + + # Print response information for verification + print(f"Group ID: {response.group_info.group_id}") + print(f"Total messages: {response.group_info.count.total}") + print(f"Successful messages: {response.group_info.count.registered_success}") + print(f"Failed messages: {response.group_info.count.registered_failed}") + + assert response.group_info.group_id is not None + + def test_cancel_reservation( + self, message_service: SolapiMessageService, test_phone_numbers + ): + """ + Test cancelling a reserved message. + + This test verifies that the cancel_scheduled_message method works correctly. + + Args: + message_service: The SolapiMessageService fixture + test_phone_numbers: Dictionary with sender and recipient phone numbers + """ + # Create message with reservation + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] SOLAPI Python SDK 예약 취소 테스트입니다.", + ) + request_config = SendRequestConfig( + scheduled_date=datetime.now() + timedelta(minutes=10) + ) + + # Send message and get group_id + send_response = message_service.send(message, request_config) + group_id = send_response.group_info.group_id + + # Cancel reservation + cancel_response = message_service.cancel_scheduled_message(group_id) + + # Verify cancellation response + assert cancel_response.group_id is not None + + # Print cancellation information for verification + # print(f"Cancellation status: {cancel_response.status}") + print(f"Cancelled message ID: {cancel_response.group_id}") diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..433ae20 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,130 @@ +from pathlib import Path + +import pytest + +from solapi.model.request.storage import FileTypeEnum +from solapi.model.response.storage import FileUploadResponse + + +class TestStorage: + """Test cases for storage-related functionality.""" + + def test_upload_file_mms(self, message_service): + """ + Test uploading a file for MMS. + + This test verifies that the upload_file method returns a valid response + when uploading a file for MMS. + + Args: + message_service: The SolapiMessageService fixture + """ + # Get the path to the example image + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + + # Skip test if image doesn't exist + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + # Upload file + response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.MMS, + ) + + # Verify response type + assert isinstance(response, FileUploadResponse) + + # Verify response has required fields + assert hasattr(response, "file_id") + assert hasattr(response, "name") + assert hasattr(response, "date_created") + + # Print file information for verification + print(f"File ID: {response.file_id}") + print(f"File name: {response.name}") + print(f"File created: {response.date_created}") + + assert response.file_id is not None + + def test_upload_file_kakao(self, message_service): + """ + Test uploading a file for Kakao. + + This test verifies that the upload_file method returns a valid response + when uploading a file for Kakao. + + Args: + message_service: The SolapiMessageService fixture + """ + # Get the path to the example image + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + + # Skip test if image doesn't exist + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + # Upload file + response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.KAKAO, + ) + + # Verify response type + assert isinstance(response, FileUploadResponse) + + # Verify response has required fields + assert hasattr(response, "file_id") + assert hasattr(response, "name") + assert hasattr(response, "date_created") + + # Print file information for verification + print(f"File ID: {response.file_id}") + print(f"File name: {response.name}") + print(f"File created: {response.date_created}") + + assert response.file_id is not None + + def test_upload_file_fax(self, message_service): + """ + Test uploading a file as a fax. + + This test verifies that the upload_file method returns a valid response + when uploading a file as a fax. + + Args: + message_service: The SolapiMessageService fixture + """ + # Get the path to the example image (using as a document for test purposes) + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + + # Skip test if image doesn't exist + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + # Upload file + response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.FAX, + ) + + # Verify response type + assert isinstance(response, FileUploadResponse) + + # Verify response has required fields + assert hasattr(response, "file_id") + assert hasattr(response, "name") + assert hasattr(response, "date_created") + + # Print file information for verification + print(f"File ID: {response.file_id}") + print(f"File name: {response.name}") + print(f"File created: {response.date_created}") + + assert response.file_id is not None diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..71b8abc --- /dev/null +++ b/uv.lock @@ -0,0 +1,502 @@ +version = 1 +revision = 1 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[manifest] +members = [ + "django-example", + "solapi", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, +] + +[[package]] +name = "asgiref" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "django" +version = "4.2.21" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version < '3.10'" }, + { name = "sqlparse", marker = "python_full_version < '3.10'" }, + { name = "tzdata", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/bb/2fad5edc1af2945cb499a2e322ac28e4714fc310bd5201ed1f5a9f73a342/django-4.2.21.tar.gz", hash = "sha256:b54ac28d6aa964fc7c2f7335138a54d78980232011e0cd2231d04eed393dcb0d", size = 10424638 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/4f/aeaa3098da18b625ed672f3da6d1cd94e188d1b2cc27c2c841b2f9666282/django-4.2.21-py3-none-any.whl", hash = "sha256:1d658c7bf5d31c7d0cac1cab58bc1f822df89255080fec81909256c30e6180b3", size = 7993839 }, +] + +[[package]] +name = "django" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version >= '3.10'" }, + { name = "sqlparse", marker = "python_full_version >= '3.10'" }, + { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/10/0d546258772b8f31398e67c85e52c66ebc2b13a647193c3eef8ee433f1a8/django-5.2.1.tar.gz", hash = "sha256:57fe1f1b59462caed092c80b3dd324fd92161b620d59a9ba9181c34746c97284", size = 10818735 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/92/7448697b5838b3a1c6e1d2d6a673e908d0398e84dc4f803a2ce11e7ffc0f/django-5.2.1-py3-none-any.whl", hash = "sha256:a9b680e84f9a0e71da83e399f1e922e1ab37b2173ced046b541c72e1589a5961", size = 8301833 }, +] + +[[package]] +name = "django-example" +version = "0.1.0" +source = { editable = "examples/webhook/django_example" } +dependencies = [ + { name = "django", version = "4.2.21", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "django", version = "5.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "solapi" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = ">=4.2.0" }, + { name = "solapi", editable = "." }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pydantic" +version = "2.11.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900 }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817 }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357 }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011 }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730 }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178 }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462 }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652 }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306 }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720 }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915 }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884 }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496 }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019 }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584 }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071 }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823 }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792 }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998 }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200 }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890 }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359 }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883 }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074 }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538 }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909 }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786 }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000 }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996 }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957 }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199 }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296 }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109 }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028 }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044 }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881 }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034 }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187 }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 }, + { url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677 }, + { url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735 }, + { url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467 }, + { url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041 }, + { url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503 }, + { url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079 }, + { url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508 }, + { url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693 }, + { url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224 }, + { url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403 }, + { url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331 }, + { url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571 }, + { url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504 }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982 }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412 }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749 }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527 }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225 }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490 }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525 }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446 }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678 }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200 }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123 }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852 }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484 }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896 }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475 }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013 }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715 }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757 }, + { url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034 }, + { url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578 }, + { url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858 }, + { url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498 }, + { url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428 }, + { url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854 }, + { url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859 }, + { url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059 }, + { url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661 }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + +[[package]] +name = "ruff" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/2b/7ca27e854d92df5e681e6527dc0f9254c9dc06c8408317893cf96c851cdd/ruff-0.11.0.tar.gz", hash = "sha256:e55c620690a4a7ee6f1cccb256ec2157dc597d109400ae75bbf944fc9d6462e2", size = 3799407 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/40/3d0340a9e5edc77d37852c0cd98c5985a5a8081fc3befaeb2ae90aaafd2b/ruff-0.11.0-py3-none-linux_armv6l.whl", hash = "sha256:dc67e32bc3b29557513eb7eeabb23efdb25753684b913bebb8a0c62495095acb", size = 10098158 }, + { url = "https://files.pythonhosted.org/packages/ec/a9/d8f5abb3b87b973b007649ac7bf63665a05b2ae2b2af39217b09f52abbbf/ruff-0.11.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:38c23fd9bdec4eb437b4c1e3595905a0a8edfccd63a790f818b28c78fe345639", size = 10879071 }, + { url = "https://files.pythonhosted.org/packages/ab/62/aaa198614c6211677913ec480415c5e6509586d7b796356cec73a2f8a3e6/ruff-0.11.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7c8661b0be91a38bd56db593e9331beaf9064a79028adee2d5f392674bbc5e88", size = 10247944 }, + { url = "https://files.pythonhosted.org/packages/9f/52/59e0a9f2cf1ce5e6cbe336b6dd0144725c8ea3b97cac60688f4e7880bf13/ruff-0.11.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6c0e8d3d2db7e9f6efd884f44b8dc542d5b6b590fc4bb334fdbc624d93a29a2", size = 10421725 }, + { url = "https://files.pythonhosted.org/packages/a6/c3/dcd71acc6dff72ce66d13f4be5bca1dbed4db678dff2f0f6f307b04e5c02/ruff-0.11.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c3156d3f4b42e57247275a0a7e15a851c165a4fc89c5e8fa30ea6da4f7407b8", size = 9954435 }, + { url = "https://files.pythonhosted.org/packages/a6/9a/342d336c7c52dbd136dee97d4c7797e66c3f92df804f8f3b30da59b92e9c/ruff-0.11.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:490b1e147c1260545f6d041c4092483e3f6d8eba81dc2875eaebcf9140b53905", size = 11492664 }, + { url = "https://files.pythonhosted.org/packages/84/35/6e7defd2d7ca95cc385ac1bd9f7f2e4a61b9cc35d60a263aebc8e590c462/ruff-0.11.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1bc09a7419e09662983b1312f6fa5dab829d6ab5d11f18c3760be7ca521c9329", size = 12207856 }, + { url = "https://files.pythonhosted.org/packages/22/78/da669c8731bacf40001c880ada6d31bcfb81f89cc996230c3b80d319993e/ruff-0.11.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcfa478daf61ac8002214eb2ca5f3e9365048506a9d52b11bea3ecea822bb844", size = 11645156 }, + { url = "https://files.pythonhosted.org/packages/ee/47/e27d17d83530a208f4a9ab2e94f758574a04c51e492aa58f91a3ed7cbbcb/ruff-0.11.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fbb2aed66fe742a6a3a0075ed467a459b7cedc5ae01008340075909d819df1e", size = 13884167 }, + { url = "https://files.pythonhosted.org/packages/9f/5e/42ffbb0a5d4b07bbc642b7d58357b4e19a0f4774275ca6ca7d1f7b5452cd/ruff-0.11.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92c0c1ff014351c0b0cdfdb1e35fa83b780f1e065667167bb9502d47ca41e6db", size = 11348311 }, + { url = "https://files.pythonhosted.org/packages/c8/51/dc3ce0c5ce1a586727a3444a32f98b83ba99599bb1ebca29d9302886e87f/ruff-0.11.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e4fd5ff5de5f83e0458a138e8a869c7c5e907541aec32b707f57cf9a5e124445", size = 10305039 }, + { url = "https://files.pythonhosted.org/packages/60/e0/475f0c2f26280f46f2d6d1df1ba96b3399e0234cf368cc4c88e6ad10dcd9/ruff-0.11.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:96bc89a5c5fd21a04939773f9e0e276308be0935de06845110f43fd5c2e4ead7", size = 9937939 }, + { url = "https://files.pythonhosted.org/packages/e2/d3/3e61b7fd3e9cdd1e5b8c7ac188bec12975c824e51c5cd3d64caf81b0331e/ruff-0.11.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a9352b9d767889ec5df1483f94870564e8102d4d7e99da52ebf564b882cdc2c7", size = 10923259 }, + { url = "https://files.pythonhosted.org/packages/30/32/cd74149ebb40b62ddd14bd2d1842149aeb7f74191fb0f49bd45c76909ff2/ruff-0.11.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:049a191969a10897fe052ef9cc7491b3ef6de79acd7790af7d7897b7a9bfbcb6", size = 11406212 }, + { url = "https://files.pythonhosted.org/packages/00/ef/033022a6b104be32e899b00de704d7c6d1723a54d4c9e09d147368f14b62/ruff-0.11.0-py3-none-win32.whl", hash = "sha256:3191e9116b6b5bbe187447656f0c8526f0d36b6fd89ad78ccaad6bdc2fad7df2", size = 10310905 }, + { url = "https://files.pythonhosted.org/packages/ed/8a/163f2e78c37757d035bd56cd60c8d96312904ca4a6deeab8442d7b3cbf89/ruff-0.11.0-py3-none-win_amd64.whl", hash = "sha256:c58bfa00e740ca0a6c43d41fb004cd22d165302f360aaa56f7126d544db31a21", size = 11411730 }, + { url = "https://files.pythonhosted.org/packages/4e/f7/096f6efabe69b49d7ca61052fc70289c05d8d35735c137ef5ba5ef423662/ruff-0.11.0-py3-none-win_arm64.whl", hash = "sha256:868364fc23f5aa122b00c6f794211e85f7e78f5dffdf7c590ab90b8c4e69b657", size = 10538956 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "solapi" +version = "5.0.3" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1,<0.29.0" }, + { 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"] + +[[package]] +name = "sqlparse" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +]