From 53905008cfef24f25810335c944c5acba2e341a4 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 10:22:37 +0900 Subject: [PATCH 01/12] docs: add CLAUDE.md, AGENTS.md for Claude Code guidance Add project documentation files to guide Claude Code when working with this codebase. Includes project overview, architecture details, conventions, and anti-patterns specific to this SDK. Co-Authored-By: Claude Opus 4.5 --- AGENTS.md | 150 +++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 104 ++++++++++++++++++++++++++++ solapi/model/AGENTS.md | 115 +++++++++++++++++++++++++++++++ 3 files changed, 369 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 solapi/model/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8660858 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,150 @@ +# 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+ + +## 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.2" # 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..bf3d015 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# 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` + +## 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/solapi/model/AGENTS.md b/solapi/model/AGENTS.md new file mode 100644 index 0000000..d39ebe2 --- /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.2" # 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 From efc3edfbbcb8288a3b06dcd708cc1b8cc49abd99 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 11:15:28 +0900 Subject: [PATCH 02/12] =?UTF-8?q?chore:=20Claude=20Code=20Hooks=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9C=BC=EB=A1=9C=20Python=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20lint/fix=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python 파일 수정 시 자동으로 실행되는 Hook 설정: - ruff check --fix: 린트 오류 자동 수정 - ruff format: 코드 포매팅 - ty check: 타입 체크 (오류 시 Claude에게 수정 요청) PostToolUse 이벤트에서 Edit/Write/NotebookEdit 도구 사용 후 실행됩니다. Co-Authored-By: Claude Opus 4.5 --- .claude/hooks/lint-python.py | 65 ++++++++++++++++++++++++++++++++++++ .claude/settings.json | 15 +++++++++ pyproject.toml | 3 +- uv.lock | 28 +++++++++++++++- 4 files changed, 109 insertions(+), 2 deletions(-) create mode 100755 .claude/hooks/lint-python.py create mode 100644 .claude/settings.json 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/pyproject.toml b/pyproject.toml index c3b7b39..6698f79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,8 @@ dependencies = [ [project.optional-dependencies] dev = [ "ruff>=0.11.0", - "pytest>=7.0.0" + "pytest>=7.0.0", + "ty>=0.0.1" ] [build-system] diff --git a/uv.lock b/uv.lock index 4d60c0a..dc4604e 100644 --- a/uv.lock +++ b/uv.lock @@ -375,7 +375,7 @@ wheels = [ [[package]] name = "solapi" -version = "5.0.1" +version = "5.0.2" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -386,6 +386,7 @@ dependencies = [ dev = [ { name = "pytest" }, { name = "ruff" }, + { name = "ty" }, ] [package.metadata] @@ -394,6 +395,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.11.4,<3.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.0" }, + { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1" }, ] provides-extras = ["dev"] @@ -445,6 +447,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] +[[package]] +name = "ty" +version = "0.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/dc/b607f00916f5a7c52860b84a66dc17bc6988e8445e96b1d6e175a3837397/ty-0.0.13.tar.gz", hash = "sha256:7a1d135a400ca076407ea30012d1f75419634160ed3b9cad96607bf2956b23b3", size = 4999183 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/df/3632f1918f4c0a33184f107efc5d436ab6da147fd3d3b94b3af6461efbf4/ty-0.0.13-py3-none-linux_armv6l.whl", hash = "sha256:1b2b8e02697c3a94c722957d712a0615bcc317c9b9497be116ef746615d892f2", size = 9993501 }, + { url = "https://files.pythonhosted.org/packages/92/87/6a473ced5ac280c6ce5b1627c71a8a695c64481b99aabc798718376a441e/ty-0.0.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f15cdb8e233e2b5adfce673bb21f4c5e8eaf3334842f7eea3c70ac6fda8c1de5", size = 9860986 }, + { url = "https://files.pythonhosted.org/packages/5d/9b/d89ae375cf0a7cd9360e1164ce017f8c753759be63b6a11ed4c944abe8c6/ty-0.0.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0819e89ac9f0d8af7a062837ce197f0461fee2fc14fd07e2c368780d3a397b73", size = 9350748 }, + { url = "https://files.pythonhosted.org/packages/a8/a6/9ad58518056fab344b20c0bb2c1911936ebe195318e8acc3bc45ac1c6b6b/ty-0.0.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de79f481084b7cc7a202ba0d7a75e10970d10ffa4f025b23f2e6b7324b74886", size = 9849884 }, + { url = "https://files.pythonhosted.org/packages/b1/c3/8add69095fa179f523d9e9afcc15a00818af0a37f2b237a9b59bc0046c34/ty-0.0.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4fb2154cff7c6e95d46bfaba283c60642616f20d73e5f96d0c89c269f3e1bcec", size = 9822975 }, + { url = "https://files.pythonhosted.org/packages/a4/05/4c0927c68a0a6d43fb02f3f0b6c19c64e3461dc8ed6c404dde0efb8058f7/ty-0.0.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00be58d89337c27968a20d58ca553458608c5b634170e2bec82824c2e4cf4d96", size = 10294045 }, + { url = "https://files.pythonhosted.org/packages/b4/86/6dc190838aba967557fe0bfd494c595d00b5081315a98aaf60c0e632aaeb/ty-0.0.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72435eade1fa58c6218abb4340f43a6c3ff856ae2dc5722a247d3a6dd32e9737", size = 10916460 }, + { url = "https://files.pythonhosted.org/packages/04/40/9ead96b7c122e1109dfcd11671184c3506996bf6a649306ec427e81d9544/ty-0.0.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:77a548742ee8f621d718159e7027c3b555051d096a49bb580249a6c5fc86c271", size = 10597154 }, + { url = "https://files.pythonhosted.org/packages/aa/7d/e832a2c081d2be845dc6972d0c7998914d168ccbc0b9c86794419ab7376e/ty-0.0.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da067c57c289b7cf914669704b552b6207c2cc7f50da4118c3e12388642e6b3f", size = 10410710 }, + { url = "https://files.pythonhosted.org/packages/31/e3/898be3a96237a32f05c4c29b43594dc3b46e0eedfe8243058e46153b324f/ty-0.0.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d1b50a01fffa140417fca5a24b658fbe0734074a095d5b6f0552484724474343", size = 9826299 }, + { url = "https://files.pythonhosted.org/packages/bb/eb/db2d852ce0ed742505ff18ee10d7d252f3acfd6fc60eca7e9c7a0288a6d8/ty-0.0.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0f33c46f52e5e9378378eca0d8059f026f3c8073ace02f7f2e8d079ddfe5207e", size = 9831610 }, + { url = "https://files.pythonhosted.org/packages/9e/61/149f59c8abaddcbcbb0bd13b89c7741ae1c637823c5cf92ed2c644fcadef/ty-0.0.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:168eda24d9a0b202cf3758c2962cc295878842042b7eca9ed2965259f59ce9f2", size = 9978885 }, + { url = "https://files.pythonhosted.org/packages/a0/cd/026d4e4af60a80918a8d73d2c42b8262dd43ab2fa7b28d9743004cb88d57/ty-0.0.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d4917678b95dc8cb399cc459fab568ba8d5f0f33b7a94bf840d9733043c43f29", size = 10506453 }, + { url = "https://files.pythonhosted.org/packages/63/06/8932833a4eca2df49c997a29afb26721612de8078ae79074c8fe87e17516/ty-0.0.13-py3-none-win32.whl", hash = "sha256:c1f2ec40daa405508b053e5b8e440fbae5fdb85c69c9ab0ee078f8bc00eeec3d", size = 9433482 }, + { url = "https://files.pythonhosted.org/packages/aa/fd/e8d972d1a69df25c2cecb20ea50e49ad5f27a06f55f1f5f399a563e71645/ty-0.0.13-py3-none-win_amd64.whl", hash = "sha256:8b7b1ab9f187affbceff89d51076038363b14113be29bda2ddfa17116de1d476", size = 10319156 }, + { url = "https://files.pythonhosted.org/packages/2d/c2/05fdd64ac003a560d4fbd1faa7d9a31d75df8f901675e5bed1ee2ceeff87/ty-0.0.13-py3-none-win_arm64.whl", hash = "sha256:1c9630333497c77bb9bcabba42971b96ee1f36c601dd3dcac66b4134f9fa38f0", size = 9808316 }, +] + [[package]] name = "typing-extensions" version = "4.12.2" From d5c5dbf2ae7811faf8f160234673640fb5cb7cc9 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 11:23:21 +0900 Subject: [PATCH 03/12] docs: add Tidy First principles to CLAUDE.md and AGENTS.md Add Kent Beck's "Tidy First?" guidelines to enforce separation of structural (refactoring) and behavioral (feature) changes in commits. Co-Authored-By: Claude Opus 4.5 --- AGENTS.md | 6 ++++++ CLAUDE.md | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8660858..e2dec6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,12 @@ model/ - 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 diff --git a/CLAUDE.md b/CLAUDE.md index bf3d015..948123a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,26 @@ When releasing, update version in BOTH locations: - **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 | From 4f5f51c9f788639ebd36cb8b709eb9ff8b6caea0 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 14:21:20 +0900 Subject: [PATCH 04/12] Update version to 5.0.3 and add BMS (Brand Message Service) models - Incremented version in pyproject.toml from 5.0.2 to 5.0.3. - Introduced new BMS message types in message_type.py. - Added BMS-related models including Bms, BmsButton, BmsCarousel, BmsCommerce, BmsCoupon, BmsVideo, and BmsOption. - Implemented validation logic for BMS models to ensure required fields are present. - Added tests for BMS functionality to ensure proper behavior and validation. This update enhances the SDK's capabilities for handling brand messaging services. --- pyproject.toml | 2 +- solapi/model/kakao/bms/__init__.py | 62 ++ solapi/model/kakao/bms/bms_button.py | 117 +++ solapi/model/kakao/bms/bms_carousel.py | 68 ++ solapi/model/kakao/bms/bms_commerce.py | 73 ++ solapi/model/kakao/bms/bms_coupon.py | 55 ++ solapi/model/kakao/bms/bms_option.py | 94 +++ solapi/model/kakao/bms/bms_video.py | 24 + solapi/model/kakao/bms/bms_wide_item.py | 26 + solapi/model/message_type.py | 18 + solapi/model/request/kakao/bms.py | 95 ++- solapi/model/request/message.py | 1 + solapi/model/request/storage.py | 7 + tests/test_bms_free.py | 903 ++++++++++++++++++++++++ 14 files changed, 1541 insertions(+), 4 deletions(-) create mode 100644 solapi/model/kakao/bms/__init__.py create mode 100644 solapi/model/kakao/bms/bms_button.py create mode 100644 solapi/model/kakao/bms/bms_carousel.py create mode 100644 solapi/model/kakao/bms/bms_commerce.py create mode 100644 solapi/model/kakao/bms/bms_coupon.py create mode 100644 solapi/model/kakao/bms/bms_option.py create mode 100644 solapi/model/kakao/bms/bms_video.py create mode 100644 solapi/model/kakao/bms/bms_wide_item.py create mode 100644 tests/test_bms_free.py diff --git a/pyproject.toml b/pyproject.toml index 6698f79..4794770 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "solapi" -version = "5.0.2" +version = "5.0.3" description = "SOLAPI SDK for Python" authors = [ { name = "SOLAPI Team", email = "contact@solapi.com" } diff --git a/solapi/model/kakao/bms/__init__.py b/solapi/model/kakao/bms/__init__.py new file mode 100644 index 0000000..915c673 --- /dev/null +++ b/solapi/model/kakao/bms/__init__.py @@ -0,0 +1,62 @@ +"""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 +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", +] 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..f8b0d9c --- /dev/null +++ b/solapi/model/kakao/bms/bms_carousel.py @@ -0,0 +1,68 @@ +from typing import List, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from solapi.model.kakao.bms.bms_button import BmsAppButton, BmsWebButton +from solapi.model.kakao.bms.bms_commerce import BmsCommerce +from solapi.model.kakao.bms.bms_coupon import BmsCoupon + +BmsLinkButton = Union[BmsWebButton, BmsAppButton] + + +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..d6d72f4 --- /dev/null +++ b/solapi/model/kakao/bms/bms_commerce.py @@ -0,0 +1,73 @@ +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 has_discount_price and not has_discount_rate and not has_discount_fixed: + return self + + if has_discount_price and has_discount_rate and not has_discount_fixed: + return self + + if has_discount_price and has_discount_fixed and not has_discount_rate: + return self + + if has_discount_rate and has_discount_fixed: + raise ValueError( + "discountRate와 discountFixed는 동시에 사용할 수 없습니다. " + "할인율(discountRate) 또는 정액할인(discountFixed) 중 하나만 선택하세요." + ) + + if not has_discount_price and (has_discount_rate or has_discount_fixed): + raise ValueError( + "discountRate 또는 discountFixed를 사용하려면 " + "discountPrice(할인가)도 함께 지정해야 합니다." + ) + + if has_discount_price and not has_discount_rate and not has_discount_fixed: + raise ValueError( + "discountPrice를 사용하려면 discountRate(할인율) 또는 " + "discountFixed(정액할인) 중 하나를 함께 지정해야 합니다." + ) + + raise ValueError( + "알 수 없는 가격 조합입니다. regularPrice만 사용하거나, " + "regularPrice + discountPrice + discountRate/discountFixed 조합을 사용하세요." + ) diff --git a/solapi/model/kakao/bms/bms_coupon.py b/solapi/model/kakao/bms/bms_coupon.py new file mode 100644 index 0000000..28c7dca --- /dev/null +++ b/solapi/model/kakao/bms/bms_coupon.py @@ -0,0 +1,55 @@ +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 diff --git a/solapi/model/kakao/bms/bms_option.py b/solapi/model/kakao/bms/bms_option.py new file mode 100644 index 0000000..ee6f07f --- /dev/null +++ b/solapi/model/kakao/bms/bms_option.py @@ -0,0 +1,94 @@ +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_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 + + +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": + chat_bubble_type = self.chat_bubble_type + required_fields = BMS_REQUIRED_FIELDS.get(chat_bubble_type, []) + missing_fields = [ + field for field in required_fields if getattr(self, field, None) 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": + sub_wide_item_list = self.sub_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}개" + ) + + return self + + +def _to_camel(s: str) -> str: + components = s.split("_") + return components[0] + "".join(x.title() for x in components[1:]) diff --git a/solapi/model/kakao/bms/bms_video.py b/solapi/model/kakao/bms/bms_video.py new file mode 100644 index 0000000..4e6eecb --- /dev/null +++ b/solapi/model/kakao/bms/bms_video.py @@ -0,0 +1,24 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator +from pydantic.alias_generators import to_camel + +KAKAO_TV_URL_PREFIX = "https://tv.kakao.com/" + + +class BmsVideo(BaseModel): + video_url: str + image_id: Optional[str] = None + image_link: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + @field_validator("video_url") + @classmethod + def validate_kakao_tv_url(cls, v: str) -> str: + if not v.startswith(KAKAO_TV_URL_PREFIX): + raise ValueError( + f"videoUrl은 '{KAKAO_TV_URL_PREFIX}'으로 시작하는 " + "카카오TV 동영상 링크여야 합니다." + ) + return v diff --git a/solapi/model/kakao/bms/bms_wide_item.py b/solapi/model/kakao/bms/bms_wide_item.py new file mode 100644 index 0000000..a6c5bcd --- /dev/null +++ b/solapi/model/kakao/bms/bms_wide_item.py @@ -0,0 +1,26 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class BmsMainWideItem(BaseModel): + title: Optional[str] = None + image_id: Optional[str] = None + link_mobile: Optional[str] = None + link_pc: Optional[str] = None + link_android: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsSubWideItem(BaseModel): + title: Optional[str] = None + image_id: Optional[str] = None + link_mobile: Optional[str] = None + link_pc: Optional[str] = None + link_android: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/message_type.py b/solapi/model/message_type.py index cfa8b4c..652b5e2 100644 --- a/solapi/model/message_type.py +++ b/solapi/model/message_type.py @@ -19,6 +19,15 @@ class MessageType(Enum): RCS_LTPL: RCS LMS 템플릿 문자 FAX: 팩스 VOICE: 음성문자(TTS) + BMS_TEXT: 브랜드 메시지 텍스트형 + BMS_IMAGE: 브랜드 메시지 이미지형 + BMS_WIDE: 브랜드 메시지 와이드형 + BMS_WIDE_ITEM_LIST: 브랜드 메시지 와이드 아이템 리스트형 + BMS_CAROUSEL_FEED: 브랜드 메시지 캐러셀 피드형 + BMS_PREMIUM_VIDEO: 브랜드 메시지 프리미엄 비디오형 + BMS_COMMERCE: 브랜드 메시지 커머스형 + BMS_CAROUSEL_COMMERCE: 브랜드 메시지 캐러셀 커머스형 + BMS_FREE: 브랜드 메시지 자유형 """ SMS = "SMS" @@ -36,6 +45,15 @@ class MessageType(Enum): RCS_LTPL = "RCS_LTPL" FAX = "FAX" VOICE = "VOICE" + BMS_TEXT = "BMS_TEXT" + BMS_IMAGE = "BMS_IMAGE" + BMS_WIDE = "BMS_WIDE" + BMS_WIDE_ITEM_LIST = "BMS_WIDE_ITEM_LIST" + BMS_CAROUSEL_FEED = "BMS_CAROUSEL_FEED" + BMS_PREMIUM_VIDEO = "BMS_PREMIUM_VIDEO" + BMS_COMMERCE = "BMS_COMMERCE" + BMS_CAROUSEL_COMMERCE = "BMS_CAROUSEL_COMMERCE" + BMS_FREE = "BMS_FREE" def __str__(self) -> str: return self.value diff --git a/solapi/model/request/kakao/bms.py b/solapi/model/request/kakao/bms.py index 9a59c51..6f59810 100644 --- a/solapi/model/request/kakao/bms.py +++ b/solapi/model/request/kakao/bms.py @@ -1,12 +1,101 @@ -from typing import Literal +from typing import Literal, Optional, Union -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, model_validator +from pydantic.alias_generators import to_camel + +from solapi.model.kakao.bms.bms_button import BmsButton +from solapi.model.kakao.bms.bms_carousel import ( + BmsCarouselCommerceSchema, + BmsCarouselFeedSchema, +) +from solapi.model.kakao.bms.bms_commerce import BmsCommerce +from solapi.model.kakao.bms.bms_coupon import BmsCoupon +from solapi.model.kakao.bms.bms_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:]) class Bms(BaseModel): - targeting: Literal["M", "N", "I"] + targeting: Optional[Literal["I", "M", "N"]] = None + chat_bubble_type: Optional[BmsChatBubbleType] = None + + adult: Optional[bool] = None + header: Optional[str] = None + image_id: Optional[str] = None + image_link: Optional[str] = None + additional_content: Optional[str] = None + content: Optional[str] = None + + carousel: Optional[Union[BmsCarouselFeedSchema, BmsCarouselCommerceSchema]] = None + main_wide_item: Optional[BmsMainWideItem] = None + sub_wide_item_list: Optional[list[BmsSubWideItem]] = None + buttons: Optional[list[BmsButton]] = None + coupon: Optional[BmsCoupon] = None + commerce: Optional[BmsCommerce] = None + video: Optional[BmsVideo] = None model_config = ConfigDict( + alias_generator=to_camel, populate_by_name=True, extra="ignore", ) + + @model_validator(mode="after") + def validate_required_fields(self) -> "Bms": + chat_bubble_type = self.chat_bubble_type + if chat_bubble_type is None: + return self + + required_fields = BMS_REQUIRED_FIELDS.get(chat_bubble_type, []) + missing_fields = [ + field for field in required_fields if getattr(self, field, None) 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": + sub_wide_item_list = self.sub_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}개" + ) + + return self diff --git a/solapi/model/request/message.py b/solapi/model/request/message.py index 793edc1..c5fa214 100644 --- a/solapi/model/request/message.py +++ b/solapi/model/request/message.py @@ -81,4 +81,5 @@ def normalize_to_phone_number( extra="ignore", populate_by_name=True, alias_generator=to_camel, + use_enum_values=True, ) diff --git a/solapi/model/request/storage.py b/solapi/model/request/storage.py index e46a657..dff6bbd 100644 --- a/solapi/model/request/storage.py +++ b/solapi/model/request/storage.py @@ -9,6 +9,13 @@ class FileTypeEnum(str, Enum): KAKAO = "KAKAO" RCS = "RCS" FAX = "FAX" + # BMS (Brand Message Service) file types + BMS = "BMS" + BMS_WIDE = "BMS_WIDE" + BMS_WIDE_MAIN_ITEM_LIST = "BMS_WIDE_MAIN_ITEM_LIST" + BMS_WIDE_SUB_ITEM_LIST = "BMS_WIDE_SUB_ITEM_LIST" + BMS_CAROUSEL_FEED_LIST = "BMS_CAROUSEL_FEED_LIST" + BMS_CAROUSEL_COMMERCE_LIST = "BMS_CAROUSEL_COMMERCE_LIST" class FileUploadRequest(BaseModel): diff --git a/tests/test_bms_free.py b/tests/test_bms_free.py new file mode 100644 index 0000000..35a27db --- /dev/null +++ b/tests/test_bms_free.py @@ -0,0 +1,903 @@ +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) + + 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}") + except Exception as e: + pytest.skip(f"BMS FREE TEXT test skipped: {e}") + + 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) + + 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}") + except Exception as e: + pytest.skip(f"BMS FREE TEXT with buttons test skipped: {e}") + + 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) + + 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}") + except Exception as e: + pytest.skip(f"BMS FREE IMAGE test skipped: {e}") + + 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) + + 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}") + except Exception as e: + pytest.skip(f"BMS FREE COMMERCE test skipped: {e}") + + 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) + + 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}") + except Exception as e: + pytest.skip(f"BMS FREE WIDE test skipped: {e}") + + def test_send_bms_wide_item_list( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE WIDE_ITEM_LIST 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: + main_file_response = message_service.upload_file( + file_path=str(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(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) + + 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}") + except Exception as e: + pytest.skip(f"BMS FREE WIDE_ITEM_LIST test skipped: {e}") + + 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) + + 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}") + except Exception as e: + pytest.skip(f"BMS FREE CAROUSEL_FEED test skipped: {e}") + + 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) + + 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}") + except Exception as e: + pytest.skip(f"BMS FREE CAROUSEL_COMMERCE test skipped: {e}") + + 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) + + 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}") + except Exception as e: + pytest.skip(f"BMS FREE PREMIUM_VIDEO test skipped: {e}") From c6bcc8d9df7f58fa50bff2ed9cc65ae37598b189 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 14:32:12 +0900 Subject: [PATCH 05/12] Update solapi package version to 5.0.3 in uv.lock --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index dc4604e..71b8abc 100644 --- a/uv.lock +++ b/uv.lock @@ -375,7 +375,7 @@ wheels = [ [[package]] name = "solapi" -version = "5.0.2" +version = "5.0.3" source = { editable = "." } dependencies = [ { name = "httpx" }, From 27c8d0ca27b56b15163b7d179fd55e42c49fb2dd Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 14:33:37 +0900 Subject: [PATCH 06/12] Update version to 5.0.3 in AGENTS.md and related files --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e2dec6b..bffc9c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ model/ ### VERSION SYNC REQUIRED ```python # solapi/model/request/__init__.py -VERSION = "python/5.0.2" # MUST update on every release! +VERSION = "python/5.0.3" # MUST update on every release! ``` Also update `pyproject.toml` version. From 726e1639c9e73f0eeba78d699380d3c011e32099 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 14:33:59 +0900 Subject: [PATCH 07/12] Update version to 5.0.3 in AGENTS.md and request module --- solapi/model/AGENTS.md | 2 +- solapi/model/request/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/solapi/model/AGENTS.md b/solapi/model/AGENTS.md index d39ebe2..a8e8daf 100644 --- a/solapi/model/AGENTS.md +++ b/solapi/model/AGENTS.md @@ -90,7 +90,7 @@ image_id: Optional[str] = Field(default=None, alias="imageId") ### VERSION IN THIS PACKAGE ```python # request/__init__.py line 1-2 -VERSION = "python/5.0.2" # Sync with pyproject.toml! +VERSION = "python/5.0.3" # Sync with pyproject.toml! ``` ## KEY CLASSES diff --git a/solapi/model/request/__init__.py b/solapi/model/request/__init__.py index 0fe91e2..7d920d8 100644 --- a/solapi/model/request/__init__.py +++ b/solapi/model/request/__init__.py @@ -1,2 +1,2 @@ # NOTE: Python SDK가 업데이트 될 때마다 Version도 갱신해야 함! -VERSION = "python/5.0.2" +VERSION = "python/5.0.3" From bee47b7446713510ec9289b97c8cd63c9852a834 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 15:18:37 +0900 Subject: [PATCH 08/12] Enhance BMS functionality and add new images - Added support for development requirements in `uv.lock`. - Introduced new images: `example_square.jpg` and `example_wide.jpg`. - Refactored BMS models to use `list` instead of `List` for type hints. - Improved validation logic in `BmsCommerce` for discount handling. - Updated tests to ensure proper assertions and added image checks for BMS wide item list. --- examples/images/example_square.jpg | Bin 0 -> 18009 bytes examples/images/example_wide.jpg | Bin 0 -> 25983 bytes solapi/model/kakao/bms/bms_carousel.py | 14 +-- solapi/model/kakao/bms/bms_commerce.py | 38 +++--- solapi/model/request/kakao/bms.py | 35 +----- tests/test_bms_free.py | 166 +++++++++++++------------ uv.lock | 3 + 7 files changed, 118 insertions(+), 138 deletions(-) create mode 100644 examples/images/example_square.jpg create mode 100644 examples/images/example_wide.jpg diff --git a/examples/images/example_square.jpg b/examples/images/example_square.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8598047f0b24592175617f1758fd5ad72a75930d GIT binary patch literal 18009 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 MuHQ^N*!jf&17Uwa+W-In literal 0 HcmV?d00001 diff --git a/examples/images/example_wide.jpg b/examples/images/example_wide.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f7af943848c3ef4be0cb7a747e90ef842a7b09f4 GIT binary patch literal 25983 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 SpQsC|Uhw<}0E_l;=zjojG9YIF literal 0 HcmV?d00001 diff --git a/solapi/model/kakao/bms/bms_carousel.py b/solapi/model/kakao/bms/bms_carousel.py index f8b0d9c..7e129b9 100644 --- a/solapi/model/kakao/bms/bms_carousel.py +++ b/solapi/model/kakao/bms/bms_carousel.py @@ -1,14 +1,12 @@ -from typing import List, Optional, Union +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 BmsAppButton, BmsWebButton +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 -BmsLinkButton = Union[BmsWebButton, BmsAppButton] - class BmsCarouselHead(BaseModel): header: Optional[str] = None @@ -36,14 +34,14 @@ class BmsCarouselFeedItem(BaseModel): content: Optional[str] = None image_id: Optional[str] = None image_link: Optional[str] = None - buttons: Optional[List[BmsLinkButton]] = 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") + items: Optional[list[BmsCarouselFeedItem]] = Field(default=None, alias="list") tail: Optional[BmsCarouselTail] = None model_config = ConfigDict(populate_by_name=True) @@ -53,7 +51,7 @@ class BmsCarouselCommerceItem(BaseModel): commerce: Optional[BmsCommerce] = None image_id: Optional[str] = None image_link: Optional[str] = None - buttons: Optional[List[BmsLinkButton]] = None + buttons: Optional[list[BmsLinkButton]] = None additional_content: Optional[str] = None coupon: Optional[BmsCoupon] = None @@ -62,7 +60,7 @@ class BmsCarouselCommerceItem(BaseModel): class BmsCarouselCommerceSchema(BaseModel): head: Optional[BmsCarouselHead] = None - items: Optional[List[BmsCarouselCommerceItem]] = Field(default=None, alias="list") + 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 index d6d72f4..aee583f 100644 --- a/solapi/model/kakao/bms/bms_commerce.py +++ b/solapi/model/kakao/bms/bms_commerce.py @@ -40,34 +40,28 @@ def validate_price_combination(self) -> "BmsCommerce": has_discount_rate = self.discount_rate is not None has_discount_fixed = self.discount_fixed is not None - if not has_discount_price and not has_discount_rate and not has_discount_fixed: - return self - - if has_discount_price and has_discount_rate and not has_discount_fixed: - return self - - if has_discount_price and has_discount_fixed and not has_discount_rate: + # 할인 정보 없음 = 유효 + 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) 중 하나만 선택하세요." ) - if not has_discount_price and (has_discount_rate or has_discount_fixed): - raise ValueError( - "discountRate 또는 discountFixed를 사용하려면 " - "discountPrice(할인가)도 함께 지정해야 합니다." - ) - - if has_discount_price and not has_discount_rate and not has_discount_fixed: - raise ValueError( - "discountPrice를 사용하려면 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(할인가)도 함께 지정해야 합니다." + ) - raise ValueError( - "알 수 없는 가격 조합입니다. regularPrice만 사용하거나, " - "regularPrice + discountPrice + discountRate/discountFixed 조합을 사용하세요." - ) + return self diff --git a/solapi/model/request/kakao/bms.py b/solapi/model/request/kakao/bms.py index 6f59810..1bf605b 100644 --- a/solapi/model/request/kakao/bms.py +++ b/solapi/model/request/kakao/bms.py @@ -10,38 +10,15 @@ ) 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 ( + BMS_REQUIRED_FIELDS, + WIDE_ITEM_LIST_MIN_SUB_ITEMS, + BmsChatBubbleType, + _to_camel, +) 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:]) - class Bms(BaseModel): targeting: Optional[Literal["I", "M", "N"]] = None diff --git a/tests/test_bms_free.py b/tests/test_bms_free.py index 35a27db..43afbe3 100644 --- a/tests/test_bms_free.py +++ b/tests/test_bms_free.py @@ -331,17 +331,17 @@ def test_send_bms_text_minimal( try: response = message_service.send(message) - - 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}") 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 ): @@ -387,17 +387,17 @@ def test_send_bms_text_with_buttons( try: response = message_service.send(message) - - 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}") 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 ): @@ -445,17 +445,17 @@ def test_send_bms_image( ) response = message_service.send(message) - - 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}") 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 ): @@ -513,17 +513,17 @@ def test_send_bms_commerce( ) response = message_service.send(message) - - 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}") 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 ): @@ -577,21 +577,24 @@ def test_send_bms_wide( ) response = message_service.send(message) - - 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}") 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.""" + """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 @@ -605,22 +608,27 @@ def test_send_bms_wide_item_list( 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" + main_image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example_wide.jpg" ) - if not image_path.exists(): - pytest.skip(f"Test image not found at {image_path}") + 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(image_path), + 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(image_path), + file_path=str(sub_image_path), upload_type=FileTypeEnum.BMS_WIDE_SUB_ITEM_LIST, ) sub_image_id = sub_file_response.file_id @@ -669,17 +677,17 @@ def test_send_bms_wide_item_list( ) response = message_service.send(message) - - 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}") 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 ): @@ -751,17 +759,17 @@ def test_send_bms_carousel_feed( ) response = message_service.send(message) - - 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}") 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 ): @@ -841,17 +849,17 @@ def test_send_bms_carousel_commerce( ) response = message_service.send(message) - - 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}") 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 ): @@ -891,13 +899,13 @@ def test_send_bms_premium_video( ) response = message_service.send(message) - - 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}") except Exception as e: pytest.skip(f"BMS FREE PREMIUM_VIDEO test skipped: {e}") + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") diff --git a/uv.lock b/uv.lock index 71b8abc..735b56a 100644 --- a/uv.lock +++ b/uv.lock @@ -399,6 +399,9 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [] + [[package]] name = "sqlparse" version = "0.5.3" From c04acf092de187c6707652f1d63745f7bd968c34 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 15:42:04 +0900 Subject: [PATCH 09/12] Refactor BMS models and add new examples - Removed unused development requirements from `uv.lock`. - Introduced new example scripts for various BMS message types, including carousel commerce, carousel feed, and commerce messages. - Added validation for required fields in BMS models to enhance error handling. - Updated imports in BMS module to include new validation functions. - Improved documentation in example scripts for better clarity on usage. --- .../simple/send_bms_free_carousel_commerce.py | 121 ++++++++++++++++++ .../simple/send_bms_free_carousel_feed.py | 115 +++++++++++++++++ examples/simple/send_bms_free_commerce.py | 81 ++++++++++++ examples/simple/send_bms_free_image.py | 47 +++++++ .../send_bms_free_image_with_buttons.py | 76 +++++++++++ .../simple/send_bms_free_premium_video.py | 92 +++++++++++++ examples/simple/send_bms_free_text.py | 95 ++++++++++++++ .../simple/send_bms_free_text_with_buttons.py | 62 +++++++++ examples/simple/send_bms_free_wide.py | 54 ++++++++ .../simple/send_bms_free_wide_item_list.py | 84 ++++++++++++ solapi/model/kakao/bms/__init__.py | 8 +- solapi/model/kakao/bms/bms_option.py | 74 ++++++----- solapi/model/request/kakao/bms.py | 37 +----- uv.lock | 3 - 14 files changed, 883 insertions(+), 66 deletions(-) create mode 100644 examples/simple/send_bms_free_carousel_commerce.py create mode 100644 examples/simple/send_bms_free_carousel_feed.py create mode 100644 examples/simple/send_bms_free_commerce.py create mode 100644 examples/simple/send_bms_free_image.py create mode 100644 examples/simple/send_bms_free_image_with_buttons.py create mode 100644 examples/simple/send_bms_free_premium_video.py create mode 100644 examples/simple/send_bms_free_text.py create mode 100644 examples/simple/send_bms_free_text_with_buttons.py create mode 100644 examples/simple/send_bms_free_wide.py create mode 100644 examples/simple/send_bms_free_wide_item_list.py 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..68fef1b --- /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 os.path import abspath + +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=abspath("../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..7709126 --- /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 os.path import abspath + +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=abspath("../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..24c1d24 --- /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 os.path import abspath + +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=abspath("../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..68bc9fd --- /dev/null +++ b/examples/simple/send_bms_free_image.py @@ -0,0 +1,47 @@ +""" +카카오 BMS 자유형 IMAGE 타입 발송 예제 +이미지 업로드 후 imageId를 사용하여 발송합니다. +이미지 업로드 시 fileType은 반드시 'BMS'를 사용해야 합니다. +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from os.path import abspath + +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=abspath("../images/example.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..eb16855 --- /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 os.path import abspath + +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=abspath("../images/example.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..a2f0009 --- /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 os.path import abspath + +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=abspath("../images/example.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..335058e --- /dev/null +++ b/examples/simple/send_bms_free_wide.py @@ -0,0 +1,54 @@ +""" +카카오 BMS 자유형 WIDE 타입 발송 예제 +와이드 이미지를 사용하는 메시지입니다. +이미지 업로드 시 fileType은 'BMS_WIDE'를 사용해야 합니다. (2:1 비율 이미지 권장) +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from os.path import abspath + +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=abspath("../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..458ff42 --- /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 os.path import abspath + +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=abspath("../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=abspath("../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/solapi/model/kakao/bms/__init__.py b/solapi/model/kakao/bms/__init__.py index 915c673..72e6010 100644 --- a/solapi/model/kakao/bms/__init__.py +++ b/solapi/model/kakao/bms/__init__.py @@ -23,7 +23,11 @@ ) 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 +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 @@ -59,4 +63,6 @@ # Option "BmsChatBubbleType", "BmsOption", + # Validation + "validate_bms_required_fields", ] diff --git a/solapi/model/kakao/bms/bms_option.py b/solapi/model/kakao/bms/bms_option.py index ee6f07f..8e2ceff 100644 --- a/solapi/model/kakao/bms/bms_option.py +++ b/solapi/model/kakao/bms/bms_option.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, Union +from typing import Any, Callable, Literal, Optional, Union from pydantic import BaseModel, ConfigDict, model_validator from pydantic.alias_generators import to_camel @@ -38,6 +38,43 @@ 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 @@ -61,34 +98,9 @@ class BmsOption(BaseModel): @model_validator(mode="after") def validate_required_fields(self) -> "BmsOption": - chat_bubble_type = self.chat_bubble_type - required_fields = BMS_REQUIRED_FIELDS.get(chat_bubble_type, []) - missing_fields = [ - field for field in required_fields if getattr(self, field, None) 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": - sub_wide_item_list = self.sub_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}개" - ) - + 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 - - -def _to_camel(s: str) -> str: - components = s.split("_") - return components[0] + "".join(x.title() for x in components[1:]) diff --git a/solapi/model/request/kakao/bms.py b/solapi/model/request/kakao/bms.py index 1bf605b..f17e6b6 100644 --- a/solapi/model/request/kakao/bms.py +++ b/solapi/model/request/kakao/bms.py @@ -11,10 +11,8 @@ 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 ( - BMS_REQUIRED_FIELDS, - WIDE_ITEM_LIST_MIN_SUB_ITEMS, BmsChatBubbleType, - _to_camel, + validate_bms_required_fields, ) from solapi.model.kakao.bms.bms_video import BmsVideo from solapi.model.kakao.bms.bms_wide_item import BmsMainWideItem, BmsSubWideItem @@ -47,32 +45,9 @@ class Bms(BaseModel): @model_validator(mode="after") def validate_required_fields(self) -> "Bms": - chat_bubble_type = self.chat_bubble_type - if chat_bubble_type is None: - return self - - required_fields = BMS_REQUIRED_FIELDS.get(chat_bubble_type, []) - missing_fields = [ - field for field in required_fields if getattr(self, field, None) 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": - sub_wide_item_list = self.sub_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}개" - ) - + 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/uv.lock b/uv.lock index 735b56a..71b8abc 100644 --- a/uv.lock +++ b/uv.lock @@ -399,9 +399,6 @@ requires-dist = [ ] provides-extras = ["dev"] -[package.metadata.requires-dev] -dev = [] - [[package]] name = "sqlparse" version = "0.5.3" From 2a1e79f98c65931ee5ece73bfba7b2406fcff686 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 21:40:37 +0900 Subject: [PATCH 10/12] Add validation for coupon description in BmsCoupon model - Introduced a new field validator for the "description" field in the BmsCoupon class. - Ensures that the description is limited to a maximum of 12 characters, enhancing input validation and error handling. --- solapi/model/kakao/bms/bms_coupon.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/solapi/model/kakao/bms/bms_coupon.py b/solapi/model/kakao/bms/bms_coupon.py index 28c7dca..c412e5f 100644 --- a/solapi/model/kakao/bms/bms_coupon.py +++ b/solapi/model/kakao/bms/bms_coupon.py @@ -53,3 +53,12 @@ def validate_coupon_title(cls, v: Optional[str]) -> Optional[str]: '"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 From e90e83ffdf9cfb6c99d3fe9d4b8d3eeeb0d5cfaa Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 21:56:16 +0900 Subject: [PATCH 11/12] Refactor file path handling and update coupon descriptions in BMS examples - Replaced `os.path.abspath` with `pathlib.Path` for improved file path handling in multiple example scripts. - Shortened coupon descriptions in various BMS examples for better clarity and consistency. --- examples/simple/send_bms_free_carousel_commerce.py | 6 +++--- examples/simple/send_bms_free_carousel_feed.py | 6 +++--- examples/simple/send_bms_free_commerce.py | 6 +++--- examples/simple/send_bms_free_image.py | 4 ++-- examples/simple/send_bms_free_image_with_buttons.py | 6 +++--- examples/simple/send_bms_free_premium_video.py | 6 +++--- examples/simple/send_bms_free_wide.py | 4 ++-- examples/simple/send_bms_free_wide_item_list.py | 6 +++--- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/examples/simple/send_bms_free_carousel_commerce.py b/examples/simple/send_bms_free_carousel_commerce.py index 68fef1b..57c8d0e 100644 --- a/examples/simple/send_bms_free_carousel_commerce.py +++ b/examples/simple/send_bms_free_carousel_commerce.py @@ -10,7 +10,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -33,7 +33,7 @@ try: file_response = message_service.upload_file( - file_path=abspath("../images/example_wide.jpg"), + file_path=str(Path(__file__).parent / "../images/example_wide.jpg"), upload_type=FileTypeEnum.BMS_CAROUSEL_COMMERCE_LIST, ) image_id = file_response.file_id @@ -83,7 +83,7 @@ ], coupon=BmsCoupon( title="10000원 할인 쿠폰", - description="첫 구매 고객 전용 쿠폰입니다.", + description="첫 구매 고객 전용", link_mobile="https://example.com/coupon", ), ), diff --git a/examples/simple/send_bms_free_carousel_feed.py b/examples/simple/send_bms_free_carousel_feed.py index 7709126..db2a240 100644 --- a/examples/simple/send_bms_free_carousel_feed.py +++ b/examples/simple/send_bms_free_carousel_feed.py @@ -8,7 +8,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -29,7 +29,7 @@ try: file_response = message_service.upload_file( - file_path=abspath("../images/example_wide.jpg"), + file_path=str(Path(__file__).parent / "../images/example_wide.jpg"), upload_type=FileTypeEnum.BMS_CAROUSEL_FEED_LIST, ) image_id = file_response.file_id @@ -67,7 +67,7 @@ ], coupon=BmsCoupon( title="10% 할인 쿠폰", - description="첫 등록 고객 전용 할인 쿠폰입니다.", + description="첫 등록 고객 전용", link_mobile="https://example.com/coupon", ), ), diff --git a/examples/simple/send_bms_free_commerce.py b/examples/simple/send_bms_free_commerce.py index 24c1d24..9a9d14d 100644 --- a/examples/simple/send_bms_free_commerce.py +++ b/examples/simple/send_bms_free_commerce.py @@ -8,7 +8,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -27,7 +27,7 @@ try: file_response = message_service.upload_file( - file_path=abspath("../images/example_wide.jpg"), + file_path=str(Path(__file__).parent / "../images/example_wide.jpg"), upload_type=FileTypeEnum.BMS, ) print(f"파일 업로드 성공! File ID: {file_response.file_id}") @@ -65,7 +65,7 @@ ], coupon=BmsCoupon( title="포인트 UP 쿠폰", - description="구매 시 2배 적립 쿠폰입니다.", + description="구매 시 2배 적립", link_mobile="https://example.com/coupon", ), ), diff --git a/examples/simple/send_bms_free_image.py b/examples/simple/send_bms_free_image.py index 68bc9fd..f2c9e1c 100644 --- a/examples/simple/send_bms_free_image.py +++ b/examples/simple/send_bms_free_image.py @@ -5,7 +5,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -18,7 +18,7 @@ try: file_response = message_service.upload_file( - file_path=abspath("../images/example.jpg"), + file_path=str(Path(__file__).parent / "../images/example_square.jpg"), upload_type=FileTypeEnum.BMS, ) print(f"파일 업로드 성공! File ID: {file_response.file_id}") diff --git a/examples/simple/send_bms_free_image_with_buttons.py b/examples/simple/send_bms_free_image_with_buttons.py index eb16855..28ba55f 100644 --- a/examples/simple/send_bms_free_image_with_buttons.py +++ b/examples/simple/send_bms_free_image_with_buttons.py @@ -7,7 +7,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -26,7 +26,7 @@ try: file_response = message_service.upload_file( - file_path=abspath("../images/example.jpg"), + file_path=str(Path(__file__).parent / "../images/example_square.jpg"), upload_type=FileTypeEnum.BMS, ) print(f"파일 업로드 성공! File ID: {file_response.file_id}") @@ -60,7 +60,7 @@ ], coupon=BmsCoupon( title="10000원 할인 쿠폰", - description="연말 감사 할인 쿠폰입니다.", + description="연말 감사 할인", link_mobile="https://example.com/coupon", ), ), diff --git a/examples/simple/send_bms_free_premium_video.py b/examples/simple/send_bms_free_premium_video.py index a2f0009..4406160 100644 --- a/examples/simple/send_bms_free_premium_video.py +++ b/examples/simple/send_bms_free_premium_video.py @@ -7,7 +7,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -47,7 +47,7 @@ try: file_response = message_service.upload_file( - file_path=abspath("../images/example.jpg"), + file_path=str(Path(__file__).parent / "../images/example_square.jpg"), upload_type=FileTypeEnum.KAKAO, ) @@ -78,7 +78,7 @@ ], coupon=BmsCoupon( title="10% 할인 쿠폰", - description="영화 예매 시 사용 가능한 할인 쿠폰입니다.", + description="영화 예매 시 할인", link_mobile="https://example.com/coupon", ), ), diff --git a/examples/simple/send_bms_free_wide.py b/examples/simple/send_bms_free_wide.py index 335058e..093682e 100644 --- a/examples/simple/send_bms_free_wide.py +++ b/examples/simple/send_bms_free_wide.py @@ -5,7 +5,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -19,7 +19,7 @@ try: file_response = message_service.upload_file( - file_path=abspath("../images/example_wide.jpg"), + file_path=str(Path(__file__).parent / "../images/example_wide.jpg"), upload_type=FileTypeEnum.BMS_WIDE, ) print(f"파일 업로드 성공! File ID: {file_response.file_id}") diff --git a/examples/simple/send_bms_free_wide_item_list.py b/examples/simple/send_bms_free_wide_item_list.py index 458ff42..2d46d46 100644 --- a/examples/simple/send_bms_free_wide_item_list.py +++ b/examples/simple/send_bms_free_wide_item_list.py @@ -6,7 +6,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -20,14 +20,14 @@ try: main_file_response = message_service.upload_file( - file_path=abspath("../images/example_wide.jpg"), + 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=abspath("../images/example_square.jpg"), + 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 From c96af92d5bbc07155aaa9b04be00e69a4af023f5 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 22:00:29 +0900 Subject: [PATCH 12/12] Add omc directory to .gitignore - Added .omc/ to the .gitignore file to prevent tracking of omc-related files in the repository. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 118a46a..8d3bfd2 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ debug # ruff .ruff_cache/ + +# omc +.omc/ \ No newline at end of file