From 90b37db63f62d7109cd88c09c6af92bf0750e94d Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 5 Jan 2026 10:00:56 +0900 Subject: [PATCH 01/13] Add CLAUDE.md for project guidance and tidy-first agent documentation; update .gitignore to retain .vercel/ entry --- .claude/agents/tidy-first.md | 79 +++++++++++++++++++++++++++++ .gitignore | 5 +- CLAUDE.md | 98 ++++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 .claude/agents/tidy-first.md create mode 100644 CLAUDE.md diff --git a/.claude/agents/tidy-first.md b/.claude/agents/tidy-first.md new file mode 100644 index 0000000..d5c8099 --- /dev/null +++ b/.claude/agents/tidy-first.md @@ -0,0 +1,79 @@ +--- +name: tidy-first +description: Refactoring specialist applying Kent Beck's Tidy First principles. Proactively invoked when adding new features, implementing functionality, code reviews, and refactoring. Evaluates whether to tidy code BEFORE making behavioral changes. Also responds to Korean prompts (기능 추가, 기능 구현, 새 기능, 리팩토링, 코드 정리, 코드 리뷰). +tools: Read, Grep, Glob, Bash, Edit +model: inherit +--- + +You are a refactoring specialist focused on Kent Beck's "Tidy First?" principles. + +## Language Support + +Respond in the same language as the user's prompt: +- If the user writes in Korean, respond in Korean +- If the user writes in English, respond in English + +## When to Activate + +**Proactively engage when the user wants to:** +- Add a new feature or functionality +- Implement new behavior +- Modify existing features +- Review or refactor code + +**Your first task**: Before any behavioral change, analyze the target code area and recommend tidying opportunities that would make the feature implementation easier. + +## Core Principles + +### The Tidy First? Question +ALWAYS ask this question before adding features: +- Tidy first if: cost of tidying < reduction in future change costs +- Tidying should be a minutes-to-hours activity +- Always separate structural changes from behavioral changes +- Make the change easy, then make the easy change + +### Tidying Types +1. **Guard Clauses**: Convert nested conditionals to early returns +2. **Dead Code**: Remove unreachable or unused code +3. **Normalize Symmetries**: Make similar code patterns consistent +4. **Extract Functions**: Break complex logic into focused functions +5. **Readability**: Improve naming and structure +6. **Cohesion Order**: Place related code close together +7. **Explaining Variables**: Add descriptive variables for complex expressions + +## Work Process + +1. **Analyze**: Read code and identify Tidy First opportunities +2. **Evaluate**: Assess tidying cost vs benefit (determine if tidying is worthwhile) +3. **Verify Tests**: Ensure existing tests pass +4. **Apply**: Apply only one tidying type at a time +5. **Validate**: Re-run tests after changes (`pnpm test`) +6. **Suggest Commit**: Propose commit message in Conventional Commits format + +## Project Rules Compliance + +Follow this project's code style: + +- **Effect Library**: Maintain `Effect.gen`, `pipe`, `Data.TaggedError` style +- **Type Safety**: Never use `any` type - use `unknown` with type guards or Effect Schema +- **Linting**: Follow Biome lint rules (`pnpm lint`) +- **TDD**: Respect Red → Green → Refactor cycle + +## Important Principles + +- **Keep it small**: Each tidying should take minutes to hours +- **Safety first**: Only make structural changes that don't alter behavior +- **Tests required**: Verify all tests pass after every change +- **Separate commits**: Keep structural and behavioral changes in separate commits +- **Incremental improvement**: Apply only one tidying type at a time + +## Commit Message Format + +``` +refactor: [tidying type] - [change description] + +Examples: +refactor: guard clauses - convert nested if statements to early returns +refactor: dead code - remove unused helper function +refactor: extract function - separate complex validation logic into validateInput +``` diff --git a/.gitignore b/.gitignore index 49e2c82..4cd2ac1 100644 --- a/.gitignore +++ b/.gitignore @@ -56,7 +56,4 @@ coverage/ # Next.js example build outputs **/.next/ **/out/ -.vercel/ - -# Claude -CLAUDE.md \ No newline at end of file +.vercel/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5863972 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +SOLAPI SDK for Node.js - A server-side SDK for sending SMS, LMS, MMS, and Kakao messages (Alimtalk/Friendtalk) in Korea. Compatible with SOLAPI family services (CoolSMS, etc). + +## Commands + +```bash +# Development +pnpm dev # Watch mode with tsup +pnpm build # Lint + build (production) +pnpm lint # Biome check with auto-fix + +# Testing +pnpm test # Run all tests once +pnpm test:watch # Watch mode +pnpm vitest run # Run specific test file + +# Documentation +pnpm docs # Generate TypeDoc documentation +``` + +## Architecture + +### Entry Point & Service Facade +`SolapiMessageService` (src/index.ts) is the main SDK entry point. It aggregates all domain services and exposes their methods via delegation pattern using `bindServices()`. + +### Service Layer +All services extend `DefaultService` (src/services/defaultService.ts) which provides: +- Base URL configuration (https://api.solapi.com) +- Authentication handling via `AuthenticationParameter` +- HTTP request abstraction via `defaultFetcher` + +Domain services: +- `MessageService` / `GroupService` - Message sending and group management +- `KakaoChannelService` / `KakaoTemplateService` - Kakao Alimtalk integration +- `CashService` - Balance inquiries +- `IamService` - Block lists and 080 rejection management +- `StorageService` - File uploads (images, documents) + +### Effect Library Integration +This project uses the **Effect** library for functional programming and type-safe error handling: + +- All errors extend `Data.TaggedError` with environment-aware `toString()` methods +- Use `Effect.gen` for complex business logic +- Use `pipe` with `Effect.flatMap` for data transformation chains +- Schema validation via Effect Schema for runtime type safety +- Convert Effect to Promise using `runSafePromise` for API compatibility + +### Path Aliases +``` +@models → src/models +@lib → src/lib +@services → src/services +@errors → src/errors +@internal-types → src/types +@ → src +``` + +## Code Style Requirements + +### TypeScript +- **Never use `any` type** - use `unknown` with type guards, union types, or Effect Schema +- Prefer functional programming style with Effect library +- Run lint after writing code + +### TDD Approach +- Follow Red → Green → Refactor cycle +- Separate structural changes from behavioral changes in commits +- Only commit when all tests pass + +### Error Handling +- Define errors as Effect Data types (`Data.TaggedError`) +- Provide concise messages in production, detailed in development +- Use structured logging with environment-specific verbosity + +## Sub-Agents + +### tidy-first +Refactoring specialist applying Kent Beck's "Tidy First?" principles. + +**Auto-invocation conditions**: +- Adding new features or functionality +- Implementing new behavior +- Code review requests +- Refactoring tasks + +**Core principles**: +- Always separate structural changes from behavioral changes +- Make small, reversible changes only (minutes to hours) +- Maintain test coverage + +**Tidying types**: Guard Clauses, Dead Code removal, Pattern normalization, Function extraction, Readability improvements + +Works alongside the TDD Approach section's "Separate structural changes from behavioral changes" principle. From 6eccd4eb3aa43e2369c790b72aee2ffecb38c5a3 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 6 Jan 2026 14:26:38 +0900 Subject: [PATCH 02/13] =?UTF-8?q?chore(deps):=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EB=B2=84=EC=A0=84=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - effect: 3.19.6 → 3.19.14 - @biomejs/biome: 2.3.7 → 2.3.11 - typedoc: 0.28.14 → 0.28.15 - vite-tsconfig-paths: 5.1.4 → 6.0.3 - vitest: 4.0.14 → 4.0.16 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- biome.json | 2 +- package.json | 12 +-- pnpm-lock.yaml | 282 ++++++++++++++++++++++++++----------------------- 3 files changed, 157 insertions(+), 139 deletions(-) diff --git a/biome.json b/biome.json index cdfd3fd..e2c8cd7 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false }, "files": { "ignoreUnknown": false, diff --git a/package.json b/package.json index cf59210..2e80828 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solapi", - "version": "5.5.3", + "version": "5.5.4", "description": "SOLAPI SDK for Node.js(Server Side Only)", "keywords": [ "solapi", @@ -40,18 +40,18 @@ }, "dependencies": { "date-fns": "^4.1.0", - "effect": "^3.19.6" + "effect": "^3.19.14" }, "devDependencies": { - "@biomejs/biome": "2.3.7", + "@biomejs/biome": "2.3.11", "@effect/vitest": "^0.27.0", "@types/node": "^24.10.1", "dotenv": "^17.2.3", "tsup": "^8.5.1", - "typedoc": "^0.28.14", + "typedoc": "^0.28.15", "typescript": "^5.9.3", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "^4.0.14" + "vite-tsconfig-paths": "^6.0.3", + "vitest": "^4.0.16" }, "packageManager": "pnpm@10.15.1", "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e3f831..a8cfb4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,15 +12,15 @@ importers: specifier: ^4.1.0 version: 4.1.0 effect: - specifier: ^3.19.6 - version: 3.19.6 + specifier: ^3.19.14 + version: 3.19.14 devDependencies: '@biomejs/biome': - specifier: 2.3.7 - version: 2.3.7 + specifier: 2.3.11 + version: 2.3.11 '@effect/vitest': specifier: ^0.27.0 - version: 0.27.0(effect@3.19.6)(vitest@4.0.14(@types/node@24.10.1)(yaml@2.8.1)) + version: 0.27.0(effect@3.19.14)(vitest@4.0.16(@types/node@24.10.1)(yaml@2.8.1)) '@types/node': specifier: ^24.10.1 version: 24.10.1 @@ -31,69 +31,69 @@ importers: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1) typedoc: - specifier: ^0.28.14 - version: 0.28.14(typescript@5.9.3) + specifier: ^0.28.15 + version: 0.28.15(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 vite-tsconfig-paths: - specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.1.5(@types/node@24.10.1)(yaml@2.8.1)) + specifier: ^6.0.3 + version: 6.0.3(typescript@5.9.3)(vite@7.1.5(@types/node@24.10.1)(yaml@2.8.1)) vitest: - specifier: ^4.0.14 - version: 4.0.14(@types/node@24.10.1)(yaml@2.8.1) + specifier: ^4.0.16 + version: 4.0.16(@types/node@24.10.1)(yaml@2.8.1) packages: - '@biomejs/biome@2.3.7': - resolution: {integrity: sha512-CTbAS/jNAiUc6rcq94BrTB8z83O9+BsgWj2sBCQg9rD6Wkh2gjfR87usjx0Ncx0zGXP1NKgT7JNglay5Zfs9jw==} + '@biomejs/biome@2.3.11': + resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.3.7': - resolution: {integrity: sha512-LirkamEwzIUULhXcf2D5b+NatXKeqhOwilM+5eRkbrnr6daKz9rsBL0kNZ16Hcy4b8RFq22SG4tcLwM+yx/wFA==} + '@biomejs/cli-darwin-arm64@2.3.11': + resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.3.7': - resolution: {integrity: sha512-Q4TO633kvrMQkKIV7wmf8HXwF0dhdTD9S458LGE24TYgBjSRbuhvio4D5eOQzirEYg6eqxfs53ga/rbdd8nBKg==} + '@biomejs/cli-darwin-x64@2.3.11': + resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.3.7': - resolution: {integrity: sha512-/afy8lto4CB8scWfMdt+NoCZtatBUF62Tk3ilWH2w8ENd5spLhM77zKlFZEvsKJv9AFNHknMl03zO67CiklL2Q==} + '@biomejs/cli-linux-arm64-musl@2.3.11': + resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-arm64@2.3.7': - resolution: {integrity: sha512-inHOTdlstUBzgjDcx0ge71U4SVTbwAljmkfi3MC5WzsYCRhancqfeL+sa4Ke6v2ND53WIwCFD5hGsYExoI3EZQ==} + '@biomejs/cli-linux-arm64@2.3.11': + resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-x64-musl@2.3.7': - resolution: {integrity: sha512-CQUtgH1tIN6e5wiYSJqzSwJumHYolNtaj1dwZGCnZXm2PZU1jOJof9TsyiP3bXNDb+VOR7oo7ZvY01If0W3iFQ==} + '@biomejs/cli-linux-x64-musl@2.3.11': + resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-linux-x64@2.3.7': - resolution: {integrity: sha512-fJMc3ZEuo/NaMYo5rvoWjdSS5/uVSW+HPRQujucpZqm2ZCq71b8MKJ9U4th9yrv2L5+5NjPF0nqqILCl8HY/fg==} + '@biomejs/cli-linux-x64@2.3.11': + resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-win32-arm64@2.3.7': - resolution: {integrity: sha512-aJAE8eCNyRpcfx2JJAtsPtISnELJ0H4xVVSwnxm13bzI8RwbXMyVtxy2r5DV1xT3WiSP+7LxORcApWw0LM8HiA==} + '@biomejs/cli-win32-arm64@2.3.11': + resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.3.7': - resolution: {integrity: sha512-pulzUshqv9Ed//MiE8MOUeeEkbkSHVDVY5Cz5wVAnH1DUqliCQG3j6s1POaITTFqFfo7AVIx2sWdKpx/GS+Nqw==} + '@biomejs/cli-win32-x64@2.3.11': + resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -416,8 +416,8 @@ packages: cpu: [x64] os: [win32] - '@gerrit0/mini-shiki@3.12.2': - resolution: {integrity: sha512-HKZPmO8OSSAAo20H2B3xgJdxZaLTwtlMwxg0967scnrDlPwe6j5+ULGHyIqwgTbFCn9yv/ff8CmfWZLE9YKBzA==} + '@gerrit0/mini-shiki@3.20.0': + resolution: {integrity: sha512-Wa57i+bMpK6PGJZ1f2myxo3iO+K/kZikcyvH8NIqNNZhQUbDav7V9LQmWOXhf946mz5c1NZ19WMsGYiDKTryzQ==} '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -545,17 +545,17 @@ packages: cpu: [x64] os: [win32] - '@shikijs/engine-oniguruma@3.12.2': - resolution: {integrity: sha512-hozwnFHsLvujK4/CPVHNo3Bcg2EsnG8krI/ZQ2FlBlCRpPZW4XAEQmEwqegJsypsTAN9ehu2tEYe30lYKSZW/w==} + '@shikijs/engine-oniguruma@3.20.0': + resolution: {integrity: sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ==} - '@shikijs/langs@3.12.2': - resolution: {integrity: sha512-bVx5PfuZHDSHoBal+KzJZGheFuyH4qwwcwG/n+MsWno5cTlKmaNtTsGzJpHYQ8YPbB5BdEdKU1rga5/6JGY8ww==} + '@shikijs/langs@3.20.0': + resolution: {integrity: sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA==} - '@shikijs/themes@3.12.2': - resolution: {integrity: sha512-fTR3QAgnwYpfGczpIbzPjlRnxyONJOerguQv1iwpyQZ9QXX4qy/XFQqXlf17XTsorxnHoJGbH/LXBvwtqDsF5A==} + '@shikijs/themes@3.20.0': + resolution: {integrity: sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ==} - '@shikijs/types@3.12.2': - resolution: {integrity: sha512-K5UIBzxCyv0YoxN3LMrKB9zuhp1bV+LgewxuVwHdl4Gz5oePoUFrr9EfgJlGlDeXCU1b/yhdnXeuRvAnz8HN8Q==} + '@shikijs/types@3.20.0': + resolution: {integrity: sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw==} '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -563,8 +563,11 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - '@types/chai@5.2.2': - resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -581,11 +584,11 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@vitest/expect@4.0.14': - resolution: {integrity: sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==} + '@vitest/expect@4.0.16': + resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} - '@vitest/mocker@4.0.14': - resolution: {integrity: sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==} + '@vitest/mocker@4.0.16': + resolution: {integrity: sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0-0 @@ -595,20 +598,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.0.14': - resolution: {integrity: sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==} + '@vitest/pretty-format@4.0.16': + resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} - '@vitest/runner@4.0.14': - resolution: {integrity: sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==} + '@vitest/runner@4.0.16': + resolution: {integrity: sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==} - '@vitest/snapshot@4.0.14': - resolution: {integrity: sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==} + '@vitest/snapshot@4.0.16': + resolution: {integrity: sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==} - '@vitest/spy@4.0.14': - resolution: {integrity: sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==} + '@vitest/spy@4.0.16': + resolution: {integrity: sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==} - '@vitest/utils@4.0.14': - resolution: {integrity: sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==} + '@vitest/utils@4.0.16': + resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} @@ -637,6 +640,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -653,8 +660,8 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - chai@6.2.1: - resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} chokidar@4.0.3: @@ -702,8 +709,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - effect@3.19.6: - resolution: {integrity: sha512-Eh1E/CI+xCAcMSDC5DtyE29yWJINC0zwBbwHappQPorjKyS69rCA8qzpsHpfhKnPDYgxdg8zkknii8mZ+6YMQA==} + effect@3.19.14: + resolution: {integrity: sha512-3vwdq0zlvQOxXzXNKRIPKTqZNMyGCdaFUBfMPqpsyzZDre67kgC1EEHDV4EoQTovJ4w5fmJW756f86kkuz7WFA==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -976,6 +983,10 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -1020,8 +1031,8 @@ packages: typescript: optional: true - typedoc@0.28.14: - resolution: {integrity: sha512-ftJYPvpVfQvFzpkoSfHLkJybdA/geDJ8BGQt/ZnkkhnBYoYW6lBgPQXu6vqLxO4X75dA55hX8Af847H5KXlEFA==} + typedoc@0.28.15: + resolution: {integrity: sha512-mw2/2vTL7MlT+BVo43lOsufkkd2CJO4zeOSuWQQsiXoV2VuEn7f6IZp2jsUDPmBMABpgR0R5jlcJ2OGEFYmkyg==} engines: {node: '>= 18', pnpm: '>= 10'} hasBin: true peerDependencies: @@ -1041,8 +1052,8 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - vite-tsconfig-paths@5.1.4: - resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + vite-tsconfig-paths@6.0.3: + resolution: {integrity: sha512-7bL7FPX/DSviaZGYUKowWF1AiDVWjMjxNbE8lyaVGDezkedWqfGhlnQ4BZXre0ZN5P4kAgIJfAlgFDVyjrCIyg==} peerDependencies: vite: '*' peerDependenciesMeta: @@ -1089,18 +1100,18 @@ packages: yaml: optional: true - vitest@4.0.14: - resolution: {integrity: sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==} + vitest@4.0.16: + resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.14 - '@vitest/browser-preview': 4.0.14 - '@vitest/browser-webdriverio': 4.0.14 - '@vitest/ui': 4.0.14 + '@vitest/browser-playwright': 4.0.16 + '@vitest/browser-preview': 4.0.16 + '@vitest/browser-webdriverio': 4.0.16 + '@vitest/ui': 4.0.16 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -1148,45 +1159,45 @@ packages: snapshots: - '@biomejs/biome@2.3.7': + '@biomejs/biome@2.3.11': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.3.7 - '@biomejs/cli-darwin-x64': 2.3.7 - '@biomejs/cli-linux-arm64': 2.3.7 - '@biomejs/cli-linux-arm64-musl': 2.3.7 - '@biomejs/cli-linux-x64': 2.3.7 - '@biomejs/cli-linux-x64-musl': 2.3.7 - '@biomejs/cli-win32-arm64': 2.3.7 - '@biomejs/cli-win32-x64': 2.3.7 + '@biomejs/cli-darwin-arm64': 2.3.11 + '@biomejs/cli-darwin-x64': 2.3.11 + '@biomejs/cli-linux-arm64': 2.3.11 + '@biomejs/cli-linux-arm64-musl': 2.3.11 + '@biomejs/cli-linux-x64': 2.3.11 + '@biomejs/cli-linux-x64-musl': 2.3.11 + '@biomejs/cli-win32-arm64': 2.3.11 + '@biomejs/cli-win32-x64': 2.3.11 - '@biomejs/cli-darwin-arm64@2.3.7': + '@biomejs/cli-darwin-arm64@2.3.11': optional: true - '@biomejs/cli-darwin-x64@2.3.7': + '@biomejs/cli-darwin-x64@2.3.11': optional: true - '@biomejs/cli-linux-arm64-musl@2.3.7': + '@biomejs/cli-linux-arm64-musl@2.3.11': optional: true - '@biomejs/cli-linux-arm64@2.3.7': + '@biomejs/cli-linux-arm64@2.3.11': optional: true - '@biomejs/cli-linux-x64-musl@2.3.7': + '@biomejs/cli-linux-x64-musl@2.3.11': optional: true - '@biomejs/cli-linux-x64@2.3.7': + '@biomejs/cli-linux-x64@2.3.11': optional: true - '@biomejs/cli-win32-arm64@2.3.7': + '@biomejs/cli-win32-arm64@2.3.11': optional: true - '@biomejs/cli-win32-x64@2.3.7': + '@biomejs/cli-win32-x64@2.3.11': optional: true - '@effect/vitest@0.27.0(effect@3.19.6)(vitest@4.0.14(@types/node@24.10.1)(yaml@2.8.1))': + '@effect/vitest@0.27.0(effect@3.19.14)(vitest@4.0.16(@types/node@24.10.1)(yaml@2.8.1))': dependencies: - effect: 3.19.6 - vitest: 4.0.14(@types/node@24.10.1)(yaml@2.8.1) + effect: 3.19.14 + vitest: 4.0.16(@types/node@24.10.1)(yaml@2.8.1) '@esbuild/aix-ppc64@0.25.9': optional: true @@ -1344,12 +1355,12 @@ snapshots: '@esbuild/win32-x64@0.27.0': optional: true - '@gerrit0/mini-shiki@3.12.2': + '@gerrit0/mini-shiki@3.20.0': dependencies: - '@shikijs/engine-oniguruma': 3.12.2 - '@shikijs/langs': 3.12.2 - '@shikijs/themes': 3.12.2 - '@shikijs/types': 3.12.2 + '@shikijs/engine-oniguruma': 3.20.0 + '@shikijs/langs': 3.20.0 + '@shikijs/themes': 3.20.0 + '@shikijs/types': 3.20.0 '@shikijs/vscode-textmate': 10.0.2 '@isaacs/cliui@8.0.2': @@ -1441,20 +1452,20 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.50.1': optional: true - '@shikijs/engine-oniguruma@3.12.2': + '@shikijs/engine-oniguruma@3.20.0': dependencies: - '@shikijs/types': 3.12.2 + '@shikijs/types': 3.20.0 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@3.12.2': + '@shikijs/langs@3.20.0': dependencies: - '@shikijs/types': 3.12.2 + '@shikijs/types': 3.20.0 - '@shikijs/themes@3.12.2': + '@shikijs/themes@3.20.0': dependencies: - '@shikijs/types': 3.12.2 + '@shikijs/types': 3.20.0 - '@shikijs/types@3.12.2': + '@shikijs/types@3.20.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -1463,9 +1474,12 @@ snapshots: '@standard-schema/spec@1.0.0': {} - '@types/chai@5.2.2': + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 '@types/deep-eql@4.0.2': {} @@ -1481,43 +1495,43 @@ snapshots: '@types/unist@3.0.3': {} - '@vitest/expect@4.0.14': + '@vitest/expect@4.0.16': dependencies: - '@standard-schema/spec': 1.0.0 - '@types/chai': 5.2.2 - '@vitest/spy': 4.0.14 - '@vitest/utils': 4.0.14 - chai: 6.2.1 + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 + chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.14(vite@7.1.5(@types/node@24.10.1)(yaml@2.8.1))': + '@vitest/mocker@4.0.16(vite@7.1.5(@types/node@24.10.1)(yaml@2.8.1))': dependencies: - '@vitest/spy': 4.0.14 + '@vitest/spy': 4.0.16 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 7.1.5(@types/node@24.10.1)(yaml@2.8.1) - '@vitest/pretty-format@4.0.14': + '@vitest/pretty-format@4.0.16': dependencies: tinyrainbow: 3.0.3 - '@vitest/runner@4.0.14': + '@vitest/runner@4.0.16': dependencies: - '@vitest/utils': 4.0.14 + '@vitest/utils': 4.0.16 pathe: 2.0.3 - '@vitest/snapshot@4.0.14': + '@vitest/snapshot@4.0.16': dependencies: - '@vitest/pretty-format': 4.0.14 + '@vitest/pretty-format': 4.0.16 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.14': {} + '@vitest/spy@4.0.16': {} - '@vitest/utils@4.0.14': + '@vitest/utils@4.0.16': dependencies: - '@vitest/pretty-format': 4.0.14 + '@vitest/pretty-format': 4.0.16 tinyrainbow: 3.0.3 acorn@8.15.0: {} @@ -1536,6 +1550,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + balanced-match@1.0.2: {} brace-expansion@2.0.2: @@ -1549,7 +1565,7 @@ snapshots: cac@6.7.14: {} - chai@6.2.1: {} + chai@6.2.2: {} chokidar@4.0.3: dependencies: @@ -1583,7 +1599,7 @@ snapshots: eastasianwidth@0.2.0: {} - effect@3.19.6: + effect@3.19.14: dependencies: '@standard-schema/spec': 1.0.0 fast-check: 3.23.2 @@ -1896,6 +1912,8 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -1939,9 +1957,9 @@ snapshots: - tsx - yaml - typedoc@0.28.14(typescript@5.9.3): + typedoc@0.28.15(typescript@5.9.3): dependencies: - '@gerrit0/mini-shiki': 3.12.2 + '@gerrit0/mini-shiki': 3.20.0 lunr: 2.3.9 markdown-it: 14.1.0 minimatch: 9.0.5 @@ -1956,7 +1974,7 @@ snapshots: undici-types@7.16.0: {} - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.5(@types/node@24.10.1)(yaml@2.8.1)): + vite-tsconfig-paths@6.0.3(typescript@5.9.3)(vite@7.1.5(@types/node@24.10.1)(yaml@2.8.1)): dependencies: debug: 4.4.1 globrex: 0.1.2 @@ -1980,15 +1998,15 @@ snapshots: fsevents: 2.3.3 yaml: 2.8.1 - vitest@4.0.14(@types/node@24.10.1)(yaml@2.8.1): + vitest@4.0.16(@types/node@24.10.1)(yaml@2.8.1): dependencies: - '@vitest/expect': 4.0.14 - '@vitest/mocker': 4.0.14(vite@7.1.5(@types/node@24.10.1)(yaml@2.8.1)) - '@vitest/pretty-format': 4.0.14 - '@vitest/runner': 4.0.14 - '@vitest/snapshot': 4.0.14 - '@vitest/spy': 4.0.14 - '@vitest/utils': 4.0.14 + '@vitest/expect': 4.0.16 + '@vitest/mocker': 4.0.16(vite@7.1.5(@types/node@24.10.1)(yaml@2.8.1)) + '@vitest/pretty-format': 4.0.16 + '@vitest/runner': 4.0.16 + '@vitest/snapshot': 4.0.16 + '@vitest/spy': 4.0.16 + '@vitest/utils': 4.0.16 es-module-lexer: 1.7.0 expect-type: 1.2.2 magic-string: 0.30.21 @@ -1997,7 +2015,7 @@ snapshots: picomatch: 4.0.3 std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 vite: 7.1.5(@types/node@24.10.1)(yaml@2.8.1) From e2a2381ccb48e60ecbc87f1e934867f724fed513 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 7 Jan 2026 15:50:59 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat(kakao):=20BMS(=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EB=93=9C=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4)=20=ED=83=80=EC=9E=85=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카카오 BMS 메시지 발송을 위한 타입 및 스키마를 구현합니다. - 8가지 chatBubbleType 지원 (TEXT, IMAGE, WIDE, WIDE_ITEM_LIST, COMMERCE, CAROUSEL_FEED, CAROUSEL_COMMERCE, PREMIUM_VIDEO) - chatBubbleType별 필수 필드 검증 로직 추가 - BMS 복합 타입 스키마 추가 (버튼, 캐러셀, 커머스, 쿠폰, 비디오, 와이드아이템) - 단위 테스트 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/models/base/kakao/bms/bmsButton.ts | 96 ++++ src/models/base/kakao/bms/bmsCarousel.ts | 88 +++ src/models/base/kakao/bms/bmsCommerce.ts | 30 + src/models/base/kakao/bms/bmsCoupon.ts | 53 ++ src/models/base/kakao/bms/bmsVideo.ts | 21 + src/models/base/kakao/bms/bmsWideItem.ts | 33 ++ src/models/base/kakao/bms/index.ts | 49 ++ src/models/base/kakao/kakaoOption.ts | 104 +++- test/models/base/kakao/bms/bmsButton.test.ts | 182 ++++++ test/models/base/kakao/bms/bmsCoupon.test.ts | 83 +++ test/models/base/kakao/bms/bmsOption.test.ts | 562 +++++++++++++++++++ 11 files changed, 1299 insertions(+), 2 deletions(-) create mode 100644 src/models/base/kakao/bms/bmsButton.ts create mode 100644 src/models/base/kakao/bms/bmsCarousel.ts create mode 100644 src/models/base/kakao/bms/bmsCommerce.ts create mode 100644 src/models/base/kakao/bms/bmsCoupon.ts create mode 100644 src/models/base/kakao/bms/bmsVideo.ts create mode 100644 src/models/base/kakao/bms/bmsWideItem.ts create mode 100644 src/models/base/kakao/bms/index.ts create mode 100644 test/models/base/kakao/bms/bmsButton.test.ts create mode 100644 test/models/base/kakao/bms/bmsCoupon.test.ts create mode 100644 test/models/base/kakao/bms/bmsOption.test.ts diff --git a/src/models/base/kakao/bms/bmsButton.ts b/src/models/base/kakao/bms/bmsButton.ts new file mode 100644 index 0000000..ab47d89 --- /dev/null +++ b/src/models/base/kakao/bms/bmsButton.ts @@ -0,0 +1,96 @@ +import {Schema} from 'effect'; + +/** + * BMS 버튼 링크 타입 + * WL: 웹 링크 + * AL: 앱 링크 + * AC: 채널 추가 + */ +export type BmsButtonLinkType = 'WL' | 'AL' | 'AC'; + +/** + * BMS 웹 링크 버튼 타입 + */ +export type BmsWebButton = { + name: string; + linkType: 'WL'; + linkMobile: string; + linkPc?: string; +}; + +/** + * BMS 앱 링크 버튼 타입 + */ +export type BmsAppButton = { + name: string; + linkType: 'AL'; + linkAndroid: string; + linkIos: string; +}; + +/** + * BMS 채널 추가 버튼 타입 + */ +export type BmsChannelAddButton = { + name: string; + linkType: 'AC'; +}; + +/** + * BMS 버튼 통합 타입 + */ +export type BmsButton = BmsWebButton | BmsAppButton | BmsChannelAddButton; + +/** + * BMS 웹 링크 버튼 스키마 + * - linkMobile 필수 + * - linkPc 선택 + */ +export const bmsWebButtonSchema = Schema.Struct({ + name: Schema.String, + linkType: Schema.Literal('WL'), + linkMobile: Schema.String, + linkPc: Schema.optional(Schema.String), +}); + +/** + * BMS 앱 링크 버튼 스키마 + * - linkAndroid, linkIos 필수 + */ +export const bmsAppButtonSchema = Schema.Struct({ + name: Schema.String, + linkType: Schema.Literal('AL'), + linkAndroid: Schema.String, + linkIos: Schema.String, +}); + +/** + * BMS 채널 추가 버튼 스키마 + */ +export const bmsChannelAddButtonSchema = Schema.Struct({ + name: Schema.String, + linkType: Schema.Literal('AC'), +}); + +/** + * BMS 버튼 통합 스키마 (Union) + */ +export const bmsButtonSchema = Schema.Union( + bmsWebButtonSchema, + bmsAppButtonSchema, + bmsChannelAddButtonSchema, +); + +export type BmsButtonSchema = Schema.Schema.Type; + +/** + * BMS 버튼 스키마 (WL, AL만 허용) - 캐러셀 등 일부 타입에서 사용 + */ +export const bmsLinkButtonSchema = Schema.Union( + bmsWebButtonSchema, + bmsAppButtonSchema, +); + +export type BmsLinkButtonSchema = Schema.Schema.Type< + typeof bmsLinkButtonSchema +>; diff --git a/src/models/base/kakao/bms/bmsCarousel.ts b/src/models/base/kakao/bms/bmsCarousel.ts new file mode 100644 index 0000000..8f02eef --- /dev/null +++ b/src/models/base/kakao/bms/bmsCarousel.ts @@ -0,0 +1,88 @@ +import {Schema} from 'effect'; +import {bmsLinkButtonSchema} from './bmsButton'; +import {bmsCommerceSchema} from './bmsCommerce'; +import {bmsCouponSchema} from './bmsCoupon'; + +/** + * BMS 캐러셀 피드 아이템 타입 (CAROUSEL_FEED용) + */ +export type BmsCarouselFeedItem = { + header: string; + content: string; + imageId: string; + buttons: ReadonlyArray>; + coupon?: Schema.Schema.Type; +}; + +/** + * BMS 캐러셀 커머스 아이템 타입 (CAROUSEL_COMMERCE용) + */ +export type BmsCarouselCommerceItem = { + commerce: Schema.Schema.Type; + imageId: string; + buttons: ReadonlyArray>; + additionalContent?: string; + coupon?: Schema.Schema.Type; +}; + +/** + * BMS 캐러셀 피드 아이템 스키마 + * - header: 헤더 (필수, max 20 chars) + * - content: 내용 (필수, max 180 chars) + * - imageId: 이미지 ID (필수) + * - buttons: 버튼 목록 (필수, 1-2개, WL/AL만) + * - coupon: 쿠폰 (선택) + */ +export const bmsCarouselFeedItemSchema = Schema.Struct({ + header: Schema.String, + content: Schema.String, + imageId: Schema.String, + buttons: Schema.Array(bmsLinkButtonSchema), + coupon: Schema.optional(bmsCouponSchema), +}); + +export type BmsCarouselFeedItemSchema = Schema.Schema.Type< + typeof bmsCarouselFeedItemSchema +>; + +/** + * BMS 캐러셀 커머스 아이템 스키마 + * - commerce: 커머스 정보 (필수) + * - imageId: 이미지 ID (필수) + * - buttons: 버튼 목록 (필수, 1-2개, WL/AL만) + * - additionalContent: 추가 내용 (선택, max 34 chars) + * - coupon: 쿠폰 (선택) + */ +export const bmsCarouselCommerceItemSchema = Schema.Struct({ + commerce: bmsCommerceSchema, + imageId: Schema.String, + buttons: Schema.Array(bmsLinkButtonSchema), + additionalContent: Schema.optional(Schema.String), + coupon: Schema.optional(bmsCouponSchema), +}); + +export type BmsCarouselCommerceItemSchema = Schema.Schema.Type< + typeof bmsCarouselCommerceItemSchema +>; + +/** + * BMS 캐러셀 피드 스키마 (CAROUSEL_FEED용) + */ +export const bmsCarouselFeedSchema = Schema.Struct({ + list: Schema.Array(bmsCarouselFeedItemSchema), +}); + +export type BmsCarouselFeedSchema = Schema.Schema.Type< + typeof bmsCarouselFeedSchema +>; + +/** + * BMS 캐러셀 커머스 스키마 (CAROUSEL_COMMERCE용) + */ +export const bmsCarouselCommerceSchema = Schema.Struct({ + list: Schema.Array(bmsCarouselCommerceItemSchema), +}); + +export type BmsCarouselCommerceSchema = Schema.Schema.Type< + typeof bmsCarouselCommerceSchema +>; diff --git a/src/models/base/kakao/bms/bmsCommerce.ts b/src/models/base/kakao/bms/bmsCommerce.ts new file mode 100644 index 0000000..5a1375e --- /dev/null +++ b/src/models/base/kakao/bms/bmsCommerce.ts @@ -0,0 +1,30 @@ +import {Schema} from 'effect'; + +/** + * BMS 커머스 정보 타입 + */ +export type BmsCommerce = { + title: string; + regularPrice: number; + discountPrice?: number; + discountRate?: number; + discountFixed?: number; +}; + +/** + * BMS 커머스 정보 스키마 + * - title: 상품명 (필수) + * - regularPrice: 정가 (필수) + * - discountPrice: 할인가 (선택) + * - discountRate: 할인율 (선택) + * - discountFixed: 고정 할인금액 (선택) + */ +export const bmsCommerceSchema = Schema.Struct({ + title: Schema.String, + regularPrice: Schema.Number, + discountPrice: Schema.optional(Schema.Number), + discountRate: Schema.optional(Schema.Number), + discountFixed: Schema.optional(Schema.Number), +}); + +export type BmsCommerceSchema = Schema.Schema.Type; diff --git a/src/models/base/kakao/bms/bmsCoupon.ts b/src/models/base/kakao/bms/bmsCoupon.ts new file mode 100644 index 0000000..41a4cb4 --- /dev/null +++ b/src/models/base/kakao/bms/bmsCoupon.ts @@ -0,0 +1,53 @@ +import {Schema} from 'effect'; + +/** + * BMS 쿠폰 제목 프리셋 + * API에서 허용하는 5가지 프리셋 값만 사용 가능 + */ +export type BmsCouponTitle = + | '할인 쿠폰' + | '배송비 쿠폰' + | '기간 제한 쿠폰' + | '이벤트 쿠폰' + | '적립금 쿠폰'; + +/** + * BMS 쿠폰 타입 + */ +export type BmsCoupon = { + title: BmsCouponTitle; + description: string; + linkMobile?: string; + linkPc?: string; + linkAndroid?: string; + linkIos?: string; +}; + +/** + * BMS 쿠폰 제목 스키마 + * 5가지 프리셋 값만 허용 + */ +export const bmsCouponTitleSchema = Schema.Literal( + '할인 쿠폰', + '배송비 쿠폰', + '기간 제한 쿠폰', + '이벤트 쿠폰', + '적립금 쿠폰', +); + +/** + * BMS 쿠폰 스키마 + * - title: 5가지 프리셋 중 하나 (필수) + * - description: 설명 (필수, max 12-18 chars by type) + * - linkMobile, linkPc, linkAndroid, linkIos: 링크 (선택) + */ +export const bmsCouponSchema = Schema.Struct({ + title: bmsCouponTitleSchema, + description: Schema.String, + linkMobile: Schema.optional(Schema.String), + linkPc: Schema.optional(Schema.String), + linkAndroid: Schema.optional(Schema.String), + linkIos: Schema.optional(Schema.String), +}); + +export type BmsCouponSchema = Schema.Schema.Type; diff --git a/src/models/base/kakao/bms/bmsVideo.ts b/src/models/base/kakao/bms/bmsVideo.ts new file mode 100644 index 0000000..5785e31 --- /dev/null +++ b/src/models/base/kakao/bms/bmsVideo.ts @@ -0,0 +1,21 @@ +import {Schema} from 'effect'; + +/** + * BMS 비디오 정보 타입 (PREMIUM_VIDEO용) + */ +export type BmsVideo = { + videoId: string; + thumbImageId: string; +}; + +/** + * BMS 비디오 정보 스키마 + * - videoId: 비디오 ID (필수) + * - thumbImageId: 썸네일 이미지 ID (필수) + */ +export const bmsVideoSchema = Schema.Struct({ + videoId: Schema.String, + thumbImageId: Schema.String, +}); + +export type BmsVideoSchema = Schema.Schema.Type; diff --git a/src/models/base/kakao/bms/bmsWideItem.ts b/src/models/base/kakao/bms/bmsWideItem.ts new file mode 100644 index 0000000..a9601a8 --- /dev/null +++ b/src/models/base/kakao/bms/bmsWideItem.ts @@ -0,0 +1,33 @@ +import {Schema} from 'effect'; + +/** + * BMS 와이드 아이템 타입 (WIDE_ITEM_LIST용) + */ +export type BmsWideItem = { + title: string; + description?: string; + imageId?: string; + linkMobile?: string; + linkPc?: string; + linkAndroid?: string; + linkIos?: string; +}; + +/** + * BMS 와이드 아이템 스키마 + * - title: 제목 (필수) + * - description: 설명 (선택) + * - imageId: 이미지 ID (선택) + * - linkMobile, linkPc, linkAndroid, linkIos: 링크 (선택) + */ +export const bmsWideItemSchema = Schema.Struct({ + title: Schema.String, + description: Schema.optional(Schema.String), + imageId: Schema.optional(Schema.String), + linkMobile: Schema.optional(Schema.String), + linkPc: Schema.optional(Schema.String), + linkAndroid: Schema.optional(Schema.String), + linkIos: Schema.optional(Schema.String), +}); + +export type BmsWideItemSchema = Schema.Schema.Type; diff --git a/src/models/base/kakao/bms/index.ts b/src/models/base/kakao/bms/index.ts new file mode 100644 index 0000000..c20c7ac --- /dev/null +++ b/src/models/base/kakao/bms/index.ts @@ -0,0 +1,49 @@ +export { + type BmsAppButton, + type BmsButton, + type BmsButtonLinkType, + type BmsButtonSchema, + type BmsChannelAddButton, + type BmsLinkButtonSchema, + type BmsWebButton, + bmsAppButtonSchema, + bmsButtonSchema, + bmsChannelAddButtonSchema, + bmsLinkButtonSchema, + bmsWebButtonSchema, +} from './bmsButton'; +export { + type BmsCarouselCommerceItem, + type BmsCarouselCommerceItemSchema, + type BmsCarouselCommerceSchema, + type BmsCarouselFeedItem, + type BmsCarouselFeedItemSchema, + type BmsCarouselFeedSchema, + bmsCarouselCommerceItemSchema, + bmsCarouselCommerceSchema, + bmsCarouselFeedItemSchema, + bmsCarouselFeedSchema, +} from './bmsCarousel'; + +export { + type BmsCommerce, + type BmsCommerceSchema, + bmsCommerceSchema, +} from './bmsCommerce'; +export { + type BmsCoupon, + type BmsCouponSchema, + type BmsCouponTitle, + bmsCouponSchema, + bmsCouponTitleSchema, +} from './bmsCoupon'; +export { + type BmsVideo, + type BmsVideoSchema, + bmsVideoSchema, +} from './bmsVideo'; +export { + type BmsWideItem, + type BmsWideItemSchema, + bmsWideItemSchema, +} from './bmsWideItem'; diff --git a/src/models/base/kakao/kakaoOption.ts b/src/models/base/kakao/kakaoOption.ts index 27431ed..fd3ed4a 100644 --- a/src/models/base/kakao/kakaoOption.ts +++ b/src/models/base/kakao/kakaoOption.ts @@ -1,6 +1,15 @@ +import {runSafeSync} from '@lib/effectErrorHandler'; import {Data, Effect, Array as EffectArray, pipe, Schema} from 'effect'; -import {runSafeSync} from '../../../lib/effectErrorHandler'; import {kakaoOptionRequest} from '../../requests/kakao/kakaoOptionRequest'; +import { + bmsButtonSchema, + bmsCarouselCommerceSchema, + bmsCarouselFeedSchema, + bmsCommerceSchema, + bmsCouponSchema, + bmsVideoSchema, + bmsWideItemSchema, +} from './bms'; import {KakaoButton, kakaoButtonSchema} from './kakaoButton'; // Effect Data 타입을 활용한 에러 클래스 @@ -15,10 +24,101 @@ export class VariableValidationError extends Data.TaggedError( } } -const kakaoOptionBmsSchema = Schema.Struct({ +/** + * BMS chatBubbleType 스키마 + * 지원하는 8가지 말풍선 타입 + */ +export const bmsChatBubbleTypeSchema = Schema.Literal( + 'TEXT', + 'IMAGE', + 'WIDE', + 'WIDE_ITEM_LIST', + 'COMMERCE', + 'CAROUSEL_FEED', + 'CAROUSEL_COMMERCE', + 'PREMIUM_VIDEO', +); + +export type BmsChatBubbleType = Schema.Schema.Type< + typeof bmsChatBubbleTypeSchema +>; + +/** + * chatBubbleType별 필수 필드 정의 + */ +const BMS_REQUIRED_FIELDS: Record> = { + TEXT: [], + IMAGE: ['imageId'], + WIDE: ['imageId'], + WIDE_ITEM_LIST: ['mainWideItem', 'subWideItemList'], + COMMERCE: ['commerce', 'buttons'], + CAROUSEL_FEED: ['carousel'], + CAROUSEL_COMMERCE: ['carousel'], + PREMIUM_VIDEO: ['video'], +}; + +/** + * BMS 옵션 기본 스키마 (검증 전) + */ +const baseBmsSchema = Schema.Struct({ + // 필수 필드 targeting: Schema.Literal('I', 'M', 'N'), + chatBubbleType: bmsChatBubbleTypeSchema, + + // 선택 필드 + adult: Schema.optional(Schema.Boolean), + header: Schema.optional(Schema.String), + imageId: Schema.optional(Schema.String), + imageLink: Schema.optional(Schema.String), + additionalContent: Schema.optional(Schema.String), + + // 복합 타입 필드 + carousel: Schema.optional( + Schema.Union(bmsCarouselFeedSchema, bmsCarouselCommerceSchema), + ), + mainWideItem: Schema.optional(bmsWideItemSchema), + subWideItemList: Schema.optional(Schema.Array(bmsWideItemSchema)), + buttons: Schema.optional(Schema.Array(bmsButtonSchema)), + coupon: Schema.optional(bmsCouponSchema), + commerce: Schema.optional(bmsCommerceSchema), + video: Schema.optional(bmsVideoSchema), }); +type BaseBmsSchemaType = Schema.Schema.Type; + +/** + * chatBubbleType별 필수 필드 검증 및 에러 메시지 반환 + * - 검증 통과 시: true 반환 + * - 검증 실패 시: 에러 메시지 문자열 반환 + */ +const validateBmsRequiredFields = ( + bms: BaseBmsSchemaType, +): boolean | string => { + const chatBubbleType = bms.chatBubbleType; + const requiredFields = BMS_REQUIRED_FIELDS[chatBubbleType] ?? []; + const bmsRecord = bms as Record; + const missingFields = requiredFields.filter( + field => bmsRecord[field] === undefined || bmsRecord[field] === null, + ); + + if (missingFields.length > 0) { + return `BMS ${chatBubbleType} 타입에 필수 필드가 누락되었습니다: ${missingFields.join(', ')}`; + } + + return true; +}; + +/** + * BMS 옵션 스키마 (chatBubbleType별 필수 필드 검증 포함) + */ +const kakaoOptionBmsSchema = baseBmsSchema.pipe( + Schema.filter(validateBmsRequiredFields), +); + +export type KakaoOptionBmsSchema = Schema.Schema.Type< + typeof kakaoOptionBmsSchema +>; + // Constants for variable validation const VARIABLE_KEY_PATTERN = /^#\{.+}$/; const DOT_PATTERN = /\./; diff --git a/test/models/base/kakao/bms/bmsButton.test.ts b/test/models/base/kakao/bms/bmsButton.test.ts new file mode 100644 index 0000000..ce1e168 --- /dev/null +++ b/test/models/base/kakao/bms/bmsButton.test.ts @@ -0,0 +1,182 @@ +import { + bmsAppButtonSchema, + bmsButtonSchema, + bmsChannelAddButtonSchema, + bmsLinkButtonSchema, + bmsWebButtonSchema, +} from '@models/base/kakao/bms/bmsButton'; +import {Schema} from 'effect'; +import {describe, expect, it} from 'vitest'; + +describe('BMS Button Schema', () => { + describe('bmsWebButtonSchema (WL)', () => { + it('should accept valid web button with required fields', () => { + const validButton = { + name: '버튼명', + linkType: 'WL', + linkMobile: 'https://example.com', + }; + + const result = + Schema.decodeUnknownEither(bmsWebButtonSchema)(validButton); + expect(result._tag).toBe('Right'); + }); + + it('should accept web button with optional linkPc', () => { + const validButton = { + name: '버튼명', + linkType: 'WL', + linkMobile: 'https://m.example.com', + linkPc: 'https://www.example.com', + }; + + const result = + Schema.decodeUnknownEither(bmsWebButtonSchema)(validButton); + expect(result._tag).toBe('Right'); + }); + + it('should reject web button without linkMobile', () => { + const invalidButton = { + name: '버튼명', + linkType: 'WL', + }; + + const result = + Schema.decodeUnknownEither(bmsWebButtonSchema)(invalidButton); + expect(result._tag).toBe('Left'); + }); + }); + + describe('bmsAppButtonSchema (AL)', () => { + it('should accept valid app button with required fields', () => { + const validButton = { + name: '앱버튼', + linkType: 'AL', + linkAndroid: 'intent://example', + linkIos: 'example://app', + }; + + const result = + Schema.decodeUnknownEither(bmsAppButtonSchema)(validButton); + expect(result._tag).toBe('Right'); + }); + + it('should reject app button without linkAndroid', () => { + const invalidButton = { + name: '앱버튼', + linkType: 'AL', + linkIos: 'example://app', + }; + + const result = + Schema.decodeUnknownEither(bmsAppButtonSchema)(invalidButton); + expect(result._tag).toBe('Left'); + }); + + it('should reject app button without linkIos', () => { + const invalidButton = { + name: '앱버튼', + linkType: 'AL', + linkAndroid: 'intent://example', + }; + + const result = + Schema.decodeUnknownEither(bmsAppButtonSchema)(invalidButton); + expect(result._tag).toBe('Left'); + }); + }); + + describe('bmsChannelAddButtonSchema (AC)', () => { + it('should accept valid channel add button', () => { + const validButton = { + name: '채널추가', + linkType: 'AC', + }; + + const result = Schema.decodeUnknownEither(bmsChannelAddButtonSchema)( + validButton, + ); + expect(result._tag).toBe('Right'); + }); + }); + + describe('bmsButtonSchema (Union)', () => { + it('should accept WL button', () => { + const button = { + name: '웹링크', + linkType: 'WL', + linkMobile: 'https://example.com', + }; + + const result = Schema.decodeUnknownEither(bmsButtonSchema)(button); + expect(result._tag).toBe('Right'); + }); + + it('should accept AL button', () => { + const button = { + name: '앱링크', + linkType: 'AL', + linkAndroid: 'intent://example', + linkIos: 'example://app', + }; + + const result = Schema.decodeUnknownEither(bmsButtonSchema)(button); + expect(result._tag).toBe('Right'); + }); + + it('should accept AC button', () => { + const button = { + name: '채널추가', + linkType: 'AC', + }; + + const result = Schema.decodeUnknownEither(bmsButtonSchema)(button); + expect(result._tag).toBe('Right'); + }); + + it('should reject invalid linkType', () => { + const invalidButton = { + name: '버튼', + linkType: 'INVALID', + }; + + const result = Schema.decodeUnknownEither(bmsButtonSchema)(invalidButton); + expect(result._tag).toBe('Left'); + }); + }); + + describe('bmsLinkButtonSchema (WL/AL only)', () => { + it('should accept WL button', () => { + const button = { + name: '웹링크', + linkType: 'WL', + linkMobile: 'https://example.com', + }; + + const result = Schema.decodeUnknownEither(bmsLinkButtonSchema)(button); + expect(result._tag).toBe('Right'); + }); + + it('should accept AL button', () => { + const button = { + name: '앱링크', + linkType: 'AL', + linkAndroid: 'intent://example', + linkIos: 'example://app', + }; + + const result = Schema.decodeUnknownEither(bmsLinkButtonSchema)(button); + expect(result._tag).toBe('Right'); + }); + + it('should reject AC button', () => { + const button = { + name: '채널추가', + linkType: 'AC', + }; + + const result = Schema.decodeUnknownEither(bmsLinkButtonSchema)(button); + expect(result._tag).toBe('Left'); + }); + }); +}); diff --git a/test/models/base/kakao/bms/bmsCoupon.test.ts b/test/models/base/kakao/bms/bmsCoupon.test.ts new file mode 100644 index 0000000..3cba1ac --- /dev/null +++ b/test/models/base/kakao/bms/bmsCoupon.test.ts @@ -0,0 +1,83 @@ +import { + bmsCouponSchema, + bmsCouponTitleSchema, +} from '@models/base/kakao/bms/bmsCoupon'; +import {Schema} from 'effect'; +import {describe, expect, it} from 'vitest'; + +describe('BMS Coupon Schema', () => { + describe('bmsCouponTitleSchema', () => { + const validTitles = [ + '할인 쿠폰', + '배송비 쿠폰', + '기간 제한 쿠폰', + '이벤트 쿠폰', + '적립금 쿠폰', + ]; + + it.each(validTitles)('should accept valid title: %s', title => { + const result = Schema.decodeUnknownEither(bmsCouponTitleSchema)(title); + expect(result._tag).toBe('Right'); + }); + + it('should reject invalid title', () => { + const result = + Schema.decodeUnknownEither(bmsCouponTitleSchema)('잘못된 쿠폰'); + expect(result._tag).toBe('Left'); + }); + }); + + describe('bmsCouponSchema', () => { + it('should accept valid coupon with required fields', () => { + const validCoupon = { + title: '할인 쿠폰', + description: '10% 할인', + }; + + const result = Schema.decodeUnknownEither(bmsCouponSchema)(validCoupon); + expect(result._tag).toBe('Right'); + }); + + it('should accept coupon with all optional fields', () => { + const validCoupon = { + title: '이벤트 쿠폰', + description: '특별 할인', + linkMobile: 'https://m.example.com/coupon', + linkPc: 'https://www.example.com/coupon', + linkAndroid: 'intent://coupon', + linkIos: 'example://coupon', + }; + + const result = Schema.decodeUnknownEither(bmsCouponSchema)(validCoupon); + expect(result._tag).toBe('Right'); + }); + + it('should reject coupon without title', () => { + const invalidCoupon = { + description: '10% 할인', + }; + + const result = Schema.decodeUnknownEither(bmsCouponSchema)(invalidCoupon); + expect(result._tag).toBe('Left'); + }); + + it('should reject coupon without description', () => { + const invalidCoupon = { + title: '할인 쿠폰', + }; + + const result = Schema.decodeUnknownEither(bmsCouponSchema)(invalidCoupon); + expect(result._tag).toBe('Left'); + }); + + it('should reject coupon with invalid title', () => { + const invalidCoupon = { + title: '잘못된 쿠폰', + description: '10% 할인', + }; + + const result = Schema.decodeUnknownEither(bmsCouponSchema)(invalidCoupon); + expect(result._tag).toBe('Left'); + }); + }); +}); diff --git a/test/models/base/kakao/bms/bmsOption.test.ts b/test/models/base/kakao/bms/bmsOption.test.ts new file mode 100644 index 0000000..752a5df --- /dev/null +++ b/test/models/base/kakao/bms/bmsOption.test.ts @@ -0,0 +1,562 @@ +import {baseKakaoOptionSchema} from '@models/base/kakao/kakaoOption'; +import {Schema} from 'effect'; +import {describe, expect, it} from 'vitest'; + +describe('BMS Option Schema in KakaoOption', () => { + describe('chatBubbleType별 필수 필드 검증', () => { + it('should accept valid BMS_TEXT message (no required fields)', () => { + const validBmsText = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + validBmsText, + ); + expect(result._tag).toBe('Right'); + }); + + it('should accept BMS_TEXT with optional header', () => { + const validBmsText = { + pfId: 'test-pf-id', + bms: { + targeting: 'M', + chatBubbleType: 'TEXT', + header: '안내', + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + validBmsText, + ); + expect(result._tag).toBe('Right'); + }); + + it('should accept valid BMS_IMAGE message with imageId', () => { + const validBmsImage = { + pfId: 'test-pf-id', + bms: { + targeting: 'N', + chatBubbleType: 'IMAGE', + imageId: 'img-123', + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + validBmsImage, + ); + expect(result._tag).toBe('Right'); + }); + + it('should reject BMS_IMAGE without imageId', () => { + const invalidBmsImage = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(invalidBmsImage); + }).toThrow('BMS IMAGE 타입에 필수 필드가 누락되었습니다: imageId'); + }); + + it('should accept valid BMS_WIDE message with imageId', () => { + const validBmsWide = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE', + imageId: 'img-456', + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + validBmsWide, + ); + expect(result._tag).toBe('Right'); + }); + + it('should reject BMS_WIDE without imageId', () => { + const invalidBmsWide = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE', + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(invalidBmsWide); + }).toThrow('BMS WIDE 타입에 필수 필드가 누락되었습니다: imageId'); + }); + + it('should accept valid BMS_WIDE_ITEM_LIST message', () => { + const validBmsWideItemList = { + pfId: 'test-pf-id', + bms: { + targeting: 'M', + chatBubbleType: 'WIDE_ITEM_LIST', + mainWideItem: { + title: '메인 아이템', + }, + subWideItemList: [{title: '서브 아이템 1'}, {title: '서브 아이템 2'}], + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + validBmsWideItemList, + ); + expect(result._tag).toBe('Right'); + }); + + it('should reject BMS_WIDE_ITEM_LIST without mainWideItem', () => { + const invalidBmsWideItemList = { + pfId: 'test-pf-id', + bms: { + targeting: 'M', + chatBubbleType: 'WIDE_ITEM_LIST', + subWideItemList: [{title: '서브 아이템'}], + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(invalidBmsWideItemList); + }).toThrow('BMS WIDE_ITEM_LIST 타입에 필수 필드가 누락되었습니다'); + }); + + it('should accept valid BMS_COMMERCE message', () => { + const validBmsCommerce = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + commerce: { + title: '상품명', + regularPrice: 10000, + discountPrice: 8000, + }, + buttons: [ + { + name: '구매하기', + linkType: 'WL', + linkMobile: 'https://shop.example.com', + }, + ], + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + validBmsCommerce, + ); + expect(result._tag).toBe('Right'); + }); + + it('should reject BMS_COMMERCE without commerce', () => { + const invalidBmsCommerce = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + buttons: [ + { + name: '구매하기', + linkType: 'WL', + linkMobile: 'https://shop.example.com', + }, + ], + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(invalidBmsCommerce); + }).toThrow('BMS COMMERCE 타입에 필수 필드가 누락되었습니다'); + }); + + it('should reject BMS_COMMERCE without buttons', () => { + const invalidBmsCommerce = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + commerce: { + title: '상품명', + regularPrice: 10000, + }, + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(invalidBmsCommerce); + }).toThrow('BMS COMMERCE 타입에 필수 필드가 누락되었습니다'); + }); + + it('should accept valid BMS_CAROUSEL_FEED message', () => { + const validBmsCarouselFeed = { + pfId: 'test-pf-id', + bms: { + targeting: 'N', + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [ + { + header: '캐러셀 1', + content: '내용 1', + imageId: 'img-1', + buttons: [ + { + name: '자세히', + linkType: 'WL', + linkMobile: 'https://example.com/1', + }, + ], + }, + ], + }, + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + validBmsCarouselFeed, + ); + expect(result._tag).toBe('Right'); + }); + + it('should reject BMS_CAROUSEL_FEED without carousel', () => { + const invalidBmsCarouselFeed = { + pfId: 'test-pf-id', + bms: { + targeting: 'N', + chatBubbleType: 'CAROUSEL_FEED', + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(invalidBmsCarouselFeed); + }).toThrow( + 'BMS CAROUSEL_FEED 타입에 필수 필드가 누락되었습니다: carousel', + ); + }); + + it('should accept valid BMS_CAROUSEL_COMMERCE message', () => { + const validBmsCarouselCommerce = { + pfId: 'test-pf-id', + bms: { + targeting: 'M', + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + list: [ + { + commerce: { + title: '상품 1', + regularPrice: 15000, + }, + imageId: 'img-1', + buttons: [ + { + name: '구매', + linkType: 'WL', + linkMobile: 'https://shop.example.com/1', + }, + ], + }, + ], + }, + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + validBmsCarouselCommerce, + ); + expect(result._tag).toBe('Right'); + }); + + it('should reject BMS_CAROUSEL_COMMERCE without carousel', () => { + const invalidBmsCarouselCommerce = { + pfId: 'test-pf-id', + bms: { + targeting: 'M', + chatBubbleType: 'CAROUSEL_COMMERCE', + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)( + invalidBmsCarouselCommerce, + ); + }).toThrow( + 'BMS CAROUSEL_COMMERCE 타입에 필수 필드가 누락되었습니다: carousel', + ); + }); + + it('should accept valid BMS_PREMIUM_VIDEO message', () => { + const validBmsPremiumVideo = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'PREMIUM_VIDEO', + video: { + videoId: 'video-123', + thumbImageId: 'thumb-123', + }, + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + validBmsPremiumVideo, + ); + expect(result._tag).toBe('Right'); + }); + + it('should reject BMS_PREMIUM_VIDEO without video', () => { + const invalidBmsPremiumVideo = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'PREMIUM_VIDEO', + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(invalidBmsPremiumVideo); + }).toThrow('BMS PREMIUM_VIDEO 타입에 필수 필드가 누락되었습니다: video'); + }); + }); + + describe('targeting 필드 검증', () => { + it.each([ + 'I', + 'M', + 'N', + ] as const)('should accept valid targeting: %s', targeting => { + const validBms = { + pfId: 'test-pf-id', + bms: { + targeting, + chatBubbleType: 'TEXT', + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + validBms, + ); + expect(result._tag).toBe('Right'); + }); + + it('should reject invalid targeting', () => { + const invalidBms = { + pfId: 'test-pf-id', + bms: { + targeting: 'INVALID', + chatBubbleType: 'TEXT', + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + invalidBms, + ); + expect(result._tag).toBe('Left'); + }); + }); + + describe('chatBubbleType 필드 검증', () => { + const validChatBubbleTypes = [ + 'TEXT', + 'IMAGE', + 'WIDE', + 'WIDE_ITEM_LIST', + 'COMMERCE', + 'CAROUSEL_FEED', + 'CAROUSEL_COMMERCE', + 'PREMIUM_VIDEO', + ] as const; + + it.each( + validChatBubbleTypes, + )('should accept valid chatBubbleType: %s (with required fields)', chatBubbleType => { + let bms: Record = { + targeting: 'I', + chatBubbleType, + }; + + // chatBubbleType별 필수 필드 추가 + switch (chatBubbleType) { + case 'IMAGE': + case 'WIDE': + bms = {...bms, imageId: 'img-123'}; + break; + case 'WIDE_ITEM_LIST': + bms = { + ...bms, + mainWideItem: {title: '메인'}, + subWideItemList: [{title: '서브'}], + }; + break; + case 'COMMERCE': + bms = { + ...bms, + commerce: {title: '상품', regularPrice: 10000}, + buttons: [ + {name: '구매', linkType: 'WL', linkMobile: 'https://example.com'}, + ], + }; + break; + case 'CAROUSEL_FEED': + bms = { + ...bms, + carousel: { + list: [ + { + header: '헤더', + content: '내용', + imageId: 'img-1', + buttons: [ + { + name: '버튼', + linkType: 'WL', + linkMobile: 'https://example.com', + }, + ], + }, + ], + }, + }; + break; + case 'CAROUSEL_COMMERCE': + bms = { + ...bms, + carousel: { + list: [ + { + commerce: {title: '상품', regularPrice: 10000}, + imageId: 'img-1', + buttons: [ + { + name: '구매', + linkType: 'WL', + linkMobile: 'https://example.com', + }, + ], + }, + ], + }, + }; + break; + case 'PREMIUM_VIDEO': + bms = { + ...bms, + video: {videoId: 'video-123', thumbImageId: 'thumb-123'}, + }; + break; + } + + const validBms = { + pfId: 'test-pf-id', + bms, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + validBms, + ); + expect(result._tag).toBe('Right'); + }); + + it('should reject invalid chatBubbleType', () => { + const invalidBms = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'INVALID_TYPE', + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + invalidBms, + ); + expect(result._tag).toBe('Left'); + }); + }); + + describe('optional fields', () => { + it('should accept BMS with adult flag', () => { + const bmsWithAdult = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + adult: true, + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + bmsWithAdult, + ); + expect(result._tag).toBe('Right'); + }); + + it('should accept BMS with coupon', () => { + const bmsWithCoupon = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + coupon: { + title: '할인 쿠폰', + description: '10% 할인', + }, + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + bmsWithCoupon, + ); + expect(result._tag).toBe('Right'); + }); + + it('should accept BMS with buttons', () => { + const bmsWithButtons = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + buttons: [ + { + name: '버튼1', + linkType: 'WL', + linkMobile: 'https://example.com', + }, + { + name: '채널추가', + linkType: 'AC', + }, + ], + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + bmsWithButtons, + ); + expect(result._tag).toBe('Right'); + }); + + it('should accept BMS with additionalContent', () => { + const bmsWithAdditionalContent = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + additionalContent: '추가 내용', + }, + }; + + const result = Schema.decodeUnknownEither(baseKakaoOptionSchema)( + bmsWithAdditionalContent, + ); + expect(result._tag).toBe('Right'); + }); + }); +}); From abebea3400c92483b0b1ad0bf488fb49da4ebc0d Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 13 Jan 2026 14:27:50 +0900 Subject: [PATCH 04/13] feat(errors): Introduce ClientError and ServerError classes - Added ClientError class for handling 4xx client errors with enhanced toString method for better error reporting in production and development environments. - Introduced ServerError class for managing 5xx server errors, including detailed response body handling for non-production environments. - Deprecated ApiError in favor of ClientError for improved clarity and consistency. - Updated defaultFetcher and effectErrorHandler to utilize new error classes, ensuring better error management across the application. This update enhances error handling capabilities and improves the overall robustness of the application. --- src/errors/defaultError.ts | 32 ++- src/lib/defaultFetcher.ts | 47 +++-- src/lib/effectErrorHandler.ts | 64 +++++- src/models/base/kakao/bms/bmsButton.ts | 181 +++++++++++++---- src/models/base/kakao/bms/bmsCarousel.ts | 75 ++++++- src/models/base/kakao/bms/bmsCommerce.ts | 57 +++++- src/models/base/kakao/bms/bmsCoupon.ts | 73 +++++-- src/models/base/kakao/bms/bmsVideo.ts | 28 ++- src/models/base/kakao/bms/bmsWideItem.ts | 71 +++++-- src/models/base/kakao/bms/index.ts | 23 +++ src/models/base/kakao/kakaoOption.ts | 27 ++- src/models/base/messages/message.ts | 4 +- src/services/messages/messageService.ts | 17 +- test/models/base/kakao/bms/bmsButton.test.ts | 35 +++- .../models/base/kakao/bms/bmsCommerce.test.ts | 189 ++++++++++++++++++ test/models/base/kakao/bms/bmsCoupon.test.ts | 34 +++- test/models/base/kakao/bms/bmsOption.test.ts | 53 ++++- 17 files changed, 852 insertions(+), 158 deletions(-) create mode 100644 test/models/base/kakao/bms/bmsCommerce.test.ts diff --git a/src/errors/defaultError.ts b/src/errors/defaultError.ts index 2f87e9e..c931330 100644 --- a/src/errors/defaultError.ts +++ b/src/errors/defaultError.ts @@ -82,13 +82,41 @@ export class NetworkError extends Data.TaggedError('NetworkError')<{ } } -export class ApiError extends Data.TaggedError('ApiError')<{ +// 4xx 클라이언트 에러용 +export class ClientError extends Data.TaggedError('ClientError')<{ readonly errorCode: string; readonly errorMessage: string; readonly httpStatus: number; readonly url?: string; }> { toString(): string { - return `${this.errorCode}: ${this.errorMessage}`; + if (process.env.NODE_ENV === 'production') { + return `${this.errorCode}: ${this.errorMessage}`; + } + return `ClientError(${this.httpStatus}): ${this.errorCode} - ${this.errorMessage}\nURL: ${this.url}`; + } +} + +/** @deprecated Use ClientError instead */ +export const ApiError = ClientError; +/** @deprecated Use ClientError instead */ +export type ApiError = ClientError; + +// 5xx 서버 에러용 +export class ServerError extends Data.TaggedError('ServerError')<{ + readonly errorCode: string; + readonly errorMessage: string; + readonly httpStatus: number; + readonly url?: string; + readonly responseBody?: string; +}> { + toString(): string { + const isProduction = process.env.NODE_ENV === 'production'; + if (isProduction) { + return `ServerError(${this.httpStatus}): ${this.errorCode} - ${this.errorMessage}`; + } + return `ServerError(${this.httpStatus}): ${this.errorCode} - ${this.errorMessage} +URL: ${this.url} +Response: ${this.responseBody?.substring(0, 500) ?? '(empty)'}`; } } diff --git a/src/lib/defaultFetcher.ts b/src/lib/defaultFetcher.ts index 52d4fb3..e6079a4 100644 --- a/src/lib/defaultFetcher.ts +++ b/src/lib/defaultFetcher.ts @@ -1,9 +1,10 @@ import {Data, Effect, Match, pipe, Schedule} from 'effect'; import { - ApiError, + ClientError, DefaultError, ErrorResponse, NetworkError, + ServerError, } from '../errors/defaultError'; import getAuthInfo, {AuthenticationParameter} from './authenticator'; import {runSafePromise} from './effectErrorHandler'; @@ -51,7 +52,7 @@ const handleClientErrorResponse = (res: Response) => }), Effect.flatMap(error => Effect.fail( - new ApiError({ + new ClientError({ errorCode: error.errorCode, errorMessage: error.errorMessage, httpStatus: res.status, @@ -75,18 +76,38 @@ const handleServerErrorResponse = (res: Response) => }, }), }), - Effect.flatMap(text => - Effect.fail( - new DefaultError({ - errorCode: 'UnknownError', - errorMessage: text, - context: { - responseStatus: res.status, - responseUrl: res.url, - }, + Effect.flatMap(text => { + const isProduction = process.env.NODE_ENV === 'production'; + + // JSON 파싱 시도 + try { + const json = JSON.parse(text) as Partial; + if (json.errorCode && json.errorMessage) { + return Effect.fail( + new ServerError({ + errorCode: json.errorCode, + errorMessage: json.errorMessage, + httpStatus: res.status, + url: res.url, + responseBody: isProduction ? undefined : text, + }), + ); + } + } catch { + // JSON 파싱 실패 시 무시하고 fallback + } + + // JSON이 아니거나 필드가 없는 경우 + return Effect.fail( + new ServerError({ + errorCode: `HTTP_${res.status}`, + errorMessage: text.substring(0, 200) || 'Server error occurred', + httpStatus: res.status, + url: res.url, + responseBody: isProduction ? undefined : text, }), - ), - ), + ); + }), ); /** diff --git a/src/lib/effectErrorHandler.ts b/src/lib/effectErrorHandler.ts index b1cd5f5..055c301 100644 --- a/src/lib/effectErrorHandler.ts +++ b/src/lib/effectErrorHandler.ts @@ -23,7 +23,10 @@ export const formatError = (error: unknown): string => { if (error instanceof EffectError.NetworkError) { return error.toString(); } - if (error instanceof EffectError.ApiError) { + if (error instanceof EffectError.ClientError) { + return error.toString(); + } + if (error instanceof EffectError.ServerError) { return error.toString(); } if (error instanceof VariableValidationError) { @@ -65,7 +68,11 @@ export const runSafeSync = (effect: Effect.Effect): A => { if (firstDefect instanceof Error) { throw firstDefect; } - throw new Error(`Uncaught defect: ${String(firstDefect)}`); + const isProduction = process.env.NODE_ENV === 'production'; + const message = isProduction + ? `Unexpected error: ${String(firstDefect)}` + : `Unexpected error: ${String(firstDefect)}\nCause: ${Cause.pretty(cause)}`; + throw new Error(message); } throw new Error(`Unhandled Exit: ${Cause.pretty(cause)}`); }, @@ -94,10 +101,12 @@ export const runSafePromise = ( // 원본 Error 객체를 그대로 반환 return Promise.reject(firstDefect); } - // Error 객체가 아니면 새로 생성 - return Promise.reject( - new Error(`Uncaught defect: ${String(firstDefect)}`), - ); + // Error 객체가 아니면 환경에 따라 상세 정보 포함 + const isProduction = process.env.NODE_ENV === 'production'; + const message = isProduction + ? `Unexpected error: ${String(firstDefect)}` + : `Unexpected error: ${String(firstDefect)}\nCause: ${Cause.pretty(cause)}`; + return Promise.reject(new Error(message)); } // 3. 그 외 (예: 중단)의 경우, Cause를 문자열로 변환하여 반환 @@ -147,10 +156,10 @@ export const toCompatibleError = (effectError: unknown): Error => { return error; } - // ApiError 보존 - if (effectError instanceof EffectError.ApiError) { + // ClientError 보존 (하위 호환성을 위해 error.name은 'ApiError' 유지) + if (effectError instanceof EffectError.ClientError) { const error = new Error(effectError.toString()); - error.name = 'ApiError'; + error.name = 'ApiError'; // 하위 호환성 Object.defineProperties(error, { errorCode: { value: effectError.errorCode, @@ -175,6 +184,43 @@ export const toCompatibleError = (effectError: unknown): Error => { return error; } + // ServerError 보존 + if (effectError instanceof EffectError.ServerError) { + const error = new Error(effectError.toString()); + error.name = 'ServerError'; + const props: PropertyDescriptorMap = { + errorCode: { + value: effectError.errorCode, + writable: false, + enumerable: true, + }, + errorMessage: { + value: effectError.errorMessage, + writable: false, + enumerable: true, + }, + httpStatus: { + value: effectError.httpStatus, + writable: false, + enumerable: true, + }, + url: {value: effectError.url, writable: false, enumerable: true}, + }; + // 개발환경에서만 responseBody 포함 + if (!isProduction && effectError.responseBody) { + props.responseBody = { + value: effectError.responseBody, + writable: false, + enumerable: true, + }; + } + Object.defineProperties(error, props); + if (isProduction) { + delete (error as Error).stack; + } + return error; + } + // DefaultError 보존 if (effectError instanceof EffectError.DefaultError) { const error = new Error(effectError.toString()); diff --git a/src/models/base/kakao/bms/bmsButton.ts b/src/models/base/kakao/bms/bmsButton.ts index ab47d89..19bf18d 100644 --- a/src/models/base/kakao/bms/bmsButton.ts +++ b/src/models/base/kakao/bms/bmsButton.ts @@ -2,89 +2,188 @@ import {Schema} from 'effect'; /** * BMS 버튼 링크 타입 + * AC: 채널 추가 * WL: 웹 링크 * AL: 앱 링크 - * AC: 채널 추가 + * BK: 봇 키워드 + * MD: 메시지 전달 + * BC: 상담 요청 + * BT: 봇 전환 + * BF: 비즈니스폼 */ -export type BmsButtonLinkType = 'WL' | 'AL' | 'AC'; +export const bmsButtonLinkTypeSchema = Schema.Literal( + 'AC', + 'WL', + 'AL', + 'BK', + 'MD', + 'BC', + 'BT', + 'BF', +); + +export type BmsButtonLinkType = Schema.Schema.Type< + typeof bmsButtonLinkTypeSchema +>; /** - * BMS 웹 링크 버튼 타입 + * BMS 웹 링크 버튼 스키마 (WL) + * - name: 버튼명 (필수) + * - linkMobile: 모바일 링크 (필수) + * - linkPc: PC 링크 (선택) + * - targetOut: 외부 브라우저 열기 (선택) */ -export type BmsWebButton = { - name: string; - linkType: 'WL'; - linkMobile: string; - linkPc?: string; -}; +export const bmsWebButtonSchema = Schema.Struct({ + name: Schema.String, + linkType: Schema.Literal('WL'), + linkMobile: Schema.String, + linkPc: Schema.optional(Schema.String), + targetOut: Schema.optional(Schema.Boolean), +}); + +export type BmsWebButton = Schema.Schema.Type; /** - * BMS 앱 링크 버튼 타입 + * BMS 앱 링크 버튼 스키마 (AL) + * - name: 버튼명 (필수) + * - linkMobile, linkAndroid, linkIos 중 하나 이상 필수 + * - targetOut: 외부 브라우저 열기 (선택) */ -export type BmsAppButton = { - name: string; - linkType: 'AL'; - linkAndroid: string; - linkIos: string; -}; +export const bmsAppButtonSchema = Schema.Struct({ + name: Schema.String, + linkType: Schema.Literal('AL'), + linkMobile: Schema.optional(Schema.String), + linkAndroid: Schema.optional(Schema.String), + linkIos: Schema.optional(Schema.String), + targetOut: Schema.optional(Schema.Boolean), +}).pipe( + Schema.filter(button => { + const hasLink = button.linkMobile || button.linkAndroid || button.linkIos; + return hasLink + ? true + : 'AL 타입 버튼은 linkMobile, linkAndroid, linkIos 중 하나 이상 필수입니다.'; + }), +); + +export type BmsAppButton = Schema.Schema.Type; /** - * BMS 채널 추가 버튼 타입 + * BMS 채널 추가 버튼 스키마 (AC) + * - name: 서버에서 삭제되므로 선택 */ -export type BmsChannelAddButton = { - name: string; - linkType: 'AC'; -}; +export const bmsChannelAddButtonSchema = Schema.Struct({ + name: Schema.optional(Schema.String), + linkType: Schema.Literal('AC'), +}); + +export type BmsChannelAddButton = Schema.Schema.Type< + typeof bmsChannelAddButtonSchema +>; /** - * BMS 버튼 통합 타입 + * BMS 봇 키워드 버튼 스키마 (BK) + * - name: 버튼명 (필수) + * - chatExtra: 봇에 전달할 추가 정보 (선택) + */ +export const bmsBotKeywordButtonSchema = Schema.Struct({ + name: Schema.String, + linkType: Schema.Literal('BK'), + chatExtra: Schema.optional(Schema.String), +}); + +export type BmsBotKeywordButton = Schema.Schema.Type< + typeof bmsBotKeywordButtonSchema +>; + +/** + * BMS 메시지 전달 버튼 스키마 (MD) + * - name: 버튼명 (필수) + * - chatExtra: 봇에 전달할 추가 정보 (선택) */ -export type BmsButton = BmsWebButton | BmsAppButton | BmsChannelAddButton; +export const bmsMessageDeliveryButtonSchema = Schema.Struct({ + name: Schema.String, + linkType: Schema.Literal('MD'), + chatExtra: Schema.optional(Schema.String), +}); + +export type BmsMessageDeliveryButton = Schema.Schema.Type< + typeof bmsMessageDeliveryButtonSchema +>; /** - * BMS 웹 링크 버튼 스키마 - * - linkMobile 필수 - * - linkPc 선택 + * BMS 상담 요청 버튼 스키마 (BC) + * - name: 버튼명 (필수) + * - chatExtra: 상담사에게 전달할 추가 정보 (선택) */ -export const bmsWebButtonSchema = Schema.Struct({ +export const bmsConsultButtonSchema = Schema.Struct({ name: Schema.String, - linkType: Schema.Literal('WL'), - linkMobile: Schema.String, - linkPc: Schema.optional(Schema.String), + linkType: Schema.Literal('BC'), + chatExtra: Schema.optional(Schema.String), }); +export type BmsConsultButton = Schema.Schema.Type< + typeof bmsConsultButtonSchema +>; + /** - * BMS 앱 링크 버튼 스키마 - * - linkAndroid, linkIos 필수 + * BMS 봇 전환 버튼 스키마 (BT) + * - name: 버튼명 (필수) + * - chatExtra: 봇에 전달할 추가 정보 (선택) */ -export const bmsAppButtonSchema = Schema.Struct({ +export const bmsBotTransferButtonSchema = Schema.Struct({ name: Schema.String, - linkType: Schema.Literal('AL'), - linkAndroid: Schema.String, - linkIos: Schema.String, + linkType: Schema.Literal('BT'), + chatExtra: Schema.optional(Schema.String), }); +export type BmsBotTransferButton = Schema.Schema.Type< + typeof bmsBotTransferButtonSchema +>; + /** - * BMS 채널 추가 버튼 스키마 + * BMS 비즈니스폼 버튼 스키마 (BF) + * - name: 버튼명 (필수) */ -export const bmsChannelAddButtonSchema = Schema.Struct({ +export const bmsBusinessFormButtonSchema = Schema.Struct({ name: Schema.String, - linkType: Schema.Literal('AC'), + linkType: Schema.Literal('BF'), }); +export type BmsBusinessFormButton = Schema.Schema.Type< + typeof bmsBusinessFormButtonSchema +>; + +/** + * BMS 버튼 통합 타입 + */ +export type BmsButton = + | BmsWebButton + | BmsAppButton + | BmsChannelAddButton + | BmsBotKeywordButton + | BmsMessageDeliveryButton + | BmsConsultButton + | BmsBotTransferButton + | BmsBusinessFormButton; + /** - * BMS 버튼 통합 스키마 (Union) + * BMS 버튼 통합 스키마 (Union) - Discriminated by linkType */ export const bmsButtonSchema = Schema.Union( bmsWebButtonSchema, bmsAppButtonSchema, bmsChannelAddButtonSchema, + bmsBotKeywordButtonSchema, + bmsMessageDeliveryButtonSchema, + bmsConsultButtonSchema, + bmsBotTransferButtonSchema, + bmsBusinessFormButtonSchema, ); export type BmsButtonSchema = Schema.Schema.Type; /** - * BMS 버튼 스키마 (WL, AL만 허용) - 캐러셀 등 일부 타입에서 사용 + * BMS 링크 버튼 스키마 (WL, AL만 허용) - 캐러셀 등 일부 타입에서 사용 */ export const bmsLinkButtonSchema = Schema.Union( bmsWebButtonSchema, diff --git a/src/models/base/kakao/bms/bmsCarousel.ts b/src/models/base/kakao/bms/bmsCarousel.ts index 8f02eef..15d4ae1 100644 --- a/src/models/base/kakao/bms/bmsCarousel.ts +++ b/src/models/base/kakao/bms/bmsCarousel.ts @@ -3,6 +3,42 @@ import {bmsLinkButtonSchema} from './bmsButton'; import {bmsCommerceSchema} from './bmsCommerce'; import {bmsCouponSchema} from './bmsCoupon'; +/** + * BMS 캐러셀 인트로(head) 스키마 (CAROUSEL_COMMERCE용) + * - header: 헤더 (필수, max 20자) + * - content: 내용 (필수, max 50자) + * - imageId: 이미지 ID (필수) + * - linkMobile: 모바일 링크 (선택, linkPc/Android/Ios 사용 시 필수) + */ +export const bmsCarouselHeadSchema = Schema.Struct({ + header: Schema.String, + content: Schema.String, + imageId: Schema.String, + linkMobile: Schema.optional(Schema.String), + linkPc: Schema.optional(Schema.String), + linkAndroid: Schema.optional(Schema.String), + linkIos: Schema.optional(Schema.String), +}); + +export type BmsCarouselHeadSchema = Schema.Schema.Type< + typeof bmsCarouselHeadSchema +>; + +/** + * BMS 캐러셀 tail 스키마 + * - linkMobile: 모바일 링크 (필수) + */ +export const bmsCarouselTailSchema = Schema.Struct({ + linkMobile: Schema.String, + linkPc: Schema.optional(Schema.String), + linkAndroid: Schema.optional(Schema.String), + linkIos: Schema.optional(Schema.String), +}); + +export type BmsCarouselTailSchema = Schema.Schema.Type< + typeof bmsCarouselTailSchema +>; + /** * BMS 캐러셀 피드 아이템 타입 (CAROUSEL_FEED용) */ @@ -10,6 +46,7 @@ export type BmsCarouselFeedItem = { header: string; content: string; imageId: string; + imageLink?: string; buttons: ReadonlyArray>; coupon?: Schema.Schema.Type; }; @@ -20,16 +57,18 @@ export type BmsCarouselFeedItem = { export type BmsCarouselCommerceItem = { commerce: Schema.Schema.Type; imageId: string; + imageLink?: string; buttons: ReadonlyArray>; additionalContent?: string; coupon?: Schema.Schema.Type; }; /** - * BMS 캐러셀 피드 아이템 스키마 - * - header: 헤더 (필수, max 20 chars) - * - content: 내용 (필수, max 180 chars) - * - imageId: 이미지 ID (필수) + * BMS 캐러셀 피드 아이템 스키마 (CAROUSEL_FEED용) + * - header: 헤더 (필수, max 20자) + * - content: 내용 (필수, max 180자) + * - imageId: 이미지 ID (필수, BMS_CAROUSEL_FEED_LIST 타입) + * - imageLink: 이미지 클릭 시 이동 링크 (선택) * - buttons: 버튼 목록 (필수, 1-2개, WL/AL만) * - coupon: 쿠폰 (선택) */ @@ -37,6 +76,7 @@ export const bmsCarouselFeedItemSchema = Schema.Struct({ header: Schema.String, content: Schema.String, imageId: Schema.String, + imageLink: Schema.optional(Schema.String), buttons: Schema.Array(bmsLinkButtonSchema), coupon: Schema.optional(bmsCouponSchema), }); @@ -46,16 +86,18 @@ export type BmsCarouselFeedItemSchema = Schema.Schema.Type< >; /** - * BMS 캐러셀 커머스 아이템 스키마 + * BMS 캐러셀 커머스 아이템 스키마 (CAROUSEL_COMMERCE용) * - commerce: 커머스 정보 (필수) - * - imageId: 이미지 ID (필수) + * - imageId: 이미지 ID (필수, BMS_CAROUSEL_COMMERCE_LIST 타입) + * - imageLink: 이미지 클릭 시 이동 링크 (선택) * - buttons: 버튼 목록 (필수, 1-2개, WL/AL만) - * - additionalContent: 추가 내용 (선택, max 34 chars) + * - additionalContent: 추가 내용 (선택, max 34자) * - coupon: 쿠폰 (선택) */ export const bmsCarouselCommerceItemSchema = Schema.Struct({ commerce: bmsCommerceSchema, imageId: Schema.String, + imageLink: Schema.optional(Schema.String), buttons: Schema.Array(bmsLinkButtonSchema), additionalContent: Schema.optional(Schema.String), coupon: Schema.optional(bmsCouponSchema), @@ -67,9 +109,13 @@ export type BmsCarouselCommerceItemSchema = Schema.Schema.Type< /** * BMS 캐러셀 피드 스키마 (CAROUSEL_FEED용) + * - list: 캐러셀 아이템 목록 (필수, 2-6개, head 없을 때 / 1-5개, head 있을 때) + * - tail: 더보기 링크 (선택) + * Note: CAROUSEL_FEED에서는 head 사용 안함 */ export const bmsCarouselFeedSchema = Schema.Struct({ list: Schema.Array(bmsCarouselFeedItemSchema), + tail: Schema.optional(bmsCarouselTailSchema), }); export type BmsCarouselFeedSchema = Schema.Schema.Type< @@ -78,11 +124,26 @@ export type BmsCarouselFeedSchema = Schema.Schema.Type< /** * BMS 캐러셀 커머스 스키마 (CAROUSEL_COMMERCE용) + * - head: 캐러셀 인트로 (선택) + * - list: 캐러셀 아이템 목록 (필수, 2-6개, head 없을 때 / 1-5개, head 있을 때) + * - tail: 더보기 링크 (선택) */ export const bmsCarouselCommerceSchema = Schema.Struct({ + head: Schema.optional(bmsCarouselHeadSchema), list: Schema.Array(bmsCarouselCommerceItemSchema), + tail: Schema.optional(bmsCarouselTailSchema), }); export type BmsCarouselCommerceSchema = Schema.Schema.Type< typeof bmsCarouselCommerceSchema >; + +/** + * @deprecated bmsCarouselHeadSchema 사용 권장 + */ +export const bmsCarouselCommerceHeadSchema = bmsCarouselHeadSchema; + +/** + * @deprecated bmsCarouselTailSchema 사용 권장 + */ +export const bmsCarouselCommerceTailSchema = bmsCarouselTailSchema; diff --git a/src/models/base/kakao/bms/bmsCommerce.ts b/src/models/base/kakao/bms/bmsCommerce.ts index 5a1375e..01ddee2 100644 --- a/src/models/base/kakao/bms/bmsCommerce.ts +++ b/src/models/base/kakao/bms/bmsCommerce.ts @@ -1,4 +1,4 @@ -import {Schema} from 'effect'; +import {ParseResult, Schema} from 'effect'; /** * BMS 커머스 정보 타입 @@ -11,20 +11,59 @@ export type BmsCommerce = { discountFixed?: number; }; +/** + * 숫자 또는 숫자형 문자열을 number로 변환하는 스키마 + * - number 타입: 그대로 통과 + * - string 타입: parseFloat로 변환, 유효하지 않으면 검증 실패 + * + * API 호환성: 기존 number 입력 및 string 입력 모두 허용 + * 출력 타입: number + * + * Note: 타입 어설션을 사용하여 Encoded 타입을 number로 강제합니다. + * 이는 기존 API 타입 호환성을 유지하면서 런타임에서 문자열 입력도 허용하기 위함입니다. + */ +const NumberOrNumericString: Schema.Schema = + Schema.transformOrFail( + Schema.Union(Schema.Number, Schema.String), + Schema.Number, + { + strict: true, + decode: (input, _, ast) => { + if (typeof input === 'number') { + return ParseResult.succeed(input); + } + const trimmed = input.trim(); + if (trimmed === '') { + return ParseResult.fail( + new ParseResult.Type(ast, input, '유효한 숫자 형식이 아닙니다.'), + ); + } + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) { + return ParseResult.fail( + new ParseResult.Type(ast, input, '유효한 숫자 형식이 아닙니다.'), + ); + } + return ParseResult.succeed(parsed); + }, + encode: n => ParseResult.succeed(n), + }, + ) as Schema.Schema; + /** * BMS 커머스 정보 스키마 * - title: 상품명 (필수) - * - regularPrice: 정가 (필수) - * - discountPrice: 할인가 (선택) - * - discountRate: 할인율 (선택) - * - discountFixed: 고정 할인금액 (선택) + * - regularPrice: 정가 (필수, 숫자 또는 숫자형 문자열) + * - discountPrice: 할인가 (선택, 숫자 또는 숫자형 문자열) + * - discountRate: 할인율 (선택, 숫자 또는 숫자형 문자열) + * - discountFixed: 고정 할인금액 (선택, 숫자 또는 숫자형 문자열) */ export const bmsCommerceSchema = Schema.Struct({ title: Schema.String, - regularPrice: Schema.Number, - discountPrice: Schema.optional(Schema.Number), - discountRate: Schema.optional(Schema.Number), - discountFixed: Schema.optional(Schema.Number), + regularPrice: NumberOrNumericString, + discountPrice: Schema.optional(NumberOrNumericString), + discountRate: Schema.optional(NumberOrNumericString), + discountFixed: Schema.optional(NumberOrNumericString), }); export type BmsCommerceSchema = Schema.Schema.Type; diff --git a/src/models/base/kakao/bms/bmsCoupon.ts b/src/models/base/kakao/bms/bmsCoupon.ts index 41a4cb4..38fa8eb 100644 --- a/src/models/base/kakao/bms/bmsCoupon.ts +++ b/src/models/base/kakao/bms/bmsCoupon.ts @@ -1,15 +1,60 @@ import {Schema} from 'effect'; +// 숫자원 할인 쿠폰: 1~99999999원 (쉼표 없음) +const wonDiscountPattern = /^([1-9]\d{0,7})원 할인 쿠폰$/; + +// 퍼센트 할인 쿠폰: 1~100% +const percentDiscountPattern = /^([1-9]\d?|100)% 할인 쿠폰$/; + +// 무료 쿠폰: 앞 1~7자 (공백 포함 가능) +const freeCouponPattern = /^.{1,7} 무료 쿠폰$/; + +// UP 쿠폰: 앞 1~7자 (공백 포함 가능) +const upCouponPattern = /^.{1,7} UP 쿠폰$/; + +const isValidCouponTitle = (title: string): boolean => { + // 1. 배송비 할인 쿠폰 (고정) + if (title === '배송비 할인 쿠폰') return true; + + // 2. 숫자원 할인 쿠폰 + const wonMatch = title.match(wonDiscountPattern); + if (wonMatch) { + const num = parseInt(wonMatch[1], 10); + return num >= 1 && num <= 99_999_999; + } + + // 3. 퍼센트 할인 쿠폰 + if (percentDiscountPattern.test(title)) return true; + + // 4. 무료 쿠폰 + if (freeCouponPattern.test(title)) return true; + + // 5. UP 쿠폰 + return upCouponPattern.test(title); +}; + /** - * BMS 쿠폰 제목 프리셋 - * API에서 허용하는 5가지 프리셋 값만 사용 가능 + * BMS 쿠폰 제목 스키마 + * 5가지 형식 허용: + * - "${숫자}원 할인 쿠폰" (1~99,999,999) + * - "${숫자}% 할인 쿠폰" (1~100) + * - "배송비 할인 쿠폰" + * - "${7자 이내} 무료 쿠폰" + * - "${7자 이내} UP 쿠폰" */ -export type BmsCouponTitle = - | '할인 쿠폰' - | '배송비 쿠폰' - | '기간 제한 쿠폰' - | '이벤트 쿠폰' - | '적립금 쿠폰'; +export const bmsCouponTitleSchema = Schema.String.pipe( + Schema.filter(isValidCouponTitle, { + message: () => + '쿠폰 제목은 다음 형식 중 하나여야 합니다: ' + + '"N원 할인 쿠폰" (1~99999999), ' + + '"N% 할인 쿠폰" (1~100), ' + + '"배송비 할인 쿠폰", ' + + '"OOO 무료 쿠폰" (7자 이내), ' + + '"OOO UP 쿠폰" (7자 이내)', + }), +); + +export type BmsCouponTitle = string; /** * BMS 쿠폰 타입 @@ -23,18 +68,6 @@ export type BmsCoupon = { linkIos?: string; }; -/** - * BMS 쿠폰 제목 스키마 - * 5가지 프리셋 값만 허용 - */ -export const bmsCouponTitleSchema = Schema.Literal( - '할인 쿠폰', - '배송비 쿠폰', - '기간 제한 쿠폰', - '이벤트 쿠폰', - '적립금 쿠폰', -); - /** * BMS 쿠폰 스키마 * - title: 5가지 프리셋 중 하나 (필수) diff --git a/src/models/base/kakao/bms/bmsVideo.ts b/src/models/base/kakao/bms/bmsVideo.ts index 5785e31..470481a 100644 --- a/src/models/base/kakao/bms/bmsVideo.ts +++ b/src/models/base/kakao/bms/bmsVideo.ts @@ -1,21 +1,37 @@ import {Schema} from 'effect'; +const KAKAO_TV_URL_PREFIX = 'https://tv.kakao.com/'; + +/** + * 카카오 TV URL 검증 + */ +const isKakaoTvUrl = (url: string): boolean => + url.startsWith(KAKAO_TV_URL_PREFIX); + /** * BMS 비디오 정보 타입 (PREMIUM_VIDEO용) */ export type BmsVideo = { - videoId: string; - thumbImageId: string; + videoUrl: string; + imageId?: string; + imageLink?: string; }; /** * BMS 비디오 정보 스키마 - * - videoId: 비디오 ID (필수) - * - thumbImageId: 썸네일 이미지 ID (필수) + * - videoUrl: 카카오TV 동영상 URL (필수, https://tv.kakao.com/으로 시작) + * - imageId: 썸네일 이미지 ID (선택) + * - imageLink: 이미지 클릭 시 이동할 링크 (선택) */ export const bmsVideoSchema = Schema.Struct({ - videoId: Schema.String, - thumbImageId: Schema.String, + videoUrl: Schema.String.pipe( + Schema.filter(isKakaoTvUrl, { + message: () => + `videoUrl은 '${KAKAO_TV_URL_PREFIX}'으로 시작하는 카카오TV 동영상 링크여야 합니다.`, + }), + ), + imageId: Schema.optional(Schema.String), + imageLink: Schema.optional(Schema.String), }); export type BmsVideoSchema = Schema.Schema.Type; diff --git a/src/models/base/kakao/bms/bmsWideItem.ts b/src/models/base/kakao/bms/bmsWideItem.ts index a9601a8..dfe7722 100644 --- a/src/models/base/kakao/bms/bmsWideItem.ts +++ b/src/models/base/kakao/bms/bmsWideItem.ts @@ -1,33 +1,74 @@ import {Schema} from 'effect'; /** - * BMS 와이드 아이템 타입 (WIDE_ITEM_LIST용) + * BMS 메인 와이드 아이템 타입 (WIDE_ITEM_LIST용) */ -export type BmsWideItem = { +export type BmsMainWideItem = { + title?: string; + imageId: string; + linkMobile: string; + linkPc?: string; + linkAndroid?: string; + linkIos?: string; +}; + +/** + * BMS 서브 와이드 아이템 타입 (WIDE_ITEM_LIST용) + */ +export type BmsSubWideItem = { title: string; - description?: string; - imageId?: string; - linkMobile?: string; + imageId: string; + linkMobile: string; linkPc?: string; linkAndroid?: string; linkIos?: string; }; /** - * BMS 와이드 아이템 스키마 - * - title: 제목 (필수) - * - description: 설명 (선택) - * - imageId: 이미지 ID (선택) - * - linkMobile, linkPc, linkAndroid, linkIos: 링크 (선택) + * BMS 메인 와이드 아이템 스키마 + * - title: 제목 (선택, max 25자) + * - imageId: 이미지 ID (필수, BMS_WIDE_MAIN_ITEM_LIST 타입) + * - linkMobile: 모바일 링크 (필수) + * - linkPc, linkAndroid, linkIos: 링크 (선택) */ -export const bmsWideItemSchema = Schema.Struct({ +export const bmsMainWideItemSchema = Schema.Struct({ + title: Schema.optional(Schema.String), + imageId: Schema.String, + linkMobile: Schema.String, + linkPc: Schema.optional(Schema.String), + linkAndroid: Schema.optional(Schema.String), + linkIos: Schema.optional(Schema.String), +}); + +export type BmsMainWideItemSchema = Schema.Schema.Type< + typeof bmsMainWideItemSchema +>; + +/** + * BMS 서브 와이드 아이템 스키마 + * - title: 제목 (필수, max 30자) + * - imageId: 이미지 ID (필수, BMS_WIDE_SUB_ITEM_LIST 타입) + * - linkMobile: 모바일 링크 (필수) + * - linkPc, linkAndroid, linkIos: 링크 (선택) + */ +export const bmsSubWideItemSchema = Schema.Struct({ title: Schema.String, - description: Schema.optional(Schema.String), - imageId: Schema.optional(Schema.String), - linkMobile: Schema.optional(Schema.String), + imageId: Schema.String, + linkMobile: Schema.String, linkPc: Schema.optional(Schema.String), linkAndroid: Schema.optional(Schema.String), linkIos: Schema.optional(Schema.String), }); -export type BmsWideItemSchema = Schema.Schema.Type; +export type BmsSubWideItemSchema = Schema.Schema.Type< + typeof bmsSubWideItemSchema +>; + +/** + * @deprecated bmsMainWideItemSchema 또는 bmsSubWideItemSchema 사용 권장 + * BMS 와이드 아이템 통합 스키마 (하위 호환성) + */ +export const bmsWideItemSchema = bmsSubWideItemSchema; + +export type BmsWideItem = BmsSubWideItem; +export type BmsWideItemSchema = BmsSubWideItemSchema; diff --git a/src/models/base/kakao/bms/index.ts b/src/models/base/kakao/bms/index.ts index c20c7ac..26cf881 100644 --- a/src/models/base/kakao/bms/index.ts +++ b/src/models/base/kakao/bms/index.ts @@ -1,15 +1,26 @@ export { type BmsAppButton, + type BmsBotKeywordButton, + type BmsBotTransferButton, + type BmsBusinessFormButton, type BmsButton, type BmsButtonLinkType, type BmsButtonSchema, type BmsChannelAddButton, + type BmsConsultButton, type BmsLinkButtonSchema, + type BmsMessageDeliveryButton, type BmsWebButton, bmsAppButtonSchema, + bmsBotKeywordButtonSchema, + bmsBotTransferButtonSchema, + bmsBusinessFormButtonSchema, + bmsButtonLinkTypeSchema, bmsButtonSchema, bmsChannelAddButtonSchema, + bmsConsultButtonSchema, bmsLinkButtonSchema, + bmsMessageDeliveryButtonSchema, bmsWebButtonSchema, } from './bmsButton'; export { @@ -19,10 +30,16 @@ export { type BmsCarouselFeedItem, type BmsCarouselFeedItemSchema, type BmsCarouselFeedSchema, + type BmsCarouselHeadSchema, + type BmsCarouselTailSchema, + bmsCarouselCommerceHeadSchema, bmsCarouselCommerceItemSchema, bmsCarouselCommerceSchema, + bmsCarouselCommerceTailSchema, bmsCarouselFeedItemSchema, bmsCarouselFeedSchema, + bmsCarouselHeadSchema, + bmsCarouselTailSchema, } from './bmsCarousel'; export { @@ -43,7 +60,13 @@ export { bmsVideoSchema, } from './bmsVideo'; export { + type BmsMainWideItem, + type BmsMainWideItemSchema, + type BmsSubWideItem, + type BmsSubWideItemSchema, type BmsWideItem, type BmsWideItemSchema, + bmsMainWideItemSchema, + bmsSubWideItemSchema, bmsWideItemSchema, } from './bmsWideItem'; diff --git a/src/models/base/kakao/kakaoOption.ts b/src/models/base/kakao/kakaoOption.ts index fd3ed4a..da3689a 100644 --- a/src/models/base/kakao/kakaoOption.ts +++ b/src/models/base/kakao/kakaoOption.ts @@ -7,8 +7,9 @@ import { bmsCarouselFeedSchema, bmsCommerceSchema, bmsCouponSchema, + bmsMainWideItemSchema, + bmsSubWideItemSchema, bmsVideoSchema, - bmsWideItemSchema, } from './bms'; import {KakaoButton, kakaoButtonSchema} from './kakaoButton'; @@ -45,18 +46,29 @@ export type BmsChatBubbleType = Schema.Schema.Type< /** * chatBubbleType별 필수 필드 정의 + * - TEXT: content는 메시지의 text 필드에서 가져옴 + * - WIDE_ITEM_LIST: header, mainWideItem, subWideItemList 필수 + * - COMMERCE: imageId, commerce, buttons 필수 */ const BMS_REQUIRED_FIELDS: Record> = { TEXT: [], IMAGE: ['imageId'], WIDE: ['imageId'], - WIDE_ITEM_LIST: ['mainWideItem', 'subWideItemList'], - COMMERCE: ['commerce', 'buttons'], + WIDE_ITEM_LIST: ['header', 'mainWideItem', 'subWideItemList'], + COMMERCE: ['imageId', 'commerce', 'buttons'], CAROUSEL_FEED: ['carousel'], CAROUSEL_COMMERCE: ['carousel'], PREMIUM_VIDEO: ['video'], }; +/** + * BMS 캐러셀 통합 스키마 (CAROUSEL_FEED | CAROUSEL_COMMERCE) + */ +const bmsCarouselSchema = Schema.Union( + bmsCarouselFeedSchema, + bmsCarouselCommerceSchema, +); + /** * BMS 옵션 기본 스키마 (검증 전) */ @@ -71,13 +83,12 @@ const baseBmsSchema = Schema.Struct({ imageId: Schema.optional(Schema.String), imageLink: Schema.optional(Schema.String), additionalContent: Schema.optional(Schema.String), + content: Schema.optional(Schema.String), // 복합 타입 필드 - carousel: Schema.optional( - Schema.Union(bmsCarouselFeedSchema, bmsCarouselCommerceSchema), - ), - mainWideItem: Schema.optional(bmsWideItemSchema), - subWideItemList: Schema.optional(Schema.Array(bmsWideItemSchema)), + carousel: Schema.optional(bmsCarouselSchema), + mainWideItem: Schema.optional(bmsMainWideItemSchema), + subWideItemList: Schema.optional(Schema.Array(bmsSubWideItemSchema)), buttons: Schema.optional(Schema.Array(bmsButtonSchema)), coupon: Schema.optional(bmsCouponSchema), commerce: Schema.optional(bmsCommerceSchema), diff --git a/src/models/base/messages/message.ts b/src/models/base/messages/message.ts index f84c6da..1630c10 100644 --- a/src/models/base/messages/message.ts +++ b/src/models/base/messages/message.ts @@ -52,7 +52,8 @@ export type MessageType = | 'BMS_CAROUSEL_FEED' | 'BMS_PREMIUM_VIDEO' | 'BMS_COMMERCE' - | 'BMS_CAROUSEL_COMMERCE'; + | 'BMS_CAROUSEL_COMMERCE' + | 'BMS_FREE'; /** * 메시지 타입 @@ -104,6 +105,7 @@ export const messageTypeSchema = Schema.Literal( 'BMS_PREMIUM_VIDEO', 'BMS_COMMERCE', 'BMS_CAROUSEL_COMMERCE', + 'BMS_FREE', ); export const messageSchema = Schema.Struct({ diff --git a/src/services/messages/messageService.ts b/src/services/messages/messageService.ts index 3d58650..62acb19 100644 --- a/src/services/messages/messageService.ts +++ b/src/services/messages/messageService.ts @@ -27,7 +27,7 @@ import { SingleMessageSentResponse, } from '@models/responses/messageResponses'; import {DetailGroupMessageResponse} from '@models/responses/sendManyDetailResponse'; -import {Cause, Exit, Schema} from 'effect'; +import {Cause, Chunk, Exit, Schema} from 'effect'; import * as Effect from 'effect/Effect'; import { BadRequestError, @@ -197,7 +197,20 @@ export default class MessageService extends DefaultService { if (failure._tag === 'Some') { throw toCompatibleError(failure.value); } - throw new Error('Unknown error occurred'); + // Defect 처리 + const defects = Cause.defects(cause); + if (defects.length > 0) { + const firstDefect = Chunk.unsafeGet(defects, 0); + if (firstDefect instanceof Error) { + throw firstDefect; + } + const isProduction = process.env.NODE_ENV === 'production'; + const message = isProduction + ? `Unexpected error: ${String(firstDefect)}` + : `Unexpected error: ${String(firstDefect)}\nCause: ${Cause.pretty(cause)}`; + throw new Error(message); + } + throw new Error(`Unhandled Exit: ${Cause.pretty(cause)}`); }, onSuccess: value => value, }); diff --git a/test/models/base/kakao/bms/bmsButton.test.ts b/test/models/base/kakao/bms/bmsButton.test.ts index ce1e168..bbafa39 100644 --- a/test/models/base/kakao/bms/bmsButton.test.ts +++ b/test/models/base/kakao/bms/bmsButton.test.ts @@ -61,23 +61,46 @@ describe('BMS Button Schema', () => { expect(result._tag).toBe('Right'); }); - it('should reject app button without linkAndroid', () => { - const invalidButton = { + it('should accept app button with only linkAndroid', () => { + const validButton = { + name: '앱버튼', + linkType: 'AL', + linkAndroid: 'intent://example', + }; + + const result = + Schema.decodeUnknownEither(bmsAppButtonSchema)(validButton); + expect(result._tag).toBe('Right'); + }); + + it('should accept app button with only linkIos', () => { + const validButton = { name: '앱버튼', linkType: 'AL', linkIos: 'example://app', }; const result = - Schema.decodeUnknownEither(bmsAppButtonSchema)(invalidButton); - expect(result._tag).toBe('Left'); + Schema.decodeUnknownEither(bmsAppButtonSchema)(validButton); + expect(result._tag).toBe('Right'); + }); + + it('should accept app button with only linkMobile', () => { + const validButton = { + name: '앱버튼', + linkType: 'AL', + linkMobile: 'https://m.example.com', + }; + + const result = + Schema.decodeUnknownEither(bmsAppButtonSchema)(validButton); + expect(result._tag).toBe('Right'); }); - it('should reject app button without linkIos', () => { + it('should reject app button without any link', () => { const invalidButton = { name: '앱버튼', linkType: 'AL', - linkAndroid: 'intent://example', }; const result = diff --git a/test/models/base/kakao/bms/bmsCommerce.test.ts b/test/models/base/kakao/bms/bmsCommerce.test.ts new file mode 100644 index 0000000..d621139 --- /dev/null +++ b/test/models/base/kakao/bms/bmsCommerce.test.ts @@ -0,0 +1,189 @@ +import {bmsCommerceSchema} from '@models/base/kakao/bms/bmsCommerce'; +import {Schema} from 'effect'; +import {describe, expect, it} from 'vitest'; + +describe('BMS Commerce Schema', () => { + describe('숫자 타입 필드 검증', () => { + it('should accept number values for regularPrice', () => { + const valid = { + title: '상품명', + regularPrice: 10000, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + }); + + it('should accept numeric string values for regularPrice', () => { + const valid = { + title: '상품명', + regularPrice: '10000', + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + if (result._tag === 'Right') { + expect(result.right.regularPrice).toBe(10000); + expect(typeof result.right.regularPrice).toBe('number'); + } + }); + + it('should accept decimal numeric strings', () => { + const valid = { + title: '상품명', + regularPrice: '10000.50', + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + if (result._tag === 'Right') { + expect(result.right.regularPrice).toBe(10000.5); + } + }); + + it('should reject invalid string values', () => { + const invalid = { + title: '상품명', + regularPrice: 'invalid', + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid); + expect(result._tag).toBe('Left'); + }); + + it('should reject empty string values', () => { + const invalid = { + title: '상품명', + regularPrice: '', + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid); + expect(result._tag).toBe('Left'); + }); + + it('should reject whitespace-only string values', () => { + const invalid = { + title: '상품명', + regularPrice: ' ', + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid); + expect(result._tag).toBe('Left'); + }); + }); + + describe('선택적 숫자 필드 검증', () => { + it('should accept mixed number and string for optional fields', () => { + const valid = { + title: '상품명', + regularPrice: 10000, + discountPrice: '8000', + discountRate: 20, + discountFixed: '2000', + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + if (result._tag === 'Right') { + expect(result.right.discountPrice).toBe(8000); + expect(result.right.discountRate).toBe(20); + expect(result.right.discountFixed).toBe(2000); + } + }); + + it('should accept all string values for numeric fields', () => { + const valid = { + title: '상품명', + regularPrice: '15000', + discountPrice: '12000', + discountRate: '20', + discountFixed: '3000', + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + if (result._tag === 'Right') { + expect(result.right.regularPrice).toBe(15000); + expect(result.right.discountPrice).toBe(12000); + expect(result.right.discountRate).toBe(20); + expect(result.right.discountFixed).toBe(3000); + // 모든 필드가 number 타입으로 변환되었는지 확인 + expect(typeof result.right.regularPrice).toBe('number'); + expect(typeof result.right.discountPrice).toBe('number'); + expect(typeof result.right.discountRate).toBe('number'); + expect(typeof result.right.discountFixed).toBe('number'); + } + }); + + it('should accept optional fields as undefined', () => { + const valid = { + title: '상품명', + regularPrice: 10000, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + if (result._tag === 'Right') { + expect(result.right.discountPrice).toBeUndefined(); + expect(result.right.discountRate).toBeUndefined(); + expect(result.right.discountFixed).toBeUndefined(); + } + }); + + it('should reject invalid optional field values', () => { + const invalid = { + title: '상품명', + regularPrice: 10000, + discountPrice: 'invalid', + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid); + expect(result._tag).toBe('Left'); + }); + }); + + describe('필수 필드 검증', () => { + it('should reject missing title', () => { + const invalid = { + regularPrice: 10000, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid); + expect(result._tag).toBe('Left'); + }); + + it('should reject missing regularPrice', () => { + const invalid = { + title: '상품명', + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid); + expect(result._tag).toBe('Left'); + }); + }); + + describe('실제 사용 사례 테스트', () => { + it('should handle CAROUSEL_COMMERCE style input (string prices)', () => { + // debug/bms_free/hosy_test.js의 CAROUSEL_COMMERCE 템플릿과 동일한 구조 + const valid = { + title: '상품명2', + regularPrice: '10000', + discountPrice: '50', + discountRate: '50', + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + if (result._tag === 'Right') { + expect(result.right.regularPrice).toBe(10000); + expect(result.right.discountPrice).toBe(50); + expect(result.right.discountRate).toBe(50); + } + }); + + it('should handle COMMERCE style input (mixed types)', () => { + const valid = { + title: '상품명', + regularPrice: 1000, + discountPrice: '800', + discountRate: 20, + discountFixed: '200', + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + if (result._tag === 'Right') { + expect(result.right.regularPrice).toBe(1000); + expect(result.right.discountPrice).toBe(800); + expect(result.right.discountRate).toBe(20); + expect(result.right.discountFixed).toBe(200); + } + }); + }); +}); diff --git a/test/models/base/kakao/bms/bmsCoupon.test.ts b/test/models/base/kakao/bms/bmsCoupon.test.ts index 3cba1ac..737a17a 100644 --- a/test/models/base/kakao/bms/bmsCoupon.test.ts +++ b/test/models/base/kakao/bms/bmsCoupon.test.ts @@ -8,11 +8,14 @@ import {describe, expect, it} from 'vitest'; describe('BMS Coupon Schema', () => { describe('bmsCouponTitleSchema', () => { const validTitles = [ - '할인 쿠폰', - '배송비 쿠폰', - '기간 제한 쿠폰', - '이벤트 쿠폰', - '적립금 쿠폰', + '1000원 할인 쿠폰', + '99999999원 할인 쿠폰', + '50% 할인 쿠폰', + '100% 할인 쿠폰', + '배송비 할인 쿠폰', + '신규가입 무료 쿠폰', + '포인트 UP 쿠폰', + '신규 가입 무료 쿠폰', // 공백 포함 7자 ]; it.each(validTitles)('should accept valid title: %s', title => { @@ -20,9 +23,18 @@ describe('BMS Coupon Schema', () => { expect(result._tag).toBe('Right'); }); - it('should reject invalid title', () => { - const result = - Schema.decodeUnknownEither(bmsCouponTitleSchema)('잘못된 쿠폰'); + const invalidTitles = [ + '잘못된 쿠폰', + '0원 할인 쿠폰', // 0은 허용 안함 + '100000000원 할인 쿠폰', // 99999999 초과 + '0% 할인 쿠폰', // 0은 허용 안함 + '101% 할인 쿠폰', // 100 초과 + '12345678 무료 쿠폰', // 8자 이상 + '12345678 UP 쿠폰', // 8자 이상 + ]; + + it.each(invalidTitles)('should reject invalid title: %s', title => { + const result = Schema.decodeUnknownEither(bmsCouponTitleSchema)(title); expect(result._tag).toBe('Left'); }); }); @@ -30,7 +42,7 @@ describe('BMS Coupon Schema', () => { describe('bmsCouponSchema', () => { it('should accept valid coupon with required fields', () => { const validCoupon = { - title: '할인 쿠폰', + title: '10000원 할인 쿠폰', description: '10% 할인', }; @@ -40,7 +52,7 @@ describe('BMS Coupon Schema', () => { it('should accept coupon with all optional fields', () => { const validCoupon = { - title: '이벤트 쿠폰', + title: '50% 할인 쿠폰', description: '특별 할인', linkMobile: 'https://m.example.com/coupon', linkPc: 'https://www.example.com/coupon', @@ -63,7 +75,7 @@ describe('BMS Coupon Schema', () => { it('should reject coupon without description', () => { const invalidCoupon = { - title: '할인 쿠폰', + title: '10000원 할인 쿠폰', }; const result = Schema.decodeUnknownEither(bmsCouponSchema)(invalidCoupon); diff --git a/test/models/base/kakao/bms/bmsOption.test.ts b/test/models/base/kakao/bms/bmsOption.test.ts index 752a5df..c5a4055 100644 --- a/test/models/base/kakao/bms/bmsOption.test.ts +++ b/test/models/base/kakao/bms/bmsOption.test.ts @@ -101,10 +101,24 @@ describe('BMS Option Schema in KakaoOption', () => { bms: { targeting: 'M', chatBubbleType: 'WIDE_ITEM_LIST', + header: '헤더 제목', mainWideItem: { title: '메인 아이템', + imageId: 'img-main', + linkMobile: 'https://example.com/main', }, - subWideItemList: [{title: '서브 아이템 1'}, {title: '서브 아이템 2'}], + subWideItemList: [ + { + title: '서브 아이템 1', + imageId: 'img-sub-1', + linkMobile: 'https://example.com/sub1', + }, + { + title: '서브 아이템 2', + imageId: 'img-sub-2', + linkMobile: 'https://example.com/sub2', + }, + ], }, }; @@ -120,7 +134,14 @@ describe('BMS Option Schema in KakaoOption', () => { bms: { targeting: 'M', chatBubbleType: 'WIDE_ITEM_LIST', - subWideItemList: [{title: '서브 아이템'}], + header: '헤더 제목', + subWideItemList: [ + { + title: '서브 아이템', + imageId: 'img-sub', + linkMobile: 'https://example.com/sub', + }, + ], }, }; @@ -135,6 +156,7 @@ describe('BMS Option Schema in KakaoOption', () => { bms: { targeting: 'I', chatBubbleType: 'COMMERCE', + imageId: 'img-commerce', commerce: { title: '상품명', regularPrice: 10000, @@ -300,8 +322,8 @@ describe('BMS Option Schema in KakaoOption', () => { targeting: 'I', chatBubbleType: 'PREMIUM_VIDEO', video: { - videoId: 'video-123', - thumbImageId: 'thumb-123', + videoUrl: 'https://tv.kakao.com/v/123456789', + imageId: 'thumb-123', }, }, }; @@ -392,13 +414,25 @@ describe('BMS Option Schema in KakaoOption', () => { case 'WIDE_ITEM_LIST': bms = { ...bms, - mainWideItem: {title: '메인'}, - subWideItemList: [{title: '서브'}], + header: '헤더 제목', + mainWideItem: { + title: '메인', + imageId: 'img-main', + linkMobile: 'https://example.com/main', + }, + subWideItemList: [ + { + title: '서브', + imageId: 'img-sub', + linkMobile: 'https://example.com/sub', + }, + ], }; break; case 'COMMERCE': bms = { ...bms, + imageId: 'img-commerce', commerce: {title: '상품', regularPrice: 10000}, buttons: [ {name: '구매', linkType: 'WL', linkMobile: 'https://example.com'}, @@ -449,7 +483,10 @@ describe('BMS Option Schema in KakaoOption', () => { case 'PREMIUM_VIDEO': bms = { ...bms, - video: {videoId: 'video-123', thumbImageId: 'thumb-123'}, + video: { + videoUrl: 'https://tv.kakao.com/v/123456789', + imageId: 'thumb-123', + }, }; break; } @@ -505,7 +542,7 @@ describe('BMS Option Schema in KakaoOption', () => { targeting: 'I', chatBubbleType: 'TEXT', coupon: { - title: '할인 쿠폰', + title: '10000원 할인 쿠폰', description: '10% 할인', }, }, From 09e2fb6c3f19ea23811aac9d1b7ffd366c47e08b Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 14 Jan 2026 14:52:52 +0900 Subject: [PATCH 05/13] Remove outdated Kakao friend talk examples - Deleted multiple example files for sending Kakao friend talks, including plain text, with buttons, with images, and with images and buttons. - These files contained sample code for sending messages using the SolapiMessageService, which is no longer needed. This cleanup helps streamline the codebase by removing deprecated examples. --- .../src/kakao/send/send_friendtalk_plain.js | 90 ------ .../send/send_friendtalk_with_buttons.js | 271 ----------------- .../kakao/send/send_friendtalk_with_image.js | 99 ------ .../send_friendtalk_with_image_and_buttons.js | 283 ------------------ 4 files changed, 743 deletions(-) delete mode 100644 examples/javascript/common/src/kakao/send/send_friendtalk_plain.js delete mode 100644 examples/javascript/common/src/kakao/send/send_friendtalk_with_buttons.js delete mode 100644 examples/javascript/common/src/kakao/send/send_friendtalk_with_image.js delete mode 100644 examples/javascript/common/src/kakao/send/send_friendtalk_with_image_and_buttons.js diff --git a/examples/javascript/common/src/kakao/send/send_friendtalk_plain.js b/examples/javascript/common/src/kakao/send/send_friendtalk_plain.js deleted file mode 100644 index 14c6239..0000000 --- a/examples/javascript/common/src/kakao/send/send_friendtalk_plain.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * 카카오 친구톡 발송 예제 - * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 - */ -const {SolapiMessageService} = require('solapi'); -const messageService = new SolapiMessageService( - 'ENTER_YOUR_API_KEY', - 'ENTER_YOUR_API_SECRET', -); - -// 단일 발송 예제 -messageService - .sendOne({ - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - }, - }) - .then(res => console.log(res)); - -// 단일 예약 발송 예제 -// 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. -messageService - .sendOneFuture( - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - }, - }, - '2022-12-08 00:00:00', - ) - .then(res => console.log(res)); - -// 여러 메시지 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 -messageService - .send([ - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - }, - }, - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - }, - }, - // 2번째 파라미터 내 항목인 allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. - ]) - .then(res => console.log(res)); - -// 여러 메시지 예약 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 -// 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. -messageService - .send( - [ - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - }, - }, - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - }, - }, - ], - { - scheduledDate: '2022-12-08 00:00:00', - // allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. - // allowDuplicates: true, - }, - ) - .then(res => console.log(res)); diff --git a/examples/javascript/common/src/kakao/send/send_friendtalk_with_buttons.js b/examples/javascript/common/src/kakao/send/send_friendtalk_with_buttons.js deleted file mode 100644 index 2c0ca2b..0000000 --- a/examples/javascript/common/src/kakao/send/send_friendtalk_with_buttons.js +++ /dev/null @@ -1,271 +0,0 @@ -/** - * 버튼을 포함한 카카오 친구톡 발송 예제 - * 버튼은 최대 5개까지 추가할 수 있습니다. - * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 - */ -const {SolapiMessageService} = require('solapi'); -const messageService = new SolapiMessageService( - 'ENTER_YOUR_API_KEY', - 'ENTER_YOUR_API_SECRET', -); - -// 단일 발송 예제 -messageService - .sendOne({ - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - buttons: [ - { - buttonType: 'WL', // 웹링크 - buttonName: '버튼 이름', - linkMo: 'https://m.example.com', - linkPc: 'https://example.com', // 생략 가능 - }, - { - buttonType: 'AL', // 앱링크 - buttonName: '실행 버튼', - linkAnd: 'examplescheme://', - linkIos: 'examplescheme://', - }, - { - buttonType: 'BK', // 봇키워드(챗봇에게 키워드를 전달합니다. 버튼이름의 키워드가 그대로 전달됩니다.) - buttonName: '봇키워드', - }, - { - buttonType: 'MD', // 상담요청하기 (상담요청하기 버튼을 누르면 메시지 내용이 상담원에게 그대로 전달됩니다.) - buttonName: '상담요청하기', - }, - { - buttonType: 'BT', // 챗봇 운영시 챗봇 문의로 전환할 수 있습니다. - buttonName: '챗봇 문의', - }, - /*{ - buttonType: 'BC', // 상담톡으로 전환합니다 (상담톡 서비스 사용 시 가능) - buttonName: '상담톡 전환', - },*/ - ], - }, - }) - .then(res => console.log(res)); - -// 단일 예약 발송 예제 -// 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. -messageService - .sendOneFuture( - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - buttons: [ - { - buttonType: 'WL', // 웹링크 - buttonName: '버튼 이름', - linkMo: 'https://m.example.com', - linkPc: 'https://example.com', // 생략 가능 - }, - { - buttonType: 'AL', // 앱링크 - buttonName: '실행 버튼', - linkAnd: 'examplescheme://', - linkIos: 'examplescheme://', - }, - { - buttonType: 'BK', // 봇키워드(챗봇에게 키워드를 전달합니다. 버튼이름의 키워드가 그대로 전달됩니다.) - buttonName: '봇키워드', - }, - { - buttonType: 'MD', // 상담요청하기 (상담요청하기 버튼을 누르면 메시지 내용이 상담원에게 그대로 전달됩니다.) - buttonName: '상담요청하기', - }, - { - buttonType: 'BT', // 챗봇 운영시 챗봇 문의로 전환할 수 있습니다. - buttonName: '챗봇 문의', - }, - /*{ - buttonType: 'BC', // 상담톡으로 전환합니다 (상담톡 서비스 사용 시 가능) - buttonName: '상담톡 전환', - },*/ - ], - }, - }, - '2022-12-08 00:00:00', - ) - .then(res => console.log(res)); - -// 여러 메시지 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 -messageService - .send([ - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - buttons: [ - { - buttonType: 'WL', // 웹링크 - buttonName: '버튼 이름', - linkMo: 'https://m.example.com', - linkPc: 'https://example.com', // 생략 가능 - }, - { - buttonType: 'AL', // 앱링크 - buttonName: '실행 버튼', - linkAnd: 'examplescheme://', - linkIos: 'examplescheme://', - }, - { - buttonType: 'BK', // 봇키워드(챗봇에게 키워드를 전달합니다. 버튼이름의 키워드가 그대로 전달됩니다.) - buttonName: '봇키워드', - }, - { - buttonType: 'MD', // 상담요청하기 (상담요청하기 버튼을 누르면 메시지 내용이 상담원에게 그대로 전달됩니다.) - buttonName: '상담요청하기', - }, - { - buttonType: 'BT', // 챗봇 운영시 챗봇 문의로 전환할 수 있습니다. - buttonName: '챗봇 문의', - }, - /*{ - buttonType: 'BC', // 상담톡으로 전환합니다 (상담톡 서비스 사용 시 가능) - buttonName: '상담톡 전환', - },*/ - ], - }, - }, - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - buttons: [ - { - buttonType: 'WL', // 웹링크 - buttonName: '버튼 이름', - linkMo: 'https://m.example.com', - linkPc: 'https://example.com', // 생략 가능 - }, - { - buttonType: 'AL', // 앱링크 - buttonName: '실행 버튼', - linkAnd: 'examplescheme://', - linkIos: 'examplescheme://', - }, - { - buttonType: 'BK', // 봇키워드(챗봇에게 키워드를 전달합니다. 버튼이름의 키워드가 그대로 전달됩니다.) - buttonName: '봇키워드', - }, - { - buttonType: 'MD', // 상담요청하기 (상담요청하기 버튼을 누르면 메시지 내용이 상담원에게 그대로 전달됩니다.) - buttonName: '상담요청하기', - }, - { - buttonType: 'BT', // 챗봇 운영시 챗봇 문의로 전환할 수 있습니다. - buttonName: '챗봇 문의', - }, - /*{ - buttonType: 'BC', // 상담톡으로 전환합니다 (상담톡 서비스 사용 시 가능) - buttonName: '상담톡 전환', - },*/ - ], - }, - }, - // 2번째 파라미터 내 항목인 allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. - ]) - .then(res => console.log(res)); - -// 여러 메시지 예약 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 -// 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. -messageService - .send( - [ - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - buttons: [ - { - buttonType: 'WL', // 웹링크 - buttonName: '버튼 이름', - linkMo: 'https://m.example.com', - linkPc: 'https://example.com', // 생략 가능 - }, - { - buttonType: 'AL', // 앱링크 - buttonName: '실행 버튼', - linkAnd: 'examplescheme://', - linkIos: 'examplescheme://', - }, - { - buttonType: 'BK', // 봇키워드(챗봇에게 키워드를 전달합니다. 버튼이름의 키워드가 그대로 전달됩니다.) - buttonName: '봇키워드', - }, - { - buttonType: 'MD', // 상담요청하기 (상담요청하기 버튼을 누르면 메시지 내용이 상담원에게 그대로 전달됩니다.) - buttonName: '상담요청하기', - }, - { - buttonType: 'BT', // 챗봇 운영시 챗봇 문의로 전환할 수 있습니다. - buttonName: '챗봇 문의', - }, - /*{ - buttonType: 'BC', // 상담톡으로 전환합니다 (상담톡 서비스 사용 시 가능) - buttonName: '상담톡 전환', - },*/ - ], - }, - }, - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - buttons: [ - { - buttonType: 'WL', // 웹링크 - buttonName: '버튼 이름', - linkMo: 'https://m.example.com', - linkPc: 'https://example.com', // 생략 가능 - }, - { - buttonType: 'AL', // 앱링크 - buttonName: '실행 버튼', - linkAnd: 'examplescheme://', - linkIos: 'examplescheme://', - }, - { - buttonType: 'BK', // 봇키워드(챗봇에게 키워드를 전달합니다. 버튼이름의 키워드가 그대로 전달됩니다.) - buttonName: '봇키워드', - }, - { - buttonType: 'MD', // 상담요청하기 (상담요청하기 버튼을 누르면 메시지 내용이 상담원에게 그대로 전달됩니다.) - buttonName: '상담요청하기', - }, - { - buttonType: 'BT', // 챗봇 운영시 챗봇 문의로 전환할 수 있습니다. - buttonName: '챗봇 문의', - }, - /*{ - buttonType: 'BC', // 상담톡으로 전환합니다 (상담톡 서비스 사용 시 가능) - buttonName: '상담톡 전환', - },*/ - ], - }, - }, - ], - { - scheduledDate: '2022-12-08 00:00:00', - // allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. - // allowDuplicates: true, - }, - ) - .then(res => console.log(res)); diff --git a/examples/javascript/common/src/kakao/send/send_friendtalk_with_image.js b/examples/javascript/common/src/kakao/send/send_friendtalk_with_image.js deleted file mode 100644 index a58299b..0000000 --- a/examples/javascript/common/src/kakao/send/send_friendtalk_with_image.js +++ /dev/null @@ -1,99 +0,0 @@ -/** - * 카카오 이미지(사진 1장만 가능) 친구톡 발송 예제 - * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 - */ -const path = require('path'); -const {SolapiMessageService} = require('solapi'); -const messageService = new SolapiMessageService( - 'ENTER_YOUR_API_KEY', - 'ENTER_YOUR_API_SECRET', -); - -messageService - .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'KAKAO') - .then(res => res.fileId) - .then(fileId => { - // 단일 발송 예제 - messageService - .sendOne({ - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - imageId: fileId, - }, - }) - .then(res => console.log(res)); - - // 단일 예약 발송 예제 - // 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. - messageService - .sendOneFuture( - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - imageId: fileId, - }, - }, - '2022-02-26 00:00:00', - ) - .then(res => console.log(res)); - - // 여러 메시지 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 - messageService - .send([ - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - imageId: fileId, - }, - }, - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - imageId: fileId, - }, - }, - // 2번째 파라미터 내 항목인 allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. - ]) - .then(res => console.log(res)); - - // 여러 메시지 예약 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 - // 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. - messageService - .send( - [ - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - imageId: fileId, - }, - }, - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - imageId: fileId, - }, - }, - // 3번째 파라미터 항목인 allowDuplicates를 true로 설정하면 중복 수신번호를 허용합니다. - ], - '2022-02-26 00:00:00', - ) - .then(res => console.log(res)); - }); diff --git a/examples/javascript/common/src/kakao/send/send_friendtalk_with_image_and_buttons.js b/examples/javascript/common/src/kakao/send/send_friendtalk_with_image_and_buttons.js deleted file mode 100644 index 1f4fb76..0000000 --- a/examples/javascript/common/src/kakao/send/send_friendtalk_with_image_and_buttons.js +++ /dev/null @@ -1,283 +0,0 @@ -/** - * 버튼을 포함한 카카오 이미지(사진 1장만 가능) 친구톡 발송 예제 - * 버튼은 최대 5개까지 추가할 수 있습니다. - * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 - */ -const path = require('path'); -const {SolapiMessageService} = require('solapi'); -const messageService = new SolapiMessageService( - 'ENTER_YOUR_API_KEY', - 'ENTER_YOUR_API_SECRET', -); - -messageService - .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'KAKAO') - .then(res => res.fileId) - .then(fileId => { - // 단일 발송 예제 - messageService - .sendOne({ - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - imageId: fileId, - buttons: [ - { - buttonType: 'WL', // 웹링크 - buttonName: '버튼 이름', - linkMo: 'https://m.example.com', - linkPc: 'https://example.com', // 생략 가능 - }, - { - buttonType: 'AL', // 앱링크 - buttonName: '실행 버튼', - linkAnd: 'examplescheme://', - linkIos: 'examplescheme://', - }, - { - buttonType: 'BK', // 봇키워드(챗봇에게 키워드를 전달합니다. 버튼이름의 키워드가 그대로 전달됩니다.) - buttonName: '봇키워드', - }, - { - buttonType: 'MD', // 상담요청하기 (상담요청하기 버튼을 누르면 메시지 내용이 상담원에게 그대로 전달됩니다.) - buttonName: '상담요청하기', - }, - { - buttonType: 'BT', // 챗봇 운영시 챗봇 문의로 전환할 수 있습니다. - buttonName: '챗봇 문의', - }, - /*{ - buttonType: 'BC', // 상담톡으로 전환합니다 (상담톡 서비스 사용 시 가능) - buttonName: '상담톡 전환', - },*/ - ], - }, - }) - .then(res => console.log(res)); - - // 단일 예약 발송 예제 - // 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. - messageService - .sendOneFuture( - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - imageId: fileId, - buttons: [ - { - buttonType: 'WL', // 웹링크 - buttonName: '버튼 이름', - linkMo: 'https://m.example.com', - linkPc: 'https://example.com', // 생략 가능 - }, - { - buttonType: 'AL', // 앱링크 - buttonName: '실행 버튼', - linkAnd: 'examplescheme://', - linkIos: 'examplescheme://', - }, - { - buttonType: 'BK', // 봇키워드(챗봇에게 키워드를 전달합니다. 버튼이름의 키워드가 그대로 전달됩니다.) - buttonName: '봇키워드', - }, - { - buttonType: 'MD', // 상담요청하기 (상담요청하기 버튼을 누르면 메시지 내용이 상담원에게 그대로 전달됩니다.) - buttonName: '상담요청하기', - }, - { - buttonType: 'BT', // 챗봇 운영시 챗봇 문의로 전환할 수 있습니다. - buttonName: '챗봇 문의', - }, - /*{ - buttonType: 'BC', // 상담톡으로 전환합니다 (상담톡 서비스 사용 시 가능) - buttonName: '상담톡 전환', - },*/ - ], - }, - }, - '2022-12-08 00:00:00', - ) - .then(res => console.log(res)); - - // 여러 메시지 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 - messageService - .send([ - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - imageId: fileId, - buttons: [ - { - buttonType: 'WL', // 웹링크 - buttonName: '버튼 이름', - linkMo: 'https://m.example.com', - linkPc: 'https://example.com', // 생략 가능 - }, - { - buttonType: 'AL', // 앱링크 - buttonName: '실행 버튼', - linkAnd: 'examplescheme://', - linkIos: 'examplescheme://', - }, - { - buttonType: 'BK', // 봇키워드(챗봇에게 키워드를 전달합니다. 버튼이름의 키워드가 그대로 전달됩니다.) - buttonName: '봇키워드', - }, - { - buttonType: 'MD', // 상담요청하기 (상담요청하기 버튼을 누르면 메시지 내용이 상담원에게 그대로 전달됩니다.) - buttonName: '상담요청하기', - }, - { - buttonType: 'BT', // 챗봇 운영시 챗봇 문의로 전환할 수 있습니다. - buttonName: '챗봇 문의', - }, - /*{ - buttonType: 'BC', // 상담톡으로 전환합니다 (상담톡 서비스 사용 시 가능) - buttonName: '상담톡 전환', - },*/ - ], - }, - }, - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - imageId: fileId, - buttons: [ - { - buttonType: 'WL', // 웹링크 - buttonName: '버튼 이름', - linkMo: 'https://m.example.com', - linkPc: 'https://example.com', // 생략 가능 - }, - { - buttonType: 'AL', // 앱링크 - buttonName: '실행 버튼', - linkAnd: 'examplescheme://', - linkIos: 'examplescheme://', - }, - { - buttonType: 'BK', // 봇키워드(챗봇에게 키워드를 전달합니다. 버튼이름의 키워드가 그대로 전달됩니다.) - buttonName: '봇키워드', - }, - { - buttonType: 'MD', // 상담요청하기 (상담요청하기 버튼을 누르면 메시지 내용이 상담원에게 그대로 전달됩니다.) - buttonName: '상담요청하기', - }, - { - buttonType: 'BT', // 챗봇 운영시 챗봇 문의로 전환할 수 있습니다. - buttonName: '챗봇 문의', - }, - /*{ - buttonType: 'BC', // 상담톡으로 전환합니다 (상담톡 서비스 사용 시 가능) - buttonName: '상담톡 전환', - },*/ - ], - }, - }, - // 2번째 파라미터 내 항목인 allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. - ]) - .then(res => console.log(res)); - - // 여러 메시지 예약 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 - // 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. - messageService - .send( - [ - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - imageId: fileId, - buttons: [ - { - buttonType: 'WL', // 웹링크 - buttonName: '버튼 이름', - linkMo: 'https://m.example.com', - linkPc: 'https://example.com', // 생략 가능 - }, - { - buttonType: 'AL', // 앱링크 - buttonName: '실행 버튼', - linkAnd: 'examplescheme://', - linkIos: 'examplescheme://', - }, - { - buttonType: 'BK', // 봇키워드(챗봇에게 키워드를 전달합니다. 버튼이름의 키워드가 그대로 전달됩니다.) - buttonName: '봇키워드', - }, - { - buttonType: 'MD', // 상담요청하기 (상담요청하기 버튼을 누르면 메시지 내용이 상담원에게 그대로 전달됩니다.) - buttonName: '상담요청하기', - }, - { - buttonType: 'BT', // 챗봇 운영시 챗봇 문의로 전환할 수 있습니다. - buttonName: '챗봇 문의', - }, - /*{ - buttonType: 'BC', // 상담톡으로 전환합니다 (상담톡 서비스 사용 시 가능) - buttonName: '상담톡 전환', - },*/ - ], - }, - }, - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - imageId: fileId, - buttons: [ - { - buttonType: 'WL', // 웹링크 - buttonName: '버튼 이름', - linkMo: 'https://m.example.com', - linkPc: 'https://example.com', // 생략 가능 - }, - { - buttonType: 'AL', // 앱링크 - buttonName: '실행 버튼', - linkAnd: 'examplescheme://', - linkIos: 'examplescheme://', - }, - { - buttonType: 'BK', // 봇키워드(챗봇에게 키워드를 전달합니다. 버튼이름의 키워드가 그대로 전달됩니다.) - buttonName: '봇키워드', - }, - { - buttonType: 'MD', // 상담요청하기 (상담요청하기 버튼을 누르면 메시지 내용이 상담원에게 그대로 전달됩니다.) - buttonName: '상담요청하기', - }, - { - buttonType: 'BT', // 챗봇 운영시 챗봇 문의로 전환할 수 있습니다. - buttonName: '챗봇 문의', - }, - /*{ - buttonType: 'BC', // 상담톡으로 전환합니다 (상담톡 서비스 사용 시 가능) - buttonName: '상담톡 전환', - },*/ - ], - }, - }, - ], - { - scheduledDate: '2022-12-08 00:00:00', - // allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. - // allowDuplicates: true, - }, - ) - .then(res => console.log(res)); - }); From 5b9602ea0b102891455dd165465273af3146d39a Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 15 Jan 2026 15:07:32 +0900 Subject: [PATCH 06/13] feat(kakao): Add new BMS message examples for various types - Introduced multiple new example files for sending Kakao BMS messages, including: - `send_bms_free_carousel_commerce.js`: Example for CAROUSEL_COMMERCE type messages. - `send_bms_free_carousel_feed.js`: Example for CAROUSEL_FEED type messages. - `send_bms_free_commerce.js`: Example for COMMERCE type messages. - `send_bms_free_image_with_buttons.js`: Example for IMAGE type messages with buttons. - `send_bms_free_image.js`: Example for basic IMAGE type messages. - `send_bms_free_premium_video.js`: Example for PREMIUM_VIDEO type messages. - `send_bms_free_text_with_buttons.js`: Example for TEXT type messages with buttons. - `send_bms_free_text.js`: Example for basic TEXT type messages. - `send_bms_free_wide_item_list.js`: Example for WIDE_ITEM_LIST type messages. - `send_bms_free_wide.js`: Example for WIDE type messages. These additions enhance the documentation and provide clear usage examples for developers integrating with the Kakao BMS service. --- .../common/src/kakao/send/send_bms.js | 18 +- .../send/send_bms_free_carousel_commerce.js | 157 ++++++++++++++++++ .../kakao/send/send_bms_free_carousel_feed.js | 136 +++++++++++++++ .../src/kakao/send/send_bms_free_commerce.js | 121 ++++++++++++++ .../src/kakao/send/send_bms_free_image.js | 135 +++++++++++++++ .../send/send_bms_free_image_with_buttons.js | 144 ++++++++++++++++ .../kakao/send/send_bms_free_premium_video.js | 101 +++++++++++ .../src/kakao/send/send_bms_free_text.js | 123 ++++++++++++++ .../send/send_bms_free_text_with_buttons.js | 147 ++++++++++++++++ .../src/kakao/send/send_bms_free_wide.js | 63 +++++++ .../send/send_bms_free_wide_item_list.js | 95 +++++++++++ 11 files changed, 1238 insertions(+), 2 deletions(-) create mode 100644 examples/javascript/common/src/kakao/send/send_bms_free_carousel_commerce.js create mode 100644 examples/javascript/common/src/kakao/send/send_bms_free_carousel_feed.js create mode 100644 examples/javascript/common/src/kakao/send/send_bms_free_commerce.js create mode 100644 examples/javascript/common/src/kakao/send/send_bms_free_image.js create mode 100644 examples/javascript/common/src/kakao/send/send_bms_free_image_with_buttons.js create mode 100644 examples/javascript/common/src/kakao/send/send_bms_free_premium_video.js create mode 100644 examples/javascript/common/src/kakao/send/send_bms_free_text.js create mode 100644 examples/javascript/common/src/kakao/send/send_bms_free_text_with_buttons.js create mode 100644 examples/javascript/common/src/kakao/send/send_bms_free_wide.js create mode 100644 examples/javascript/common/src/kakao/send/send_bms_free_wide_item_list.js diff --git a/examples/javascript/common/src/kakao/send/send_bms.js b/examples/javascript/common/src/kakao/send/send_bms.js index 1c202df..46dad96 100644 --- a/examples/javascript/common/src/kakao/send/send_bms.js +++ b/examples/javascript/common/src/kakao/send/send_bms.js @@ -1,6 +1,20 @@ /** - * 카카오 브랜드 메시지 발송 예제 - * 현재 targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 카카오 브랜드 메시지(템플릿 기반) 발송 예제 + * 이 파일은 templateId를 사용한 템플릿 기반 BMS 발송 예제입니다. + * + * BMS 자유형(템플릿 없이 직접 메시지 구성) 예제는 아래 파일들을 참고하세요: + * - send_bms_free_text.js: TEXT 타입 (텍스트 전용) + * - send_bms_free_text_with_buttons.js: TEXT 타입 + 버튼 + * - send_bms_free_image.js: IMAGE 타입 (이미지 포함) + * - send_bms_free_image_with_buttons.js: IMAGE 타입 + 버튼 + * - send_bms_free_wide.js: WIDE 타입 (와이드 이미지) + * - send_bms_free_wide_item_list.js: WIDE_ITEM_LIST 타입 (와이드 아이템 리스트) + * - send_bms_free_commerce.js: COMMERCE 타입 (상품 메시지) + * - send_bms_free_carousel_feed.js: CAROUSEL_FEED 타입 (캐러셀 피드) + * - send_bms_free_carousel_commerce.js: CAROUSEL_COMMERCE 타입 (캐러셀 커머스) + * - send_bms_free_premium_video.js: PREMIUM_VIDEO 타입 (프리미엄 비디오) + * + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. * 그 외의 모든 채널은 I 타입만 사용 가능합니다. */ const {SolapiMessageService} = require('solapi'); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_carousel_commerce.js b/examples/javascript/common/src/kakao/send/send_bms_free_carousel_commerce.js new file mode 100644 index 0000000..39d2f81 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_carousel_commerce.js @@ -0,0 +1,157 @@ +/** + * 카카오 BMS 자유형 CAROUSEL_COMMERCE 타입 발송 예제 + * 캐러셀 커머스 형식으로, 여러 상품을 슬라이드로 보여주는 구조입니다. + * head + list(상품카드들) + tail 구조입니다. + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// 단일 발송 예제 +// imageId는 미리 업로드한 이미지 ID를 사용합니다. +// 이미지 업로드: messageService.uploadFile(filePath, 'KAKAO').then(res => res.fileId) +messageService + .sendOne({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', // I: 전체, M/N: 인허가 채널만 + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + // head: 캐러셀 상단 대표 이미지 및 설명 (선택) + head: { + header: '이번 주 베스트 상품', + content: '인기 상품을 만나보세요!', + imageId: '업로드한 헤드 이미지 ID', + linkMobile: 'https://m.example.com/best', + linkPc: 'https://example.com/best', // 선택 + }, + // list: 상품 카드 목록 (head 있으면 1-5개, 없으면 2-6개) + list: [ + { + additionalContent: '무료배송', // 부가정보 (선택) + imageId: '업로드한 상품 이미지 ID', + coupon: { + title: '10% 할인 쿠폰', + description: '신규 회원 전용', + linkMobile: 'https://m.example.com/coupon1', + }, + commerce: { + title: '상품명 1', + regularPrice: '30000', + discountPrice: '25000', + discountRate: '17', + }, + buttons: [ + { + linkType: 'WL', + name: '구매하기', + linkMobile: 'https://m.example.com/product1', + }, + ], + }, + { + additionalContent: '오늘 출발', + imageId: '업로드한 상품 이미지 ID', + commerce: { + title: '상품명 2', + regularPrice: '50000', + discountPrice: '40000', + discountRate: '20', + }, + buttons: [ + { + linkType: 'WL', + name: '구매하기', + linkMobile: 'https://m.example.com/product2', + }, + ], + }, + { + imageId: '업로드한 상품 이미지 ID', + commerce: { + title: '상품명 3', + regularPrice: '15000', + }, + buttons: [ + { + linkType: 'WL', + name: '구매하기', + linkMobile: 'https://m.example.com/product3', + }, + ], + }, + ], + // tail: 캐러셀 하단에 "더보기" 링크 (선택) + tail: { + linkMobile: 'https://m.example.com/all-products', + linkPc: 'https://example.com/all-products', // 선택 + }, + }, + }, + }, + }) + .then(res => console.log(res)); + +// head 없이 상품만 발송하는 예제 +messageService + .sendOne({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + list: [ + { + imageId: '업로드한 상품 이미지 ID', + commerce: { + title: '한정 특가 상품 A', + regularPrice: '100000', + discountPrice: '70000', + discountRate: '30', + }, + buttons: [ + { + linkType: 'WL', + name: '바로 구매', + linkMobile: 'https://m.example.com/productA', + }, + ], + }, + { + imageId: '업로드한 상품 이미지 ID', + commerce: { + title: '한정 특가 상품 B', + regularPrice: '80000', + discountPrice: '60000', + discountRate: '25', + }, + buttons: [ + { + linkType: 'WL', + name: '바로 구매', + linkMobile: 'https://m.example.com/productB', + }, + ], + }, + ], + tail: { + linkMobile: 'https://m.example.com/sale', + }, + }, + }, + }, + }) + .then(res => console.log(res)); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_carousel_feed.js b/examples/javascript/common/src/kakao/send/send_bms_free_carousel_feed.js new file mode 100644 index 0000000..2be7133 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_carousel_feed.js @@ -0,0 +1,136 @@ +/** + * 카카오 BMS 자유형 CAROUSEL_FEED 타입 발송 예제 + * 캐러셀 피드 형식으로, 여러 카드를 좌우로 슬라이드하는 구조입니다. + * 각 카드: header, content, imageId, imageLink, coupon, buttons + * head 없이 2-6개 아이템, head 포함 시 1-5개 아이템 가능합니다. + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// 단일 발송 예제 +// imageId는 미리 업로드한 이미지 ID를 사용합니다. +// 이미지 업로드: messageService.uploadFile(filePath, 'KAKAO').then(res => res.fileId) +messageService + .sendOne({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', // I: 전체, M/N: 인허가 채널만 + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + // head 없이 list만 있는 경우 2-6개 아이템 + list: [ + { + header: '첫 번째 카드 헤더', + content: '첫 번째 카드 내용입니다.', + imageId: '업로드한 이미지 ID', + imageLink: 'https://example.com/image1', // 이미지 클릭 시 이동 URL (선택) + coupon: { + title: '10% 할인 쿠폰', + description: '첫 구매 고객 전용', + linkMobile: 'https://m.example.com/coupon1', + }, + buttons: [ + { + linkType: 'WL', // 캐러셀 피드는 WL, AL 버튼만 지원 + name: '자세히 보기', + linkMobile: 'https://m.example.com/detail1', + }, + ], + }, + { + header: '두 번째 카드 헤더', + content: '두 번째 카드 내용입니다.', + imageId: '업로드한 이미지 ID', + coupon: { + title: '5000원 할인 쿠폰', + description: '주말 특가 할인', + linkMobile: 'https://m.example.com/coupon2', + }, + buttons: [ + { + linkType: 'WL', + name: '자세히 보기', + linkMobile: 'https://m.example.com/detail2', + }, + ], + }, + { + header: '세 번째 카드 헤더', + content: '세 번째 카드 내용입니다.', + imageId: '업로드한 이미지 ID', + buttons: [ + { + linkType: 'AL', // 앱링크 버튼 + name: '앱에서 보기', + linkAndroid: 'examplescheme://detail3', + linkIos: 'examplescheme://detail3', + }, + ], + }, + ], + // tail: 캐러셀 하단에 "더보기" 링크 (선택) + tail: { + linkMobile: 'https://m.example.com/more', + linkPc: 'https://example.com/more', // 선택 + }, + }, + }, + }, + }) + .then(res => console.log(res)); + +// 여러 메시지 발송 예제 +messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [ + { + header: '이벤트 1', + content: '특별 이벤트 안내입니다.', + imageId: '업로드한 이미지 ID', + buttons: [ + { + linkType: 'WL', + name: '참여하기', + linkMobile: 'https://m.example.com/event1', + }, + ], + }, + { + header: '이벤트 2', + content: '한정 프로모션 안내입니다.', + imageId: '업로드한 이미지 ID', + buttons: [ + { + linkType: 'WL', + name: '참여하기', + linkMobile: 'https://m.example.com/event2', + }, + ], + }, + ], + }, + }, + }, + }, + ]) + .then(res => console.log(res)); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_commerce.js b/examples/javascript/common/src/kakao/send/send_bms_free_commerce.js new file mode 100644 index 0000000..b125078 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_commerce.js @@ -0,0 +1,121 @@ +/** + * 카카오 BMS 자유형 COMMERCE 타입 발송 예제 + * 커머스(상품) 메시지로, 상품 이미지와 가격 정보, 쿠폰을 포함합니다. + * 이미지 + 상품정보(commerce) + 쿠폰(coupon) + 버튼 조합입니다. + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// 단일 발송 예제 +// imageId는 미리 업로드한 이미지 ID를 사용합니다. +// 이미지 업로드: messageService.uploadFile(filePath, 'KAKAO').then(res => res.fileId) +messageService + .sendOne({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', // I: 전체, M/N: 인허가 채널만 + chatBubbleType: 'COMMERCE', + imageId: '업로드한 상품 이미지 ID', + commerce: { + title: '상품명', + regularPrice: '10000', // 정가 + discountPrice: '8000', // 할인가 (선택) + discountRate: '20', // 할인율 % (선택) + discountFixed: '2000', // 할인금액 (선택) + }, + // 쿠폰 정보 (선택) + // 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" + coupon: { + title: '10000원 할인 쿠폰', + description: '신규 회원 전용 할인 쿠폰입니다.', + linkMobile: 'https://m.example.com/coupon', + linkPc: 'https://example.com/coupon', // 선택 + }, + buttons: [ + { + linkType: 'WL', + name: '상품 보기', + linkMobile: 'https://m.example.com/product', + linkPc: 'https://example.com/product', // 선택 + }, + { + linkType: 'WL', + name: '바로 구매', + linkMobile: 'https://m.example.com/buy', + }, + ], + }, + }, + }) + .then(res => console.log(res)); + +// 쿠폰 없이 상품 정보만 발송하는 예제 +messageService + .sendOne({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + imageId: '업로드한 상품 이미지 ID', + commerce: { + title: '한정 특가 상품', + regularPrice: '50000', + discountPrice: '35000', + discountRate: '30', + }, + buttons: [ + { + linkType: 'WL', + name: '상품 상세보기', + linkMobile: 'https://m.example.com/detail', + }, + ], + }, + }, + }) + .then(res => console.log(res)); + +// 여러 메시지 발송 예제 +messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + imageId: '업로드한 상품 이미지 ID', + commerce: { + title: '베스트 셀러 상품', + regularPrice: '25000', + discountPrice: '20000', + }, + buttons: [ + { + linkType: 'WL', + name: '구매하기', + linkMobile: 'https://m.example.com/buy', + }, + ], + }, + }, + }, + ]) + .then(res => console.log(res)); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_image.js b/examples/javascript/common/src/kakao/send/send_bms_free_image.js new file mode 100644 index 0000000..77d4b0b --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_image.js @@ -0,0 +1,135 @@ +/** + * 카카오 BMS 자유형 IMAGE 타입 발송 예제 + * 이미지 업로드 후 imageId를 사용하여 발송합니다. + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const path = require('path'); +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +messageService + .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'KAKAO') + .then(res => res.fileId) + .then(fileId => { + // 단일 발송 예제 + messageService + .sendOne({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', // I: 전체, M/N: 인허가 채널만 + chatBubbleType: 'IMAGE', + imageId: fileId, + }, + }, + }) + .then(res => console.log(res)); + + // 단일 예약 발송 예제 + // 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. + messageService + .sendOneFuture( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + imageId: fileId, + }, + }, + }, + '2025-12-08 00:00:00', + ) + .then(res => console.log(res)); + + // 여러 메시지 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 + messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + imageId: fileId, + }, + }, + }, + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + imageId: fileId, + }, + }, + }, + // 2번째 파라미터 내 항목인 allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. + ]) + .then(res => console.log(res)); + + // 여러 메시지 예약 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 + // 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. + messageService + .send( + [ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + imageId: fileId, + }, + }, + }, + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + imageId: fileId, + }, + }, + }, + ], + { + scheduledDate: '2025-12-08 00:00:00', + // allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. + // allowDuplicates: true, + }, + ) + .then(res => console.log(res)); + }); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_image_with_buttons.js b/examples/javascript/common/src/kakao/send/send_bms_free_image_with_buttons.js new file mode 100644 index 0000000..8227254 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_image_with_buttons.js @@ -0,0 +1,144 @@ +/** + * 버튼을 포함한 카카오 BMS 자유형 IMAGE 타입 발송 예제 + * 이미지 업로드 후 imageId를 사용하여 버튼과 함께 발송합니다. + * BMS 자유형 버튼 타입: WL(웹링크), AL(앱링크), AC(채널추가), BK(봇키워드), MD(상담요청), BC(상담톡전환), BT(챗봇전환), BF(비즈니스폼) + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const path = require('path'); +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +messageService + .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'KAKAO') + .then(res => res.fileId) + .then(fileId => { + // 단일 발송 예제 + messageService + .sendOne({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', // I: 전체, M/N: 인허가 채널만 + chatBubbleType: 'IMAGE', + imageId: fileId, + buttons: [ + { + linkType: 'WL', // 웹링크 + name: '버튼 이름', + linkMobile: 'https://m.example.com', + linkPc: 'https://example.com', // 생략 가능 + }, + { + linkType: 'AL', // 앱링크 + name: '앱 실행', + linkAndroid: 'examplescheme://', + linkIos: 'examplescheme://', + }, + { + linkType: 'BK', // 봇키워드 + name: '봇키워드', + chatExtra: '추가 데이터', // 선택 + }, + { + linkType: 'MD', // 상담요청하기 + name: '상담요청하기', + chatExtra: '추가 데이터', // 선택 + }, + { + linkType: 'BT', // 챗봇 문의 + name: '챗봇 문의', + chatExtra: '추가 데이터', // 선택 + }, + ], + }, + }, + }) + .then(res => console.log(res)); + + // 단일 예약 발송 예제 + // 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. + messageService + .sendOneFuture( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + imageId: fileId, + buttons: [ + { + linkType: 'WL', + name: '버튼 이름', + linkMobile: 'https://m.example.com', + }, + ], + }, + }, + }, + '2025-12-08 00:00:00', + ) + .then(res => console.log(res)); + + // 여러 메시지 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 + messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + imageId: fileId, + buttons: [ + { + linkType: 'WL', + name: '버튼 이름', + linkMobile: 'https://m.example.com', + }, + ], + }, + }, + }, + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + imageId: fileId, + buttons: [ + { + linkType: 'WL', + name: '버튼 이름', + linkMobile: 'https://m.example.com', + }, + ], + }, + }, + }, + // 2번째 파라미터 내 항목인 allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. + ]) + .then(res => console.log(res)); + }); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_premium_video.js b/examples/javascript/common/src/kakao/send/send_bms_free_premium_video.js new file mode 100644 index 0000000..ab70af4 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_premium_video.js @@ -0,0 +1,101 @@ +/** + * 카카오 BMS 자유형 PREMIUM_VIDEO 타입 발송 예제 + * 프리미엄 비디오 메시지로, 카카오TV 영상 URL과 썸네일 이미지를 포함합니다. + * video: { videoUrl, imageId, imageLink } 구조입니다. + * videoUrl은 반드시 "https://tv.kakao.com/"으로 시작해야 합니다. + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// 단일 발송 예제 +// imageId는 미리 업로드한 이미지 ID를 사용합니다. +// 이미지 업로드: messageService.uploadFile(filePath, 'KAKAO').then(res => res.fileId) +messageService + .sendOne({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '동영상 메시지입니다. 아래 영상을 확인해보세요!', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', // I: 전체, M/N: 인허가 채널만 + chatBubbleType: 'PREMIUM_VIDEO', + video: { + // videoUrl은 반드시 카카오TV URL이어야 합니다 + videoUrl: 'https://tv.kakao.com/v/123456789', + imageId: '업로드한 썸네일 이미지 ID', // 선택 (영상 썸네일) + imageLink: 'https://example.com/video-detail', // 선택 (이미지 클릭 시 이동 URL) + }, + }, + }, + }) + .then(res => console.log(res)); + +// 버튼이 포함된 프리미엄 비디오 발송 예제 +messageService + .sendOne({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '신제품 소개 영상입니다.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'PREMIUM_VIDEO', + video: { + videoUrl: 'https://tv.kakao.com/v/123456789', + imageId: '업로드한 썸네일 이미지 ID', + }, + buttons: [ + { + linkType: 'WL', + name: '제품 상세보기', + linkMobile: 'https://m.example.com/product', + }, + { + linkType: 'WL', + name: '구매하기', + linkMobile: 'https://m.example.com/buy', + }, + ], + }, + }, + }) + .then(res => console.log(res)); + +// 여러 메시지 발송 예제 +messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '이벤트 홍보 영상입니다.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'PREMIUM_VIDEO', + video: { + videoUrl: 'https://tv.kakao.com/v/123456789', + }, + buttons: [ + { + linkType: 'WL', + name: '이벤트 참여하기', + linkMobile: 'https://m.example.com/event', + }, + ], + }, + }, + }, + ]) + .then(res => console.log(res)); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_text.js b/examples/javascript/common/src/kakao/send/send_bms_free_text.js new file mode 100644 index 0000000..e045c31 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_text.js @@ -0,0 +1,123 @@ +/** + * 카카오 BMS 자유형 TEXT 타입 발송 예제 + * 텍스트만 포함하는 가장 기본적인 BMS 자유형 메시지입니다. + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// 단일 발송 예제 +messageService + .sendOne({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', // I: 전체, M/N: 인허가 채널만 + chatBubbleType: 'TEXT', + }, + }, + }) + .then(res => console.log(res)); + +// 단일 예약 발송 예제 +// 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. +messageService + .sendOneFuture( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + }, + }, + }, + '2025-12-08 00:00:00', + ) + .then(res => console.log(res)); + +// 여러 메시지 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 +messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + }, + }, + }, + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + }, + }, + }, + // 2번째 파라미터 내 항목인 allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. + ]) + .then(res => console.log(res)); + +// 여러 메시지 예약 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 +// 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. +messageService + .send( + [ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + }, + }, + }, + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + }, + }, + }, + ], + { + scheduledDate: '2025-12-08 00:00:00', + // allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. + // allowDuplicates: true, + }, + ) + .then(res => console.log(res)); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_text_with_buttons.js b/examples/javascript/common/src/kakao/send/send_bms_free_text_with_buttons.js new file mode 100644 index 0000000..ecbda94 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_text_with_buttons.js @@ -0,0 +1,147 @@ +/** + * 버튼을 포함한 카카오 BMS 자유형 TEXT 타입 발송 예제 + * BMS 자유형 버튼 타입: WL(웹링크), AL(앱링크), AC(채널추가), BK(봇키워드), MD(상담요청), BC(상담톡전환), BT(챗봇전환), BF(비즈니스폼) + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// 단일 발송 예제 +messageService + .sendOne({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', // I: 전체, M/N: 인허가 채널만 + chatBubbleType: 'TEXT', + buttons: [ + { + linkType: 'WL', // 웹링크 + name: '버튼 이름', + linkMobile: 'https://m.example.com', + linkPc: 'https://example.com', // 생략 가능 + }, + { + linkType: 'AL', // 앱링크 + name: '앱 실행', + linkAndroid: 'examplescheme://', + linkIos: 'examplescheme://', + }, + { + linkType: 'BK', // 봇키워드 (챗봇에게 키워드를 전달합니다) + name: '봇키워드', + chatExtra: '추가 데이터', // 선택 + }, + { + linkType: 'MD', // 상담요청하기 + name: '상담요청하기', + chatExtra: '추가 데이터', // 선택 + }, + { + linkType: 'BT', // 챗봇 문의로 전환 + name: '챗봇 문의', + chatExtra: '추가 데이터', // 선택 + }, + /* + { + linkType: 'AC', // 채널 추가 + }, + { + linkType: 'BC', // 상담톡 전환 (상담톡 서비스 사용 시 가능) + name: '상담톡 전환', + chatExtra: '추가 데이터', // 선택 + }, + { + linkType: 'BF', // 비즈니스폼 + name: '비즈니스폼', + }, + */ + ], + }, + }, + }) + .then(res => console.log(res)); + +// 단일 예약 발송 예제 +// 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. +messageService + .sendOneFuture( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + buttons: [ + { + linkType: 'WL', + name: '버튼 이름', + linkMobile: 'https://m.example.com', + }, + ], + }, + }, + }, + '2025-12-08 00:00:00', + ) + .then(res => console.log(res)); + +// 여러 메시지 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 +messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + buttons: [ + { + linkType: 'WL', + name: '버튼 이름', + linkMobile: 'https://m.example.com', + }, + ], + }, + }, + }, + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + buttons: [ + { + linkType: 'WL', + name: '버튼 이름', + linkMobile: 'https://m.example.com', + }, + ], + }, + }, + }, + // 2번째 파라미터 내 항목인 allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. + ]) + .then(res => console.log(res)); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_wide.js b/examples/javascript/common/src/kakao/send/send_bms_free_wide.js new file mode 100644 index 0000000..170f959 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_wide.js @@ -0,0 +1,63 @@ +/** + * 카카오 BMS 자유형 WIDE 타입 발송 예제 + * 와이드 이미지 형식으로, 기본 IMAGE 타입보다 넓은 이미지를 표시합니다. + * 와이드 이미지는 별도의 규격이 필요합니다. + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const path = require('path'); +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// 와이드 이미지 업로드 (800x600 권장) +messageService + .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'KAKAO') + .then(res => res.fileId) + .then(fileId => { + // 단일 발송 예제 + messageService + .sendOne({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', // I: 전체, M/N: 인허가 채널만 + chatBubbleType: 'WIDE', + imageId: fileId, + }, + }, + }) + .then(res => console.log(res)); + + // 버튼이 포함된 와이드 이미지 발송 예제 + messageService + .sendOne({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '2,000byte 이내의 메시지 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE', + imageId: fileId, + buttons: [ + { + linkType: 'WL', + name: '자세히 보기', + linkMobile: 'https://m.example.com', + }, + ], + }, + }, + }) + .then(res => console.log(res)); + }); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_wide_item_list.js b/examples/javascript/common/src/kakao/send/send_bms_free_wide_item_list.js new file mode 100644 index 0000000..9643722 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_wide_item_list.js @@ -0,0 +1,95 @@ +/** + * 카카오 BMS 자유형 WIDE_ITEM_LIST 타입 발송 예제 + * 와이드 아이템 리스트 형식으로, 메인 와이드 아이템과 서브 와이드 아이템 목록을 표시합니다. + * header + mainWideItem + subWideItemList 구조입니다. + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// 단일 발송 예제 +// imageId는 미리 업로드한 이미지 ID를 사용합니다. +// 이미지 업로드: messageService.uploadFile(filePath, 'KAKAO').then(res => res.fileId) +messageService + .sendOne({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', // I: 전체, M/N: 인허가 채널만 + chatBubbleType: 'WIDE_ITEM_LIST', + header: '헤더 텍스트 (최대 25자)', + mainWideItem: { + title: '메인 와이드 아이템 타이틀 (최대 25자)', // 선택 + imageId: '업로드한 메인 와이드 이미지 ID', + linkMobile: 'https://m.example.com', + linkPc: 'https://example.com', // 선택 + // linkAndroid: 'examplescheme://', // 선택 + // linkIos: 'examplescheme://', // 선택 + }, + subWideItemList: [ + { + title: '서브 와이드 첫번째 아이템 (최대 30자)', + imageId: '업로드한 서브 와이드 이미지 ID', + linkMobile: 'https://m.example.com/item1', + linkPc: 'https://example.com/item1', // 선택 + }, + { + title: '서브 와이드 두번째 아이템 (최대 30자)', + imageId: '업로드한 서브 와이드 이미지 ID', + linkMobile: 'https://m.example.com/item2', + linkPc: 'https://example.com/item2', // 선택 + }, + { + title: '서브 와이드 세번째 아이템 (최대 30자)', + imageId: '업로드한 서브 와이드 이미지 ID', + linkMobile: 'https://m.example.com/item3', + }, + ], + }, + }, + }) + .then(res => console.log(res)); + +// 여러 메시지 발송 예제 +messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE_ITEM_LIST', + header: '신상품 모음', + mainWideItem: { + title: '이번 주 베스트 상품', + imageId: '업로드한 메인 이미지 ID', + linkMobile: 'https://m.example.com/best', + }, + subWideItemList: [ + { + title: '추천 상품 1', + imageId: '업로드한 서브 이미지 ID', + linkMobile: 'https://m.example.com/item1', + }, + { + title: '추천 상품 2', + imageId: '업로드한 서브 이미지 ID', + linkMobile: 'https://m.example.com/item2', + }, + ], + }, + }, + }, + ]) + .then(res => console.log(res)); From 427481119d8c369de11b066c4d885a4067409bd6 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 19 Jan 2026 22:50:31 +0900 Subject: [PATCH 07/13] feat(bms): Enhance error handling and add BMS message types - Introduced a new function `extractDefectInfo` to improve error reporting for unexpected defects in the effect error handler. - Updated `formatCauseForProduction` to include defect information in error messages. - Enhanced `runSafeSync` and `runSafePromise` to throw more descriptive errors, including specific names for unexpected defects and unhandled exits. - Expanded the `FileType` in `groupMessageRequest.ts` to support additional BMS types. - Added utility functions and test cases for BMS message types, including new test assets for image uploads. These changes improve the robustness of error handling and expand the capabilities of the BMS messaging service. --- src/lib/effectErrorHandler.ts | 119 +- .../requests/messages/groupMessageRequest.ts | 13 +- test/assets/example-1to1.jpg | Bin 0 -> 37447 bytes test/assets/example-2to1.jpg | Bin 0 -> 46454 bytes test/lib/bms-test-utils.ts | 283 ++++ test/services/messages/bms-free.e2e.test.ts | 1188 +++++++++++++++++ 6 files changed, 1586 insertions(+), 17 deletions(-) create mode 100644 test/assets/example-1to1.jpg create mode 100644 test/assets/example-2to1.jpg create mode 100644 test/lib/bms-test-utils.ts create mode 100644 test/services/messages/bms-free.e2e.test.ts diff --git a/src/lib/effectErrorHandler.ts b/src/lib/effectErrorHandler.ts index 055c301..8f7443a 100644 --- a/src/lib/effectErrorHandler.ts +++ b/src/lib/effectErrorHandler.ts @@ -41,6 +41,42 @@ export const formatError = (error: unknown): string => { return String(error); }; +/** + * Defect(예측되지 않은 에러)에서 정보 추출 + */ +const extractDefectInfo = ( + defect: unknown, +): {summary: string; details: string} => { + // Effect Tagged Error인 경우 + if (defect && typeof defect === 'object' && '_tag' in defect) { + const tag = (defect as {_tag: string})._tag; + const message = + 'message' in defect ? String((defect as {message: unknown}).message) : ''; + return { + summary: `${tag}${message ? `: ${message}` : ''}`, + details: `Tagged Error [${tag}]: ${JSON.stringify(defect, null, 2)}`, + }; + } + + // 일반 객체인 경우 + if (defect !== null && typeof defect === 'object') { + const keys = Object.keys(defect); + const summary = + keys.length > 0 + ? `Object with keys: ${keys.slice(0, 3).join(', ')}${keys.length > 3 ? '...' : ''}` + : 'Empty object'; + return { + summary, + details: JSON.stringify(defect, null, 2), + }; + } + + return { + summary: String(defect), + details: `Value (${typeof defect}): ${String(defect)}`, + }; +}; + // Effect Cause를 프로덕션용으로 포맷팅 export const formatCauseForProduction = ( cause: Cause.Cause, @@ -49,7 +85,16 @@ export const formatCauseForProduction = ( if (failure._tag === 'Some') { return formatError(failure.value); } - return 'Unknown error occurred'; + + // Defect 정보도 포함 + const defects = Cause.defects(cause); + if (defects.length > 0) { + const firstDefect = Chunk.unsafeGet(defects, 0); + const info = extractDefectInfo(firstDefect); + return `Unexpected error: ${info.summary}`; + } + + return 'Effect execution failed'; }; // Effect 프로그램의 실행 결과를 안전하게 처리 @@ -62,6 +107,7 @@ export const runSafeSync = (effect: Effect.Effect): A => { if (failure._tag === 'Some') { throw toCompatibleError(failure.value); } + // 예측되지 않은 예외(Defect)인지 확인 const defects = Cause.defects(cause); if (defects.length > 0) { const firstDefect = Chunk.unsafeGet(defects, 0); @@ -69,12 +115,22 @@ export const runSafeSync = (effect: Effect.Effect): A => { throw firstDefect; } const isProduction = process.env.NODE_ENV === 'production'; + const defectInfo = extractDefectInfo(firstDefect); const message = isProduction - ? `Unexpected error: ${String(firstDefect)}` - : `Unexpected error: ${String(firstDefect)}\nCause: ${Cause.pretty(cause)}`; - throw new Error(message); + ? `Unexpected error: ${defectInfo.summary}` + : `Unexpected error: ${defectInfo.details}\nCause: ${Cause.pretty(cause)}`; + const error = new Error(message); + error.name = 'UnexpectedDefectError'; + throw error; } - throw new Error(`Unhandled Exit: ${Cause.pretty(cause)}`); + // 그 외 (예: 중단)의 경우 + const isProduction = process.env.NODE_ENV === 'production'; + const message = isProduction + ? 'Effect execution failed unexpectedly' + : `Unhandled Effect Exit:\n${Cause.pretty(cause)}`; + const error = new Error(message); + error.name = 'UnhandledExitError'; + throw error; }, onSuccess: value => value, }); @@ -98,21 +154,26 @@ export const runSafePromise = ( if (defects.length > 0) { const firstDefect = Chunk.unsafeGet(defects, 0); if (firstDefect instanceof Error) { - // 원본 Error 객체를 그대로 반환 return Promise.reject(firstDefect); } - // Error 객체가 아니면 환경에 따라 상세 정보 포함 const isProduction = process.env.NODE_ENV === 'production'; + const defectInfo = extractDefectInfo(firstDefect); const message = isProduction - ? `Unexpected error: ${String(firstDefect)}` - : `Unexpected error: ${String(firstDefect)}\nCause: ${Cause.pretty(cause)}`; - return Promise.reject(new Error(message)); + ? `Unexpected error: ${defectInfo.summary}` + : `Unexpected error: ${defectInfo.details}\nCause: ${Cause.pretty(cause)}`; + const error = new Error(message); + error.name = 'UnexpectedDefectError'; + return Promise.reject(error); } - // 3. 그 외 (예: 중단)의 경우, Cause를 문자열로 변환하여 반환 - return Promise.reject( - new Error(`Unhandled Exit: ${Cause.pretty(cause)}`), - ); + // 3. 그 외 (예: 중단)의 경우 + const isProduction = process.env.NODE_ENV === 'production'; + const message = isProduction + ? 'Effect execution failed unexpectedly' + : `Unhandled Effect Exit:\n${Cause.pretty(cause)}`; + const error = new Error(message); + error.name = 'UnhandledExitError'; + return Promise.reject(error); }, onSuccess: value => Promise.resolve(value), }), @@ -325,10 +386,36 @@ export const toCompatibleError = (effectError: unknown): Error => { return error; } + // Unknown 에러 타입에 대한 개선된 처리 + // Tagged Error 확인 (_tag 속성 존재 여부) + if (effectError && typeof effectError === 'object' && '_tag' in effectError) { + const taggedError = effectError as {_tag: string}; + const formatted = formatError(effectError); + const error = new Error(formatted); + error.name = `UnknownTaggedError_${taggedError._tag}`; + if (!isProduction) { + Object.defineProperty(error, 'originalError', { + value: effectError, + writable: false, + enumerable: true, + }); + } + if (isProduction) { + delete error.stack; + } + return error; + } + const formatted = formatError(effectError); - // 하위 호환성을 위해 여전히 Error 사용하지만 스택 제거 const error = new Error(formatted); - error.name = 'FromSolapiError'; + error.name = 'UnknownSolapiError'; + if (!isProduction) { + Object.defineProperty(error, 'originalError', { + value: effectError, + writable: false, + enumerable: true, + }); + } if (isProduction) { delete error.stack; } diff --git a/src/models/requests/messages/groupMessageRequest.ts b/src/models/requests/messages/groupMessageRequest.ts index 8614326..f8eebdf 100644 --- a/src/models/requests/messages/groupMessageRequest.ts +++ b/src/models/requests/messages/groupMessageRequest.ts @@ -41,7 +41,18 @@ export type FileIds = { fileIds: ReadonlyArray; }; -export type FileType = 'KAKAO' | 'MMS' | 'DOCUMENT' | 'RCS' | 'FAX'; +export type FileType = + | 'KAKAO' + | 'MMS' + | 'DOCUMENT' + | 'RCS' + | 'FAX' + | 'BMS' + | 'BMS_WIDE' + | 'BMS_WIDE_MAIN_ITEM_LIST' + | 'BMS_WIDE_SUB_ITEM_LIST' + | 'BMS_CAROUSEL_FEED_LIST' + | 'BMS_CAROUSEL_COMMERCE_LIST'; export type FileUploadRequest = { file: string; diff --git a/test/assets/example-1to1.jpg b/test/assets/example-1to1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..83e3701f3226108a45219daf5c9aae34d25e35f2 GIT binary patch literal 37447 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 L;=mcy;nAA_1(4wX literal 0 HcmV?d00001 diff --git a/test/assets/example-2to1.jpg b/test/assets/example-2to1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ae7ab5bb57e1bc5d40d3b7cbdfc0b7794ffb4156 GIT binary patch literal 46454 zcmdqIcU)85wk{l`tAHp?1cIPc>AfX3KzfrR2oVrzA|kyd3L>2d3MehoJ3;A4uL?-- zorGQ!YJilR_uXgjeb4#b-~G=0%0J&o=E_X6vS!AbbB*zgXN*OfBrO82XlrO`04OK` zfJfv9fV2eARrhm#0RZUf0Ym@*03CoL+!;Vc?vZx^aFQDUK>3XVKuLb3`0KN87ykJz z<@z_ufA%R3|JsqX15h+{a`$rgbaH>qDZ!>_W&wp>Z>=U?$KN`vZdwoV3B?opGn7m|9dN|@et;g zjNO|M`ipGr9M?H-3kV7ci^$5!D<~={KX|CFq4`Kl+vKsSnYqOiOZ(>zFC3kmT|B+K zeSH1=147@2eF%?;j7s?QIWZ~u%h!~w?3~=Z{DQ)w9~G5V)it$s^=<7Pon76(e)kNI zjE;>@OioQNq0q}Kt842Uo7nw>!=vL9-09h0dXdlPKhz>`|DoA`(Tka^*99snN-EmF z^rE=n`s7tKFNSi)^=Kmu_SKQtfZe z{(Fjr{NK{-UyA)(uNlAtz=gks^1=m5YD!8^k^7G0i`b$^-kA*ZxUY20eG~hBN1$i-1G6TQ>0zNBB2=LFAWB*nw zM~S8u9N7dOB69dKSz+g!mR=qPJ`og6yUwloo09~n+zI1Z&GR&h{+to9aOp##O^MU6 z`R5cZU-ySl7;(1wN|ieYF7-YkEr{IQ1I6T{l;s); z4GmoR>^v;km3l^n$UEW|Os&@h@hR1aDtIRea7hCHbnjitN|E3~-S90v4X%JC+Kt+Y z@wp9uTHeK|z3dK-S~ny75&j6oy7oo8mrzWpZ{x9=M*{3?WRn012ze(&4PtwvQ2)-+ zoY_I!$+M-*v68%Mk>E)hBV0V>0SMV~6 zu!-K-PH-a(v21^b?VeSxrNQs6tn-vmyg>zPV)C1Pru)L}*O3E>gpYecid~Sf1y;QI zlCjdGlx>?C_+9(p_i(#0A2Ft56vh&`4ZeX3?7ou!aO~y$upMo7d#m6qQEqDx&B=s| zcin|8d+LOid&A~DFi;cY(DNN35@5M|;j?+G>$xU$q>1@H^kxuKs!^C!J^F5=PA1%l zy`Kd5;A*8uXoAw;*|JAR2RpW!r*o=V=_g6%6P6dw-W?)slCL~l%de~{Lwx;X(mp%g z|JYfin3&!pgXf#UGu0FzZ(E0%;NFv}$xq zpPT7^J};54(bC&q&QAp)8q>SAwDLP5Z}D79?(~z9?wmHG>w}NpENR1o6zVL7|9@$; zq{09BLYEezcz+OuA14%es|>)wT&iN{V(Z4=hwgBy*>?lw!FkDb9Y?mkUEq4JXV8Ev2Eurty_cr1|);scLGF(?GHyyQD_dA;iJDP ze`yuwJvqHZR74KD;1KAV4^qfY$t+tr#iUIM1IgE#gyZ}J53~2 zQ>_@q^1Y5gP^`gr>A9L;OG8TYyUO+4AmZ^KhP1D02d_*JZy5YTp@OTyGqZt940Y zCa_fz#Dj~@#Es7>-z#|{SJ|hg`s?1>Y?H_$A&UfX9-xf}b{K@b*(?`Ou=Wc$5)Wlu zzV8cOgO>(-IjePB9tk_PkWPfHS=5Eef}0!vOx{^M7vtKedXqq@jOVDhej;m`?+;#~U`jSSu9GCrriCaUFWc z?QQkYPR6tRk_3tfG|%p`(Anr{K_&86=D(#GQRz&A!lnx1SVF>0UB-&0?at1mLZdVmdNH{1YbrZk#Ne$rXnED&r z%JF(R;*Cp$2b%7u0bxVIi0dXeN!FF|a9K-`zW5&*6gZS+^&;8P!B3V*j3^tuu^_DeK5710vF;WNouiAB9?9-YEJoDlL$BbNl zw5+I@9UF(JuN&8FfWv6KwI0i^vv$eGZ+(XRIj5jcNN7iM1+iGEai}s7d1;>K;hEa< zcwgyc*qwE$Nw?1Df-T@__ErEQ3~{51{#gt@3CdfovyNSNSZTfu)IOI4C>xKDddTGM z(aUXk1>aH}kX~6XHJ{e3dRQcZPuE`HN8^aX(CK+fiXbb@!6WQkk$3Ij9%w*?VxcEB zjc_Gc)VTK;gY(SOy#o@)Id*_3>em;h&92Jwk^mDAuXooOrJNxzWY$$xH-?oW`E(+z zLK1|$_~)$n8cV>}?3VK72;P60mpi>;NaE4EsNjtrLzw3poTwcGrpEi>l&}oU4cur3 z1tZ@UX0f(gQ)(2kJ+=7a@leX1eKpr*I6Jf#0#J2r(z9wcV>@9On7(5;rOQ;{`X-HV z4=HId^`MjIihz44j9pc8DHgH0^J;}N_S^CPY(6kO@C#IC!x%A30%#H)@6^Hs%18i@ z$V9>E9HnF;HO&I?5%fJ$+Zzh&MoG*RFYUvtru;ln4xgWUQpM)m{D3=7@p!B=fvU1; zcY1UEoQFryn(cecxT~*N^O8pAo`lXuW_M}kv8*=sfv?$$xQhmm0A@=0OUIyq#aU5_ zT`dI?;O$QX65uvyFRQ_zg^c#iTY(X6a+vT~*_o*v#VeItmrvCi3VoV_zuWYeanP82 z3}7B^tT)Xh!zm>HGPcf#p8@O`k+&p1tOxE$ihAY}?BqeVQFs4)%|vG7E>l(k#6@2D zC4(pIbrL4uJU#^8H}P?c1mIGY$YYDvW-8Q{=?RKoBcKV?uKF=+nCxiwFN5EZW?E2~ z$Btv%5!HmDRoQtYn;f^!722&R9NH2$h33}%VbKuq(vj#d4k4bsBz{3kjmpKEn||yq zw9)bEZiurwod{MfGeG%(?Ag0M+v9~4wla^2`B<4&W&I<><2VidV)i{oP4{Gexz+;>n7~p{5Au^$$tk&W4_*n)uRH{>FNee1C#Ez(B%bWRt$W`aqe*T68 z_;Lf5?r9F@YH$aiWxKzEO-6&yn8E!GZ=%*Mly$Z0-#(6){D;?!7EBOu92|)eMq3%RWKQ%UEx>V+$90_Zo%++ z&VpTVpaAVO`ei^zzTAqeA!d{W`0VlLyN=6`^QoO<5`ZVj>IM^Ne%xViUlRPq7GIoS zLHO3CdHexBdK6_d-w<`07u9dUY2f8Oj8c6GTR}tfr_6v{s@Ht(&sHAC=!5+B!E%2B zXuhKMF1yll`s7pfdF+Q%)R6$s7bn(AMH2Ew9`Vedol@#5X3;b`55ut>54q&zH; zloJe^Seu8V;jHU-!u3XTAL_?fxEth+|3W?;Yy!JTxwoj5@~+6w8VU}30g$$ z+zM8^tfyR@l2+CbKBV>0*+nlTO6c2#|CbvRY51QmOE(H|BNyrh9jiG3azSPN6po(^ z<=E%7^10gZ`wh%iM}LkvPgFav=ki82srsV1fh{1$aaHHC9=`ISot)aw8PZo?XV1m4 zFfK>oz{k><{zB9xiL1n+CMjL=`e!a%PT-#EiH;jw^y;?4=KX6VfcS(M`0P{+L8!aYJKjAb?~^pn_R(TWqugpni|P_%%3ElL1lWxQ}FXpE7GcD%^fHQI#*Ec6_9{5z9(l`loTF(mbhz=lam| z*zo%?(oD-P0mpm623$f}QGkE6xO^6Xp&v2?<^-N;C)+4`Z!0)0PW*R}6-vd1tK&+@N@IF~fzIYc35 zA@s?yvzB?T)_o>N1=q7%sWqu)U>|829mv1}UN(Fu4mbdkOgl*b`4W5T?>$2f;UPPE zOTuObES68Ujw9}QsHJ4i?1&ZW0^i)&(EK@jrVANbh!Ad1;Y!%oBYxM}bKLduP!GC< z2_#-*z+|IXJCqe-m)xmcDy~F~)TVT4ssaQ790w$T9;@cL=}E(z?mMn`^tric%uGJE z9P-A|fhrYeO!n^_gnLZ6L2Th!OA=~`>?wRIP@Uy*hLDju3*hhej@&Wfk&WjRW>J>p zExb;})4?NrXrjx!NR%afAyWF`RB)lKJ{Oi+flgKM?N2c$3p8y86LKr`=fZv!t+!Wk zf>`$s9uA|PClKXL`KMktfXT(lcJr#JvniZYeyl?b!So$4H}K1=+npmY z?g_*ZtfO%C>nncU7Z-&|06F=M=AZN7b1YUE-%g2e#1*%sgH{{9^6@P9)8krA+l)?` zTA-b1!yJSOS8*(lqei(Vjh1-PV8-+Ots2=Jzk9hGMg%pf$=lWWL7AYVKnAcsCKdG? z%kcTZ%1DC|ZN^3T61#BSnU^A@ttp(L%P_z+tPyxRxs15%K*kBP*jG$d7BeJ(V7T;Y z)wM%@k4_;u%tLS$Z5Ig;o@s|$!bIULGut;axIenE;fUuCbAG^!DYsoGi9qN$2~a@- zbT}uBsAIjWa?fA8$uW$Z6vMnqTJM2(KS3UR=*Luf#f;$(@dFavEp@pZ%00=d?9-tp z@IS3TgSLZ3)zw)r1Dw5=BAGut5dOtK8TIKk^6{xa{Uvn)XV1GKQM#*1Z!ZdE{ksWE zQ&VA605Te`K#TFi#*2TDM^>M!!(fqc$TMX`Thl8ntXriw;n;=+{tF&|9De-`c*-Ue zyIL=_l&==QvihX+vIt?hYG?F}AA+?BT`Eu|yx8HTRe7BD6IP%738I2aM*rDqb%kA? zj`ms?NL}^ZW&eivw4wG7^euR*Hg-7RA#>zw_vbXF)TWWEo_XA|Vo`=i3~7eV-9>hc zj>(l;S*6XoOuVsv6!`NR{M)*yc$Da%_B8*Bqk^En+Wjb@JoW#b6~9*9IubuI{P}BiCkRqA2Wq%k2TP@#nh2-}%eI{!8#UWtsx}>Ir`3 zmus;dyAeb+P4wwR)r|E$-;1-BzbYQ0MSiuuJ7b2VvX1x#cJc6GDzo%Bcd~4-h!pv= zLNpdB-(-tM{Yd(%^V%kz-mTYM9Pqz`f%s)C6RbX=63`Sh+?DGWYQw6<`7 zd>!Q^80!$$5rMw>c^R0=x@?*)q@o4%59t#^@&C~<$Nw(K`Sp!!T+^xYKY?&DGGOw1GdqV1HK`uqHA*;E*71!2*r6?U2gk7I$|2>U!EZue#MBJB76SYoKWHc8hkY_TSTTd_<>1Y{RAWrOX{E z4$gA8ufB8e4$qvrI@e%dG}(@EJeN)jQHv;)rj?rvsxTY06sDlf6}#}CmgfJwO#e$C zOd9!zkIjqkcOLs%>>_U|pXyz_DIFp9GRuD@zLuWrS%uUw;6+F#tw1)b&%nUIj1{CA z7mhNZCie2MrK8L;Enea_2Mbu1p6y+H&HZC7#&u-1GZ$&8zy zzrA?NNXQiUmKC$Noo(w+4m~B{U>9rXRhpk~&R#?SKe5KvO)m%EFY;Limj>@a;v6|n zB09A5^)u3$#ubaCT1PTJjZ8;1;5U1pz!pUYNq`#2>#Qiz9+2!`{%b|mBMWiG&%}7- z@e8wv2FTGcI2~pL?`onQD`7Vq5vb_?!?z+c)h&89vB8VZx5Qa>)k_WrnHD-n{It`G zCKJ$4S57DPX@Oz>Uq8#okG(Hi5-m%~Xg!zr>uk8UXrE~Va<9Cu8p1TcZ}sK(85eR1 z^3Z%OzrFd2#9Qc{$@w0l%~vG+$E@RSab4S|!{GdNJ!sgwIMHls^AyhASWix^$7^xJ z6L>P9B>(A>#lNPADc4VM2aG-^)3gwZ23s_Pzyk(6Tgd#h`WDy8t7>fRiHX3yZGU7S zG6UwaNB7hZee92I3U$1mwDeMM`H^zPl=AFb@ZQ{G1+@(BSoSZrnv@|-NXs*mV>Lq2(Owu- zpbDpg)!xX#H1=g(vWlGXbgoc3m0T;WNT8s7-@nwQ?nm%X?r3CGX_WFVn2@owg>YNS zE0Q@I1MTs&BamP$PKGvh(eXn^r$4Ch#Ksjng&5&oLs(kkf8k`#y@OOR`P(O1vq2h7 zNhh7kif#(qY+I|=b7JK@x_$brrr9FbC*Re|OKXU>s#=o()R%A@XwgWqrt41f89WtK zOMNfSf_&y~rP`Tnz2BOz`E2fqQxV7bs&(4A*lrm82z5=d*|yHuDz=G4R4a8`S`wkl zglyQ$;yf83=j?s0U#4tMv_lWQ2c4R$9d|qyH-^_N8pAu}hxRvoomt+6|D2(l4fZt3 z_uP^KYO0>GoT5eCjvJEKSm5*8q6f{3-FO!8(m>p+Z-da=PY^UNZ$3u98TPLatW#|L z_;zrOP;Nff2@TV{W1`80mGoPtNE}_!&bUYQ`X2X2EduU{mxK3g1zz?Rr1xqX7}_T4 zrbOM_b#^hh{BPX$5Bx^KpN4A4?uJo~bxu8|#;;BNHCB_dxNn544Jq;!s zj(}!uay-R!u_lxRaFn2O_Pp>z&#vYbz2IuSz0?-*g!ROqCeWPKXrz!etC0j)&TpM;6x|7L62svrC7VK_C5#<}v2%ANAJZ=IhrX zXgkz|+)VOO1QMHFoFVreZnOG+_m87Ta$yHL34-l*dSI;?Si$Ac&@X844)ZAdo{bio z1c*L4!+z4m9H^y--^|&#)!4i!x^p@ak)U;JUoQan^ykiF5Unz5XJZLUPJodBGVaz` zo(hyzdtk}a%DsLXs5(!S^2LsVm!uFMBA-Lf1A8$p8xk4ga2R_d_mW6p(qKu zU&RjKbXPxR}%pgJZLss5vV>74e4}4zB`Fc}`H#jiLHyr;o`obeIiSwCXuMN(_ zvteMD!$jmCS>M?9Y*q&)Dy}QR#q+96SaQlvXDZ(>+j|ZP&{s;NZ9WO#IzsYaOOo3& z0g+b%>PXb2(|K)VHP}tvVIw8?!*{3TGq<*<_jrFjSHgp;$jpM-5WNbhBP&yO^4;_48H@@n>VQ$taOG=n=-Tor&oW3dZdc?`#?Lgdztl z3y*mptJ$|3C=%kjFNk);vzT`^@u#3QacVBADtXVpHK&{DOlj)%YdQ!FgfHcl!ukxY ztNr9+DCCb=ph62cS~t9Jf5sD zO+Rq^Akhz--!e-R7*ZJBYHZlqDW7Iqw}ZU%b0K>!zzL?mHI8_4=5|z;@8DKwp_nwi zdpk+N<^liP9?enCLFBkeeLc8OskO7V*NZ&q(qzQ?e@Qtokw*W5H}^g6UV>aC&XIHI zYWz#{>6p~E*yawzRf%ibHFu#7?JH5zaC4p8(eUPU2dSx#4%|Yig{UCEM5l3w)7OU0nZC#u^TeBLhF zrJDX))eAmtQ9?O(e;E?i1IKP_j_$yG*LBd7_|hEkj$q%{vk;ibnSQNAWD59rBIJlJ z-dD#ZYZYugxDS*2RU##OT3Gq`_KU9=_eihyB4_Dtj=n+SzN+_ZqsI~J@k#5#4OpA+ zB7=%~di#P^sH>04>0r!r5}>Kpy{qYNKh*Zumem4{s$ml)E``Y50F!uxH5-GU7y5w5 zf~9>0@${}eEn_Pw28G+_U|02#EpGG8W|KV;VU-t2jqM$O!_MasKZN7d8Dr?4`BU+H zJLfq(({|E4=sed75@;l(ffQ!N5to1rTLLgi#u=J z?o>y-Zf0WmeI{)}0U;Agy$%6_xaa)9CJxo3H$l+U0CP{vIiVI%E?3YG#;pNEQH+AF z!4icwmdU9mLU{+BR}v6)!?MC${UDw-yhL|!uc=L<(K-I z9{^8%Kc?9$-oFPFcEJ|ygQG0H>{P_;q^j?BvGd2okdvH7p_U_wU$I=Ct&_TFN>y;I-lYBY(b#rgHb~L{r0XS`f9^lv)o7p$@9U2-(h6FvwR;yoxXn^-*pA+SF zs!9^S5}(_be`=p73@^mqt0b6IkY_Sy<=;_6Hj*+0Afn_DMVH|}XCEi|l!& zAlUU6=w^@`#`mr14i98U?Q!so&Kr8#>f>dJ&EVpmJF2&_3g}OHg;<(RazJGp;pcS@ zOVXP7&P%3Bo%hL+ZKJ9AEX}JF;?FB;99Dda;qyPdja&FK zjAb{W?=TbZ@hXc1IWHu`q^9jlrJJ;U+lsThP)BzkfqKeOyQ-+bULwcVlaj)FBW-0? z!^*=EYH;PAC5<~#U$e~&V zCMSJlFlZ0wSrOwB$c2<>T``skSKve)s!RGgELF$htC@f09reU?(#&bma2{vq zrSgL^rc_@ckS<`qd-P1`sQ5isdi)1|=8J~jA}Y6>amdbk&G(h7&11J)iF93;eg(6@ zWTu^*kD1*=VM7|o8@8(ptUJj$z^jSgDY5Jf-~E2dP>-NVCK`ssAfkj`~#Ykdyp zb@exPuj31mCUd_J&L%Y-N&;c=XU2@qu4@M2&DS%3N;wh5Y`I(8atG8sXJDBp8<2yyT<)!PUu$|J4&-df+0u(^CBM&VVw;|7>wA@ z&rMCH-EvZ?s0OfxX%%$#7(5KX>E5>VIZfFP1O#0ZXf8~w zAKMM>&bJ6XFpyWS@!))I-KfYF59#0QV2a=o!KI>l_c;B!j*#9e$GzW|j5O!s+HPv<`jM3ucWy-!b9v&g{+pg$M_o5Z- zD>2&-xVXZ2y;TJFK0_qy1hJ1?h9_*EH9x#go})E`xqtCE$&u19>mD*-|a6l;R9?B0gpYT4cw7caBI zi!`eYnp}UA04|XRUGQnarS9!L*PFo-I`&9c+W@-k6;|wQfr#2NcWm%Hz>xK>Aq{{V zaL9V<%Ytq!6K88m{Y3V^lIHc|Y`@Lk3#L+kY9QWWR{nTz`u8J)=uM6X>TT^3A z0o?p}_6ZNUcRDe(@V$FG4Cx2&kVkNWq@Sp=vF+9uA@--Fv# zmj^_MjLT5g9oaYj{Z6H~b_Wg~+=$NLtHiP@q`rZc&V{iU8NVTK?ozYHm%5%Qgx^Uu zoDfG>yn{vdUkVi3G$=CG3J@aE6*OP8r{CxSV z%p(KX~{#bfQcC!7R;;g#jTsJ zIi2TS0491wM`i_6i4Ry~6DiJU;DMu}F~*JapcD8)MTs?-*g?^59w?J8ky zfJ<+J7uYaQpe3UnATN={$a=J}>*A22s9Cy}r`}u`R{~(KlQkR_{AQ-z=Ehiqrr*r7 zVK>2_p6lfr&%tL5&fLHA_4>7$=Qd6dT+se_5}?ih+%Cu-m%#MZHS=~; z1^X7jQYd_)WdUQr#5CKA(Ld<6{3t*IbOki7W{cLC-8iN^yd{Nq-Af_3hYJO&an!6r{WPwzSY%A)9<4J2%x5MadNUd< z(~M(WEnbBdYUlZvIXZ!od>opdoAXhkC_UFFqe1rybP6{U zzUF^f{xrzg!q{SR^vBl(%a$!(Sk&W?VS*DP`&)`9tcQM2`SPk}@zE;%YtiY|QB%+o zvMJh9B~yieRS^%2Rat}Y5AVq}TH3lf*j^e?jUD2Td1Zg<#XWE#I#%FNBaCI#Zawao z{T&LCS?Og*+F9RxL*9S<_}^v7|K0aHQOC$|7@NDQ;?RcjhHJWPny9xQG^2QDrpm9B zHQmxE&HuU2v+rY)2d2U*U@|8U9WneddS}G2;;Tb-yf3ZEJ{o_7Y=zBu9>=*y6UkH` z`I2D2g)?tAR^}I0d9~ZL{RwCasYgWaL7X-N2BIOY%6p-Bl@d@Qq2eWm{_VnYMGTwK zM|1v_TW=_SEnmdH#==6$B&@ZI8)b)2-u$Zg<2d_xnf9)^>jnj9Jg)m0s>g@IU1{dE zC$I$YkVIx8SGG*|Sht;vM5(b(Lh6F`>d#}dF_*bYM~MwBb=Ffo_+iEp*^2z@6y6A0|rHyHa#whfkC|6%F=x20jU zq;UeiC?IU7I^6-3>4y>$i_h^9q%fthGZ?&B^JxRZO(fU7Fgs${w@HBF>5Uw+3-!x- zbQoxuzdGlMkc{ypmPnq(KgBiN&($`CJPcG-`)mM=%MST)kYE$p2>I41f9isflxv zlvvB|gfx@14`@}xpextVFP z#>MS|&c?X&ZUpN~Jb2aFlHRKEk%RSFC&dZz0QN(Xn;^PUytVa5e^ZBW|3z)>mzBAG zEimC#%WIKxV||u4Fy(zccU0(8+`AxZOtVgMmyOt$M7D8+Tw~q34eFUs5fXZro1Uh@ zrONTidf*gDE28sHJHw3`vtun^xAo*y$DGaSrmvY6O}WLhrKOcY?DnG>QQw|dTpI*v zl?kXcU-g?1b4qXu5QX(>qBcFY!7E?Dh%#mKpFv`TgxkrFtFR~Cghzg<9fs-0(_U10 z$re3%gD@ z_WZ>>(pr}KQCjwu4rVj8goyl6SAAjeJc5heSY{*Ml>LQV9lqYr*JLC}1-0{J&tczf zsEqIFg(*=90L5Wh${s2s`Nqrdl}T^(DSRH&uG65&l{}ZoAmt67dsy7c1lyk(j4H0< z&hhId#7%h0qu)n-r91mQ+|-PLw=RU)TnMwp_`a|FBP7&?E47zKl*C*gK#giMHx6u< zI1Ym;-sf^EDjOFJTW+x8=7_WhbGF5J{fRhLwZ)mhJQ$hY^*n8l2NGsEO&zVik#sEs~)n)1V%~Xd; zbo$U;KqOPYP>-X^6ENhZ3PW${?r~r+dVzJ*h8Aa^>t|$qcHnkg2tUdp*T@n53)f-Ossv^8{O9A9k`R}? zH4s0y@OiQN)JyZ~s_GvhDmp?*9CkO1u27ruyuJ86w2Rsf2iHoq9&|r+4cw@la=+;` zzB-Gti1u;-W#i+=o8^hOaSCXr_OB^!_GY6Jl|No556^=UC`#*mK2;)VZ3A;TfKs1s4W|bc2VTKN*11_?E4Dnz zMcu4=%Dc4zo4zm{vmp5$1SoNJL*n!e@rbCoCAz)^A%?OO0VKdF|8cUbFHMs!p&D;I z&q_#Z(B=KqTR*NEE6>Zwk@}TI9=v#Q>KGZS(O4Fjl0Hek#3Nz2qWl7z`OU%7>S7PE zL!s6&aqc`*kD-t{UY-E7Y&Ob)rxLh80ss#8ArvCE9-lqrq(ro@8rO)LS2Q*WK)djC z7`q@4PHm9{pyQ(o2J6*4xyy0E(QqGz-gcdM<1t`~2Up`_u@`!Q4p@O~9yRgYZeo}< zYfU#(boFz89?>Rrg(8eV$4Owngzmo}Wtha{EdB?)4>FKDQ(jHn8tn*nVfbCW*H`t5 z@bz4&b|^!1U%K}=9XIr%wYi46g*!Jy0C+|xH@mS=t=WO2)0tra;SvCZk*s}qURIpo{LoxB35~J!mh~eaf_wZcL+PIXwX+jM)N(={PTM= z(EJ8u@cJumA(2>@BQl^2fpK~0dx(ss(}8`&r(f=j6U^P43&`kn19Y6oheFuxqjST^Nc6{8NzAwmDc&sn?P@^^}Vpe->5tjBl8!dPWm}@!o?MnXNSm0 z62Kh@{Ij_5UfCwnRX7U5;>uRN|7>u<}gLv0=k@&NsU+rEAMjx8F!^99fRYc&uB>$NOrlPK%5AkFdQXtN-0$IKyDc zj^-7_BD5w*5tp^VO}xE1L1c+Zu7^( zhobCba=nwXGeX3}6XP6ars7|wFv~v7S`;3P+5+!LFpyK-PEWXA*2O73yc#+$Y|VO$ zXWb%jLT_x_Z@%Yg!}#F3)BkC*@2_a|b@LSv54rYPBf={f%EvA3wrj|wJU8H_)=Ips zsxU$2``C;lO-^~xt4Or-Q0?g_)y|dLdcSRIbNv!-w9rpk5}4u1kwE3p4oWhD~nE_6x)$OgQsR3%0|H8$@;jLDO6<~wR3ZfuH_ zHT-P@H3|_475R>F4}F<(ZqodcFOHQZcrCtOL}(>)!<0@*j-$BR6NUX;ewb_1@Y$AS z#{FT!<{yc$=F6(4*uX9y9y(0e_l$k)JAXQmbp!ui@6GKZrISU*N67WP#l1FndOQOT z=CDWrh^RYdxQ&f0+4%qJ`}D|fGr)1TjMfELtb;XgK~4f&#g?*_$pxa+csrfRo!2iC zo(2{3r$yW}IeCol-gCRP+N?yBC5}PZK?(+P)jtcl6&}1emF?xTe=FfGx#pEmK*R5f z?n;`LPd&X+xMv#7ZorS*ir#NH?|7-}yx8oK)ViT0(3m~*Ra5ge{-=b>Pl-uL6u1W1 zg8cAFFW+j@0A+1_t*Z2}<@Vk73jIeh_D%GlF|0u(Y%w3IT$eb}#&e~-JRHn#(|fxe zJ*&BLnJ9M(>&_EGO0ai+sfW)675Cx?hH#GSoozTei4qgq&D;1hD5`vnc9)>H|Kpm zEq<|rj3(oP#@Zj|c=bQD&&d1({iwtD;D+AKwr_U~zHz%AJUD)+MZUFDA6JJx+dBE~ z8j$Vm!{|>gAFg?}=6ND))>)@xwLofRUM zCdemyKHS%iv%l919Q5U@wZI3G?_y5r%U~cNygX5&hbrQ7&iC}Bd%+#gd1?6zw*97g zn!e+}=e#)k*7_W%DlR=>UK#tn{YG)_pWN@<)C~+RGgDtY@FG>ebuKBxQyk|;mS#a0 zE>?R8a*G#sQ^<1uJbJQk+dYAJ*{ z$Ph$Eg^z7?nq*bK_c{7BZ}117aOL6wEOMR+Br>R5?ZlXETW=Bk6vhRMSPp!*XY)xj z=*&p5P*i61Q+58D|KSFHD-oL%B1gFD>Nk~D7k*Oy3jtMP*}aU|5e4)T2U!m_I~V8} zES-LdC&QpM@VsffDBSn^_w4{eU&OrFkjBw;+loe}|FE)Bfh7 zo1({oT*}2~s?D#NXD{@rM4ie&CYnTobmz#7#c>58J@1hxAu*3FYQdR!9Rhkje?H?( z0%X`B^48B@!0E#EYAhZDq-hm-2p-#tLI1ZX} z_FuIFIRkb$Gn)5!K#iiCJD1we$iWV~{?JZ`6r;tFult^gw;(=h@*)ehQJB$NTEIoi zV<+2e>b$kDN_DTu)8TcM^)lvx_obOxzFwAZ#iDO}T8^lA=*Tzs86KC5aS!eLcEF}w z?@s8}=f88XW+Oz}zg$dJ0B^rbiXlihjbR0&8yHXn7a+l*ot9onx*gje@q@p26POa4 zRnMXb(Pv`}nz|!Lfl6KkvuTx?Cm(o(KPpR}7}Fu6`)m0xMK){%ChSk)n|~3a->bgp z`>|`Oh*&VOUTA2%eWrqkcLs=(@2w%@(F*pkn#p{fYh>;zNzeFu2>Xc4 z*1u*t2`@*6RR-$mSYr~Aag{SSk_VBON_=0iF~5pg`kWvs>9>Y-!B@yG>At$U`??{p z)Ev+Bm2j`E!9QvBFu0n>r$VdHX~g%(_MW4w`~!6s^FNirA7LVg$Pd$WqjLNuv*ORn z74HVfc6i`~l+I8Ms~ou*PK)n+dqt_$k6tXAk0~?8vxVi%YLQZ)O~tt4dpoluZD$`(iK0K8!yli+f#4k@o35T1$(|dV6eUxKZqjQ6 zhx$Ubkri;R(5h>ZLZ_dJjmV*^0Di2wZ-4uI^Fr4P^9bpesi?Ikwc6Q}J8BXi>ParCFvBf9LFF5H~>=_Rgh?I9!Q1 z6=~oh+>bGpC98k!+RKd&89n>uiw1L(&0bRGdghUJCg)tlN%}Wr#bapM;V3o>Xgt-R z)j}-|H{yJR5^b1!!p!>YnPqXZF~<^%Pdnlhpg^ewoQVdP2g{)s<}99ugu2SR(MM{M zP`>p%*U+MgOUlRu>%%g~+iH#=iabC|?b$MQB&P0_Q=!qEv_VjnOxgH-NWB*`Cqk~X z!NE8)`B!PA&UdhN$_ysA|G`T5XTG1WcbqLc)k%kfkAt-}6{JS{sURVP76Al;(@9IfZI0Rf zjFVDpQdPRf=H|6NMhyLX!()=eTP)r=3<=rM4OixlzcYHNC-yZ3&tSW;(7`DFB_pb# zv+*RYgdFGcQpmo=HR$JdA8T%Ya%EAP@S)pxJGv^l*UG&fc z(H&>13IS+k+_kWI)G=s9^EA-fXh*JS<@|cNu$(LP`NG%JoBX&X9!+a-scG+&*_Xc=NHH&aC`z}+tXwdAN&4dI$)S7@f^Kbt zkCQ`!fb@jcrFG-YMge>kW`EN^=0&TP^k-bTN6y6*%%JwGCEDl;^qtctrNqO-)z3|I zW4+(iRpC;c=HB3SUd8>MCwAB!gF=32k5D`|Mrj{9AVB(>fh zqV#-@|EzPn)xRWoJ%^e?c)jW;S2zI)wMefOU+oOEt5wf0=vlB!EVZaQIQ3o`lS$L| zy`D(zZZ` zgAJ*y)u@RxUV7qe-Hbh53$$O*I)DR6jM>9%O`3Ic@ zNVFoEKwi5zx$$26ho_s|f($r#MwZ8AJKzy1j0Gzhg19>k7d4~ufx^^4T0!rPqI~Nw zsJ#`VzF40dhtPc>?Iy4mPQpmHLX9^YV&05##JPoF)YtrhP695?&)0eRm{pu3TtPcB6^XEFp5(+GgyK1g#_SI=Va$1PntvxGY;tz9(^rT$GV< zcs$zcrCT*u`Rs$9niuruf6bB97?7Znh~MF7MYrD!EL*bz(Tcj9A2)fw#+^JVr|i-9 z$6cjne>(dTtkf$gz6n>h7wle|Dkd(1e^kv)n;2Sx4!ofW_u^(Vy|&j7JS0f-!KG`& zj`72Ci6i)L;U8{wmiM1X;{8|yx(R<8@vRu*;IiSa2$hdq$cw1Sl&27#HmVog6 z%@9kCdb;bxlcif^AD0Tcp7U>!mn3>G7;vTN9rn0AIz7&s=&2aMu&TMvbiGkG zckEaDV0UI2*O4yeH^zZrqjuf1(FsN1I2I2{)N1^kkuBDa8(&sbT-KMYgMBS zaF&oe5A!9swB2GRj78iI+xrhJ$=jH>I}otRR<4x~AnobLNe|zK;Kk!FdEMH9*}i?v zlyj7U4i2vf`DTVtQSo|e8v31s^;NQT3P#Nt6ai2h)ji0Uu^`X1V>Rz~W z>baH5JN;f#sBhCJ=8~0boYka9y(KATKoU8^1jJB`pWme^5jJ}GlsSgxh56oap(?&J zH!rkXgrEOfqNky0zbClzjfwk>eOpjdP2c8tMvN1k4^nyy8r-6nYb=C%$_zX zcZm(b8u=!8qakmsdIV4nqlIyc$XT<`;!3uQF>Wo>tfbDs9R=hWK=om;#*nD``BUyE zgC~iJHd8U0n+~5o+ zSu1x?tkk^NcxgncXX=t(^k!~Fi(cF82jAgzb4Co}mHn~dj^gvZcoZ$n34_Auk;P*`U%TTBsZh{f!gGuP+R zw&sDRKNAVxa~hBQt_1iwnyW4hf^qD9%ipj?g10k|XBSvmnb}cFXW;$ZMdd7Jnzk;n z*)h4vVGMrpa4zo|N*8HxN{cWPRQqt|ob)IDE8d!+T2)TTaSI_(?zRAA!@ZOTzkcjW z)F-h=$D4RticSw$m3nF|_LqW##)=@V)Ec9c*82UtUmqKC#eH{S;K^%LByVlR&u+** zN^O~8mRf220|=5VKMdO#%5pX>{w8$iH-pXXl~^k#fj7z${sFidvbhD?uuQc2rLU%g zr4VacpyWKqt<`z7D%=c!P&`OmA+MfRG{gw|c~ebIunFft37V$U(?I7iUUt-?JP*6* zhc@gc$>Wo%^2cQ#S(Ogz?^qNZbar_mxPX50_>MrklD$KLhC)*7_;o1bk;93jc znrl@c{4!hjgx$^Mi?#0Z&qm2bBVQ)oun9L?#9&JHI*SldcC|*SITNRpgvJ$2E&;rc zG7mE54>BAgfMkL=@IXc^uHmi!MX77ie5>%^sus+xBs(!sNJ3U9+ z@p6Hhf70C4jAwrc4OY-uYA&_T1^~Jvl!Df z+;L>N8hf<|s&dy}53v1mzOeyN$Unq2yG)eyto*8H!aI3kmS=MP-&yUE1PoYL~Ew|L&l&sM5Qta*~D<^=!w#nN^{|;&fNkYCtae#P`O9zU;7jLNJbkTXc z_s8X|_o7%U55o4+d|4>{V2g&ZfPI5t^44g+^oWTnK*9h>EOWt@^VvX#TN$Z36alPB;V?j0hjBO;9b#0VFI{1LcUGoW?wAkH#r z^*4j+id;-i>$8s!C4ev8hHyXe*U%3syULE_rS&=zzT<>egZ$*OY=8Ws>8!f$fgUvJ zS2H%c-uXbpUx$QAcFVn+akW&(Ydq}%Q@!>!a)!TP*FECli)%GS(`{DE7R6f$GoE3jf{*PT~pZQ6GeS#`P$NL9a;A zsLK?NezE#FVb6NG*uZPgBw&GhrAqDbv!U8sZBJK5f-y$2kyAXT`F3S?W&5xFzs<_>-@cU~4RJbBQe_;GCPE3<-Lh>K)9qSsq%ZlaH#Q3t$wc3@>-mvxQsu|T`kM{?&Kz=X3;AJ^Q zsPBVaNW|BFr-Lu3K-guRa<9XXeUy8!=9I>ktBTEmJUGFdQ2MBg1W)p z6Wwgkrk`--3)v5?+EQ;2)8)ounqg@WS0w8WYM@L>=UD0ib)Q%88qc+Z#t zEw(5L`LuIt`inRfm>uM82@t6RLU*?As%e{j=kvNXFFtPFcOi->aUID{{EAJhmnGFs zPl_&ug=JV}Pw8&r4~ZbMu5%QZR<@qv&83wk;87be1OqwT%>K4bWMx$?>Aad3q(1Jv zDywvZ^@9xKOu*QT%=K}I(8e2*{Hp{@oLQE%S|Wh;g#2|>rC<9&z>g*O;CTajw>wS8 zp#eOL(N6=9xn?#QUS{w+qHi4PwsLE1!E9XP-qp(tXK!h#T|k`!YoKF)pJ8d99x{KJ zB-3Y;c>Qz~=D4Yj3Me{uSPf{*)hN1>X6}&ro2OhoUS->?i6D8=?Sa@f-a(u->^X=R zRhUJG=Aa9Eq=>MoL?c(~l>-D*0l(QNdGlRZ3L@S_NTTQF&v1WxPpcSFTs!abl^fYO zZCK>pzjIUn=CUBa2M!G8(r0J_&?@{JMG2UX!)$|4z+JWVvIj9u=wcgM!Dc-b-#l}o z+Z#(621bZ%p^<8p6@%H<@=jybOB3IoS|yQnDVZevJ8^t75|SC;ks(#vt%_OcSz1?; zz2Ece4MhLG-XsOvAsO``@%ksU7ag8;puuWuxH{ZCdy~BAzC46`HeWM8F=pChwC-0G z9LwlcUsjue=dE(WfX+fl#uaK6p^Dl%!EJ;oBAuGPsBG{(!iMi(T3x=6wUW z8P6u{T?$o{B+M`^kJ6!}%2el1E0sB~j3@(PL_PZQs_ttarCy)2@$q}p=`r%iu^Okc z%4=25`(EIakVhWq`3&U&DqMP&P(YH|*ZGs8-;PvL#BOEW zWjWB)<5jzN#PANokv9zMOt}iv03(e4uCh#J+M!pzQ|hhg@?O4K+i3Eo$|u0KGJY?mVhSS&wcc*l_b4N5I(r?P_w-B(L>C_ zR(i89Ud|kA363*?)MS`W6>|dj+ltZ5jhjCDvalEmHglk5hS+E2W!#xr%kCN%~W88&8Ym zz-$JdyxL(EVH;T0iH)Ssp*?1*7INE(7=mJ4s znk8rXxM_C8@-Ib)<6J=}XF%3RaN$&()B2=)#HwV?2S2txC1$KN zsj@zU??;ixqdGnp{bx!x31{&JJ5L;TcsrCigWHv%HG>vMgwK#})0vYtp%|NWZGPwXe1?q{;|DRhegCeUn;kJx7e1zi&VCtvn>%C@GUAQS#| zPk#=}A)i$O2~A^LYc}SA0$l!23k2!I+B}hslj5rm z7%T&@UnpH1YWqnYa2V6N$;9-^X8##}fUvTyeUH{E+wR9Dwd$$SeRhMlJnHp#O@YbX zyaBZUP2kPnWduj08`?(8L@4We!z5(qcwMh-n-TtrhWkA>zrzkH&&X1_5j%;TU$axM z)NWE(3$O7)nKYoDW!G2s8x4!xG2!QDBFTQoB>~1NOp$W!BRHMqnIFFyqJ5+#sNpO5 z2WL&^bl|3DWiCl6wFmfp?gaKeY=l(n@O{kT09~3ZL#X7A|L0F`TkoLXM}?X6DGN#~ z;#ZLA+j-verRl9{-{-*74NnnDKhPWIjE6l z&$hZJLm`cugAAPvi$|aDS;SXpsKm#&Ypa325uAYik3iicfBk#Bd-N5}%h#^*C&w@_ z=JQcLjXzkgb$J~ZVOBEI?v3e`Hx79`f!e79YnedE)Q$v{z%!-of)E4J`f=K%GDj=b zsXgWFtA5CGpPgMS$Mff}H^B?3DZm#$89$6)w9x7t$eTKSv6SwF76bb`hnQLXfLWuo zs>Iwqg06?wh87Lp4XRwpc=VrJo#w4M&?%5P#J!f@w~0cQ;_&&qg=ROfo#e|Y3C~v_ z$2;!6pfV-cn!Gx5lHlS#EZAww{tS$yg*v3{ztg+^m+w!Q5Y%-uS6_a8StXA;4jAZ> zJ0sj@SXN$|1>7}!7!!u>SQ<0t!*41Fe;QxD!XFcAUFW!dHRc#}y(3HG*J7jLVWKjN z)@(EvFwypP-*>v;GOK!Ps-N4pW1e2KXqCVfm-Wx zt%AhOA)PU=+e^o0JsFHUdALXdow%RhR_!vEV`(}-z-LRP-5wK6e8EKB4I?+6`-lrJ zj1UiOK7yV81I>cC*}mYdV5++z1#KL13LV?ZemY`ysreNT^WD~I99#SBntm!}pd7t> zXhIJn(yYWe1o2HhXr|mWnNm&hmvQ>~Mh6Z*DBXg}<=NH!U3dQ?2R-`dbB)$OKLPS} zMm%EOc<)l-`T4VvGIGv2f&JBcSd4v4exB)?d_YGMB6VZ)Q4Y#x(k~cHaC{%Qmgps< z_xC4fMa*$r{pb{PM-^gY3T39>^v;=0$S5HZ>8z1Bwx6o7ad*!kldPC2F=o3b3G;kk znD&X|MXjvkb#l~azxn;r3<|`cw3a?XkY1N27)b~#}tvPh|fG7oVtO0 zXA1Urb$-!wll<99&PI(9ytyj_D>9dlJWud+rMDa|9x^oq@Di}^crN~6v9M70P$*ye zo_6$$#-0uI2JN-OkZ;;vcJ0a&0#wG^u!QaNWZss`G6Of0uKkVd+Nl3@-x_5OKhrO3 z6|LwyP&GE4Y@fwGWBlm+bU!(^*Q2&J5M;SIqXG*Od#|K_ zodF?>!w~V+7*vHwfMzzbkVY`U^}IQNPV-UoWy*UqbtdhvnN~2NRgDKPdFqGODAg)z ze4yaUXo6<O}qMBy2KH}a6dr3}%SW^-X1mVBP5-wd~C&CrB^2wyk&QGeA2S-R8+ zMubabJwhX!Of}9==-3tWH|D@Ew5oH@%)7elsrhv-EdPzw@b6N4{{agUbn6iO{}(d& zo9X_gNeiE9OK*8e7Pk@I0eF3t1$<%is|^6{Q} zW|d<|z2SAKNZDa&!MEqsMo{{G4DxY)N8mC5uJaOIz_UD3X8a{g#B?9cefq5jMX!lE zDjQa>neUylz2O_|cyI28@#ek8;9+WfS0M`x7U4abCt53q^hASc`>YRi1sw=B8BPm8 zIye&T6CT2KN-%WJE%7b)l_|8@jqJu_^xQuSuYV`sWdBR(;0}t&_X)7Yw7qCd7bQa5 zd5=S}W_$f(#Mwhs=TGNdONF*`<15t1YsXuzNlq>#xd;`#VGnU>OMCAQ1oX=Is)o(0XOc;DdKx^7jd@Y!M_RrSfdf+d7PM{APVd6m1n;8S#~V z^eeqT1scu67K#$Hp*TLj-1&sIkKt*2svJ~@3&mC7n?VT%IO^_)ARNcD@#jRpj>C6lUfN~gDfxMOq4HJ0X~@Ka-INc-hB=5U=0-BY z`B8+E4>sQx%~L>4A3}dQrv0Y~ye{SGhWJwMOWc7klyUzxOw!QO(r|den`6!Ul^Efty=oI(12L9-W^4JO36wP7iunwO%f=UM??xWOPtFsc8 zSHKP@XucXZ|KX+`S(=su0-h3$%2G_DbK6VCZ4FQNGS6e=xP6$alq)cV8Ak6HEwjo= z&+}%_zDQd70CLVzy4iu29_&1Qij$+mUdv!&ViNS1c6}ogk^s)SknfPBbnoky=Gr_) zn5QL!hFm!vUaI0F&|rF(Qo*7w1Aw8QpE)&QACD5EUK`yj*^60)CK|#>SMO7<;dJwY zI6qoV6OP3`$mP(zTX<802u}o|cWX3OfGGMl8I)w0MG5v##_UQiRQf`s5eD=@d{Rsn zC3bgA7$owL#OI=yAcwUeMDvT%0U__RJC@TfF8*z`o6~Ow&5(iL3@Gcy5p4hc7jc6x zH?!vMb3B9Zy$^wY#{M{?Oi@yAnRxVeQQ>2pAyqzvUg6`ucwP+6|+FxwB=81J2N! z74Ifq_8)vV=2q4-!a0dwnFgF^)21&DydJNL-XshZ4Jhwx#;mw7S$x^}%@Amehn@6? zfHHd_2o916%eiIB%{##I;Jfe9TSkZTo%;AhjdKqcG7rTuNpEa z1G^O;0WutPHNjT+PR_4E!I_?S^RT8*^t(8@UDJEi+=Z63@aM7H^li37haKI=NOA4D z_d6z^U~wt58Q0J!@<&~*Ivy8!B9I;1qaOm)2h`Ztp=fZd_fLv8|Ff?%zCDLzGXdbb zvPmX!FTRCv5k5ID=hV(qFPHR7zxL@X8mRpkg*RM!~h98S~209LIV zvhezhhha~d&in!G(r^Pjut~eoHjnVwRK4NcZGI;PE+O%~$DWM88H(*vDhrTBGhLro z^*IDS&{=Y_KZ9Mi7x-C4y>{taySJ@3v|rWjmmQjtG@90-ECfUQF5OGgTUz_bI-V{M zKb$rg$UwaTzgPd*rRBCy@5{1%5$GyWz!SOm@$kYkbdzC=#7(=;;XM8o%xD$Vf{I?1 zL3%ab`5Uj+pqP{H^Z1J^U^%1gO!KTch;$Ac&-%kj)cqrh)bTJ50$d!TS~svAjX0a) zodFLKUc%BCJ3avhUVeh;$D`vuy5!3}=-yQ69ppNhcm_`K81Zn_ZQYc|yzjW}uUmit?)N&C=v zqT(4s%D@5@E4;;_3G2l+K&$B|fUH?mSY@;ifm=tR47&lyBNO@K^;p08)l1B&@swd5T$ZVd>a z7TyO)exqD#+zSN)ZP?z9>}_UuK`T{v{&nXnRU|O?PcEAcQHV?8@VjEAfVQ*fKrA+?47vyuhP4qWuTU9_?0blQbI+- z{{Fkg^i;uCq@TNjit_py|32a1T)zQy7g(Tx3pGYC8PBTGdlBbz#CB$+kR;{`aa!6g z+6F%u0sPV(b0b!@wiJ&uaP}B@K&$QbLd4p~5f9qIm zs=Bu;ep+{_cxEdgy+^jD=LXfd*Shqfws;I=8y+}zmJ<^O8UR9w-9L3ciO#5O4$8+A zYm7@7_hg(uETJFq&i43PM?OND{&e4{Y&6S{gB@kvr-*aLbzyVeH?(v{HuMiP>q00~ z^!2azOS7mF=Y@L~Uc`T#|5?vfbiaNSQRfG};2pzC#)4~{wt`ub-Qn+K1oZ2y zzTnFoJ(vy$tvg*FQ>3XDIhQF|up#x6w^cqdmjGq+_aNU8Eu^UpFV>!(G8z?l62ZJt zU#S#CHC{ftbQ?S3ZNs$w^^3NHD6)s7}d87Jja|MOSPW?4!&To%B{x~8>bA?}9Gn?8A^Wc!y zRtfZxGlY7ab2p^W0~}USY%=|4?E6>jVDvW*^XvyXEAUt#HS%-rg9yY%YE?$(-pr4( zYb||=vbWOM3OH^EDu)-;`R-l>LrGWv=8u1^9{#Rn{9hCgj+{ZH(A%omG-tW($GUE{ zf3^*XRIllr7v2`bnZll-PWNW|^H(+2XveKdc6lCID|@T^T?~1J+IJ?{yQerD9ejAL z;;I=$8aQ z&yUrtLK%$o4m3z8mRp~qu0b~7y$8bt65I!4nlV%4f6ffIB z&-mmz4j0L-j#*YiN!O|P#Pu zbJl_H3)Jm3qoLjk6zn0o-l^+E5>Z$|t48iXIn%7;k!zZ@%Nyd)Soj??D>M6xYhF(k zy>7~Ncd;>_3tsMd?;y~DJBonN)Qor}Nr=?^Z-=&i?xtmHi`Vh}MEGquqIm|gQ6cQ&WY>wbR(ad5S5q~WW%Emm(tAOh)!)aE~<6B;#>miDp=Dey!opJ$1h0C3`=37 zgulcHja`|yK0)z-8Fhm5TjNU$o*7xDpZIU^(*77Mc2`LT-zMZ((JNu+9<6_g5a-_g z3`I$Pj9*?rc+h`LvsD8yGV&on%*scplJET(TNBDT1@4SFkZ#^NPkaiS<~k(AywVmP z*K!XCQ8ifewokTb6pugyaeJUscjm|J#aZeOJREqu?p(Ahe9G~`aA?S;RKm%5c9+XY z`5H^(8uGLX973Pu;Ty&(a%B3mk%lgg()sKkjUBhj9uzQ2h`Bv(lBqnlgRSan>h>CaScwPk;t~_pNiUF%-GOki|~LfJ$4ZrH7eeJ*!w0nOz=5n6yYVWB@`*;mJ1B+D4L>%*rGP3i9gPQ9Gq z>*(Dr_{~sx4}4qN0CY?QeEd&1jtj{f(}{6`S-u|GwcMgKQ9%=rZ%&R~#J)7hXu_ z+jo2UI2XF)J`oHSdO6=4b>Y07H?Wpy!ig{S6RS5h=~Q{7=t#173;IGFO_!tkQqUaxD7t4){?9j*SZNJETn=g6Sz+ZbGTm{rr zOzENM02PR=jU-)LrS{dEp!&6!F<-SGH#av&!1uj;?}}FTOVWxR@jGeo`Ti6^;SD7y zg`eEpH;R|8JH@_v?Ys^$j%5#b0?~;)>#xA)m2oW*d2aHLzAjhx#sH#@?p&L(6X#oU z1y9L>)vNu*J{DZw6J0BA%8K%eT_q)WBQu0$Bv{Gg}dJ>lTyZw9??ER|m08 zG;RN_Ib0O|8s2Nd4o()SxDEZ|d!f=q6~XGZny>rK3xwizV7)V(EPXh!q?8 zibE&7-=*j)-Oc~au#2BWSiq;SuuBp1>8RpwrMXs-sasUuKNWrcF*g6j&qp|OC3!0N zFC6IvlA1D@$C?9sUzuy$*pp9&)GnlJtfn4ymbqmRuK0Ykk zv^$*&yMw401KcR>;P%z~bH~X3S1dbRo(|{4bq!P5gx>alwShatW}NZa3vXnn$&yPY zfVvi{Q=xf*g;_iAHhs);rpSA@TAUs*434*vv4ho-*NbTWe1s*PTs)mot_w#y!*N9* zyk2(B1TjG9H5xLECwgCZ7m*HL7H79w2~(+N9g(vjBb{6)zACd#Hh3iVlf-k6|hfw+JM!;b=ID?CV@Zw=?Y_W9#Mgi!5C#@*jgN`TH2K#n9UU52RSK zlcz8n%_O7S?=X>1N9zS)WHFEzd(L3fW+rg+4%X@i>?JDDO5oICII)+<)9I+vq)TIf zO!uQDQZdts^yHpMf<<4@VnIlX<-TR(Zr1v+p}E$C(%oC4@7la>_)6&%!=$el#`ixk zQslUvo-sF|F&c4~h1hr%fZhXQXB*&6^gmGFzmCklv_REI`DlVb=YY#6;I5X@JUdtP zisao41?w~wFiSrGFVH8Vuq(CwVYnBNa8c9+Z=t)Rr94TVOiaFPc)KU9he?cpc0fD) z1mdR^Bc3K1kcAim|JQ67DbD*3{J~u*rc1ug8rux3=gt?=TiIw=QCI{wuyq$moh^cP zT3t!%{W`KS`i!GT9cYWK%2D4p+G95|=Uw;4FzYl3`80Bvu}h`EpJ!YB`lS!#tm0Jz z4?ij2=p;qXE`|A9#rb$Gve`Wi-sYTmuP=hE>p|OIF9iKBGnydKGwd_&9qvj-XU-?W{N{x6;vaYdh-=^spc_8|wI=nBKpxxsMdy zp!g>)jqX}cZ*KO|dNeaG_PvhMfW33$;U@E>&pELAJZU#|;R|o1IW7mFY-ZNXcMmQ? zS#`?UA2^Ybu5_QpF&$y?wtnWw;CJ^Gr1l=J71I2O3haW8g_CcP^$X;ksWhW`_$7bj z`Quj-*S%g2pjL%@UlzDO*;Bu;&2u9xuJUYU7|gmx{dAx5axYHd4}qZyw#3s>Yu6eF8j+fq%|!!LPell$K_% zCsR=8tn6Q9=6+I8+2m104L@%z?`-f!Hl_28d0PiOPzi1PNQ~kxAu%nX`x-jYzWena zTEj2aXtzQr-Z$XI7*HJHp9y&XSmPhLDgWb+RQV%D&xl8v`U7_^h?h6WK7Ko~>RDph zHl_c}^aB0#N$h2^_<9$Ku^qfZRsP36ecx*K-biEy69*^JXez!=-zKFak0*^ThgprG zU37?ORUmxnh8z@OyTC(DZ$chDmPc%fF7(z%e4#$imGaZIX53;2XE~*z9Y{zJ0!s76 zXBr;F+L!QCA=VZI04_h!S3dD4 zKc@;CVl0mgqY2CbKLRESOhx(qefL#By1)p8CaODr(3&Y8fnZiG5DHk4iZ+tzuhy8> zSb`&G=c3Nv#>!((5^M|2;R-o+O!D+~5I#7FGsVQj3r6DkVvtq#)yAC_OCw>fU=>fuIY;hVtta#|rwZ{HX@`n<^IVgbg| zuI%%(nrPzHo&6ADGs%Ux*v^kn6YSY?r=V$%4GShTUpWhuCO_z6EIp@wR;BYn0o*8k z7j``xNG$^T2C_78u10(BesJfV1%c(8X4Li0}9uc}lFaGpa#RObTt%WLvpDtp9Q z-jTEIlYr>;k2czqA@4j;r+douB5#(o=p^5QRr*0~f_JeUit0VW^%|p(olo&2rZH6U zoqJ%k;!y3vSZc8L3%+g;OE2n~8E}|)E7$8~-%o{=-v!~={{aYydZP}C(^4U3KS#<% zb=9894j>kvToFuKj4*UkG1}vffmAr2R$RCwSCCuN43}yhEIO@Z7Ovy9pgm5Ro&x2! zvP%^HTn+MPSM!J){4QSH|8{QRBQ%ft4(-Efk%+Qst`KH{%`Qvd9AIJv z*>u@=-$HWL^+AP9jsR^S5x?W-TQ;(s9dY>SHwf?jt8khT_RJ$%g+Gkzwa?ClZV-JW z&w=;1eR_l=V(RU$XD=3Xkf$>w_4NHqUavd9%Um6fmfL+XX2mvuze}1<{}dSP%o4(l zu-TSf)pqyc`M}}{sqp`l>*cm*CiUTJW~CVvB$NJc-045M*Z=nCJg9xZg(GBmM*!0S zZ+pMbm5oq+Jt1-+E{9%Em!9{abap^aq0a%9n~p;sqv^@-&uWP&d(diV!gKzt&1-PY zz@lDlFO7LF?KkeX^h*cLu*2X%Tvzt~hvWF)is}3_uM-lS4O(TYtc`viq7tIPVZ=1d zQz3dG_rgd@O|H-ohF?xWy38D_+M;Mq>d-Qb*CTzCyeH9buVblE_yPz$5I{e~#{|P) z?e+O!CCS)Uz+)sp_r77U9#`p(CcQCwYX(<@Fl<+3x|kc*ROIFQn?d{+tVZ!1RbMA9 zE5TK2KYKmL_{%zG3hC!DdVsz?ps#&;bX(Kel~YaRpl+dYVA8v7W!|gPd6=tA36PVdL>Ep#pfo<5caWa@Gf`Q z9uqsoT9a#_@M|S7dBTXh^)-Z;l9Rt8|GdnRqOCtBX1&h!8aCy`f_8D(J-yaSQ|6bi z*7yeQCNieOd4bec=Ugl24;4ZE)@y^26_k8VJN#VLUd6VuY<2_M63`ObM7JjKJXxF9 zanU%yf>7nAH&pxwdo!K$~W(Ve`m^c%qQyj?%^ znS@5h?dg|pJJVumHaktkbtSIi4AlpABcF#q)piIwfMO;a^Tih62#FF*oBtE$bmSku z;6}6)q`fisNbEVC>Nqm38iYD0+Toc%m0qF!$Ol81kqTi)iJ#BS^DjzMOTgt|%f8R9 zh{ZePnnR|>J3cFsZo|SC>nFP_a(X5Y#LhPPJ?JYl)7X{@v3otdD%|v9rStEWz5lm1 zoc&+$y8mTt@<09U(cA1MPeW8@4}}TnaQb;?79i1NPP@{}C-J;ILnjDbd6S`>u&07m z1QH1RWUtWYpi<4U)LOz$L6268UjeR7w2?ULEWfV07PI%Cx(Wqp&If`UAZ4n$ zH8_!9QHR4Uz!(V#?EYpL*jix~$#Cf^%dZOYy{l`i-H19sQ+2f=$v5(gi=19LJu9mV z&q_IOI8ZI5rkaMIGYF1!OgZGuDC!5A^I;ic-Kc^f=`>xA!iH9;E6LLcV&4 zpNSMqm00Ot@Y{c}s0utowKQC8bOj7QOJl-QzM1Z;ao-U`Vyhv=7p$muZFu2;*c(_}z zKnlU}pfKxCS2@CExz*Z#(yGMG`l`)v(*|_;EbPH|-FFB!=AMklHb~W-)6mE?y}L;zxygol8~p~VeUKwT@TP=-eeL@B6&M*MLX^&e}f|DO!xe=?qb z{M`}AU_1v#?+)}j&7Pz|0ZUz-jE_}yPs%>iIs9Y?>JAPanjChJ`a`dF%{g`smfODS z2wlG%;iBMa3~j@G-hfN3jw|P8SyiI*?^%C% zjhphI|KcJ3W*GLtmK2Da3`q4NzW-c(A0GcDBPNvFunxL3-3X14lSsTRKin{m4hHQ= zk_vCu{bVE?vcPphe%A$+_bW8bzpi~{L~0M%jMx!BPm>>&y0$0t2XGsFftuJWu!V?(ZA{C?35P=$2MNK8wFsBaoQJJPG=7}dCBW72n+UF_ zn9~<9qMtT#-+wbmAU@t6g|fAQN|F3N`gtFU1}di-^#8x+zB{O?Ze2eJh>A3&3q(LE zQkA9x2}lP4fsc+vL`4iu=_N$zO+rynP@;4Y2u%c}N|h=|?@fByp@aZQyxZTLGxN>4 zr_H_R`|j@#X0!KXlF76-Ukq1U+fN6wvdlG78aoo9*W)7Nd^I%Zl}uTC)V>BgfYU!0esZ$0sNn6l`Ak?qODUxd?Nlj$dXU(P+ens~!fK%M z+xus~n3fT3dkAu&-Kn%Wp zpRGHpu^-2$+h=U+P!^A8hl>}LTtoSqq^z+Ie_hL0GFY2g77uL7pEXpKwS=d{lk%Mv zW@%VDpcVtC@t6aLQ@;Ln1zf6b-nHU{OC@jAXuTAnpBb zzL)wZU0J-0oGd{na@W?SBGL4B z@B%so%eexBtoM`CyXbHkethJ$1;uFpXvIhgmyFB$XW2aLf(pQSxeSa%447ME;})wl zZeBF7TqoR<1*Q1-wEu=e{MR<5KltiGbZr{LM9Dq$crK@)!`}%y>@XIfLPd2hdO7O8 z$hinA+pR~0Azs+-xUhlQ3|{p+b(GZcW!#`VsmMq7wPiw%anH3XjF>Hvq7Tw~n<5+UIshUu5QObYL|0g6aILb<-NM`z1?)ujs z{O^%Y{)!rZz?F)@*Sr}Tmf{_~(LGpf?W_-5uce+RBB1X#YzuYDO~2=fD7L&Aso}l8 zJ9#~S1^dRY6_fYSeA{uXfPn`8hFJ^Y<{!Lf`SOr@Zkl#^O1cE8tShcfwY_Mx(~451 zyu^Qssi7+Jkza^z-YMjyY3EC|=eMiSv`nd1oudrZp{7=YmKAe#PwIoV5btRrzHNi~l#ReC(5TJ#A zRG1bEAN9Xk+Wh_M{onpib@{g~ zCkfY$%<|gE2Vq8FE`SEba63H(r9PKZ1~bJEH|j;J3FXZ4^q%;NwC}_ng%iU+AqoQy z#ElyTHLh?jsD+u)FrylA{RLhvzFRSY=X}sD?fr((QQYJ02~Fu~fWI$%sP?A(-JzFE z;XPM14EChQV3RPdFE7l!=cdC01%Z&5{kQ|d>jQ_FGp~L^kku5qFXm-jt-5&7k=rgu z*XE_*0}z^xp8oOh;E~1&WaH*0H9W|dBt{l>m?kv#?8%D%&&BMysL_iB(B-|0d`|P1 zb&*Ea%B|N+F913fMX)>Qm(UuF=$k{l(&>K}rFoycKC(jTIAmI~f}IBB6K6VE7!poO z-lOh*8{V~a$fSC^k`qVA73;naPK z{m9QfA9NY<4(tk284(x>3$Q?>RHHVljQ#>Td{~x03=6b`bUE+ZwYa}L)bqD@K)Vcm zD!zcf+Gu{Of#NxvCQ9VWTs>D*qaZ~HXv2zb_nqCCL|YL=79acFB#O5M=c`RM)!iS@ z+jeFf^a*dYy$IBVdLk7pcA7uzXrgD-4u{3Yl|OusNi^4L0}arh{7Cub*!eo{kC;EV ze7N!oH;SZ$^{lnW95WS7neQboLvWcjw7_rFd^z*9UwI01mHd4}&{931G1+rDUzxtn z(vHf!C;bRm#E#*y0GYuWyGs)t4B076-7<_84spTt!|!k~fK#R`dy*-w#nL2}QTA^8~)s*zKv$7)U+NSg9}hUP6Cu!&!A`Iu}b!D?g^Uir7v z=hyu&A-z8($o)s#RpyxJ!*?e#gSavtW~nSsu-!QUzlHo-qX!_r_zfMb8Br*OCfMU_y9k;}*yA{j7?JM`mk@b$r%*E5qA3@g&hZ zaV^(5Cw(q8p@2*Gs0q|wU%t3i}!8F281aFy(6zH z6)u2^=%nrRy6oXP$jbI+YSVtVQ(HVwli$vWbR#q#$2|<=Ab!8m*?njDGGnRgQ41+t za2uzaCi#Zo&^~{K=QdL;x3^UqN<(=*K|xgDh1~#45d8Xmk2Ez^Ftv659}UF6|9shl zi5^)eT*W>n_d&mh3m<~me+7u@>WG!;dSuCG(0aKKP7$?cdn0NP0=4A`htl>*rKh27 zN6I5)u_+Q1(9-VrvL!wb%kYp6s0sXG_-%U{11?h|HJ-}_((lw&z)CxW_bJiA)wm%yF4g%CkgN89q>ShYUiTH(gYq8Etlp>3zS{wR28Oi zQvnViNKE)xY}NWrs}TE~q;Ul0z;#*QwmoD3|-LUFU#LNXgGD4tMu}n3*AHtB6mv}d@DRSH6SkD&U%^ZNE ziQ{}ls=IQk!+QaZwj3qO$I;V0snco=&2BE2;MB&Hn%?+o&-B_YVFOtsOs ztCveC_IpJfw}_VWg|b)~J>M5-)(hH9I;I#Ju`yuqwDTU=})+ zm_Kq^Z~WT3OK#p7Qt*fZQp|jRMfI|8f3TG-h#*&v{k@9Ke}A?z^z@XNiGH8l^Si2y zFC8SlV-FUy!k7AMT~&!3bIvN6)iot6eF_)Q1&P}{fe%XG`e@BLxSW;1-KMNEfJ(Up zCAHYSAs_!nK4HA#vn-3xnub^Ezk+?1&-m&?QHa^C5I&}FQJoj)ZdD@Yv2jeL9u&`2 z77fsAy7@heCWIP-qV*kgSFfW%c~J*tZiuTFE?{VY`i5?yru)4SiwZl80a+c*O!Z!5 zv5A6p@$zwx^5SO_pfYX5v0M_ZP4^Q%HvE35lCy8osE-v{jve^1=J=_XqnpP&B5L~v zG(*;I;40Ju@TFmrEcET3x_RWpXsO0ns4GvR>=d_((8-pY3H$tu{RUK5_`e0?zlTAY z4QqND5U{O?wfxqxJ4VmI8;uLhU7v+iR{f>h$;dag03u|o?a(&SY2nRMCj?|ld$NW+ z$d^GrxNjUHoH>WhS4!KT8GgFCZC~bpx9wV#=1<6InN7WYa7Z$JOe@u#A0!0mZIdubQgDZEoyP?_dy;uzfyuB3f8gXiwYSbI}XrNcLZK}lb6uK`ev>ZB6dgbs{xjcC z#SgGx9syXQ2zuQFfBZm-qD<+oPRnt~Z{|iJ)k;36$yJ$hAwOvMJw5Xj?t2FFhM_~@ zo7#5@)#^ROU zvsI7Wy7{dl<%hU$Gyptk(wz^3{1~P=(t2W#Gxq}6^XY4V&Lh3(90jlO4u--vw7u@todkzi2@Ty=vW+6?5MScg2y0+dGQZX=E;_k%d}_4)qBU|{ z%u*beZnm1B!5w+mc8I zAHrQH+2M_n%G4zpN5;ItWSzf!lyWLgmN=K-bfArJy`6ED^kHiaeu5~T>Fss>c1A%y z*9D8dCQ&XLg=;h(i`AU6%KAv0 zy;HX`S@3*BJK|^=e>?jvVBeW-?_-x6K95Lg6Zs`96m1=+wrqe49z3CnmZA zum|7a4qY3d|JY#xDB@M70E#R;oIa_h<^SxY|Nn?*qc1FkNo%YjOWGbWxJ$|A^sg#r08S8o)N;{0X zYM`_BCnSn*&8E`vq+~aWU!mF`tiSU6K?R|uxj14E5RmXT8%7Q|1eEo|l z8nN{LCoXQMcscO@Cc`Ck*D$~fw{oeIkWG%OV}HnP9@N$XXBZn>_A0hiJM9-Eb4A0I z^EnlZ49nAUs=Y$CZRebg(IeL~&{M8hy4M~8(A1}JtHWTeA=!FnN3ZIsNKf9q z&~>Ev*)NUrN%CA~W<1PVH$yA@ub7d zIsr`s8dtq8k|c@IM_Vf!-`ViaWcE5xpeo&R3CC=;d^?swPZoGM*7|ae;nJ|pw=Mnw z*|p^%q<_Q(OMxU|E^{qoHBp`*hCr*pDJY(!q*Xny z7b{q)OjCOf|T;%LI(F|FxF;lZyt&0EJo`Nntp6TbIL?E?vG!#FDOJ0X=H<6>SF@xx_7A$hv| zglo7v@0czVae((Mu66p9;Y@uLAGBh4H?PS~*f5Plkni4=(@F-4SD29p+vGg-OgxlD ziT2`F^iPOGbfl8~?B1kk{{wzcYtPM+G*FIbWQ<%(Zpqir>^ogB=WumS3ZeL>Xx^GdLKz(?vc@w%EzTP(HJoYjt0>+EH zTtV}J5G_E2H(g@0&DEPqTEg)*tfvy?_JO)8YE;E-;SOJYly_ zz!k~^Id~Q%aG@U*lbH#t2cf$0I}KwU2hmWbOLfdu&eXK`uDaI(HXF?i*5l{UB(`Bf zK7Qcr3WXIV(?BS5JE#BY$|MySxfR`f1zU|mT~>j9hqg$?VCgCJJ}8nQUaPsnmgoBb zZ=t7%`HS41k#w#EFfzE$nnI!@7lQf%MYclt?%J(q@CR#-HP_O63Qr$S_CL$D;h%~2 zIbdJa!t=DN&fDSIQSQyyGl9}Rd9DF!xsL*+@eD^!ny-JM7|;%RP1rv%#Xco#)|C#)H?+OTw41eh^ZGPtk3Lg@{GrC)qbn z(o*o}FtjDTHp8pQ>%m7tl;(Oq+m1GHBjS?d4A2q1HO4wr{BTDx?n+#@%tYqI0>ksl zpAZl8EnlMdkt9hR*bf1-=E6W-ve4VPNTH}E^@LH$v4uu@oyXo+6UDvqNjp_NbNP(g zY!7uB5yS__91o7zraK4-!;7dyC<;!FJ6a>fg{l)(X(0`_U{sL4YIBrR_bO= zw<}baT4L4Ri&WB}1d9bvO_SmELlJ7?B3>#4{ivQO3~i+ukjSq5Cb`dAG2q;osynzb zu;e%)ubs)1B24wYh^i4c2^~F0ITZ|~&d-zs=;rM3hBO5?7W>K<4A&11(HRB{PVQhF zmD#vnuy@FXht6W2f~G76`ygB5jI1@vU%j3)Vd{qhq$wiXAxYJHwc^sXAI#`XO*;JfDR`vMB)Iq}Kh_bnt zpg^Qt((V4`{TAmvkqD^5rUeDBwLdRjw@bCB!3kCnxwxl-(}@XR^u@sVwl43!wAcj> zBQTGrtR@(ggJ;8YG{cEP$?NWTwjWiMfVdy+L}sFVv{KSl;mXL9;lMT?;RjarBh!Sz zuXQm%ur_`H2yf;^>KYpid=8ods7CGw1lyW}BSmw)cA657_fiI1YLZf}^}y-#DbJSQ zF*S5cQBHW@D-A9Tu#TVKl-Lb5IjHSq>aPn2q)05E33q_;w#RcDYS#FBxvHXa9abtM zd)6N2ZRB#?P=^S0YaJV<7fnMsX+=(+bqJQ7W=xPT9|EY|&x7pe*?ryV=#+H|zt36| z_H@nJLXryJv%RdZoL4Ylz8^UzMyO>TV-}Pn44{@d|{;XQnGLJ z{l^er`IQTgH$8=sU&~`=8*z0|$|;#6_BMsMKoN_xtNc}BV3)~>$0|^#8ROvuk{EFX zoSDuqdoq9z&0ZA2k7fM#lSc22#Q3v&7-8*fKmNQgt zbD9|<^hhVV{>;cY=D5$)E$u-kL4@R-!8G#IftgKO(W$SLz7<4ITN|iM@SPAtPTa$7 zxSm}xsd$MLK_Zw}Bm_qnpz$>WR5NJDW7wN}xb?C8DND;$`O=Z;hGhBSQ^ug4@baq9 zrH)Y=mH}=E#RBfoOB55{paot_?7|~H!vvR^Lsg{SwB)vCU=lXeIOZvS9}>y4h;jrC zdL|oPn$xQOsTA9c07BPHoK5VDLO;@um)a~eOaL%X;iL4L9YQF$evATp5gb!%HpD<2 zFyAcWf;tSitAE}68oZ|LqUjU#QljuTkW$z>O%!t7Fahr8)CD)y11S$nTm<%SNIV&* z^R75=ki+X110Mt9yYvo9n{hc9-WXKIu8uvfx)|9UU#UEpd>-@ItW=EX2q?7H$6~4X zI6eW;Xix0P36%vMXNKnDcXk6hE^T(O_X%~YfG~+|pHK$mYdFs-^pUJj+tW;s`pQ6$ z93KIg=Dc@Is})fcKMy!jk|RPu6+F00eTlXX+jN#Q4{=zza0Y#SbO1e5+4xRShj_yn zZTYdV9Zp+LxA%@|x-0hO>jI<|JbbtFxxH;&uzvAJ;~g8D_Tnb)*!9gD&o=eZR{|Bm zG3g5I59ANhLmXfBgNCv-9y1No8xP%QkU8)@Yz=&Q(2dt4JR=U{OMwZJ5k4tv?!5Jr z#>a}N*Ni@+E|rm}irjZh%L)W6_)gUU5=3v>=`a|TdfIh7GcCOQ+>;gd4NT3s@Jhix z>F}eaEVMG3g>oUlf}Bn{KjHJab$B@K5?(SRE_6`9>g6041(lET2M=bM zNM|_+VdS)$TNHURrcNqVNA41DwmqDrQ5&T&E6^@rdbyD zrDN1fVFioeuZG!B@NxzK^Yy7Z*=hi+C)0Q zd~RJIq><6Azyu!H_vXM!svU}o%DrZ>S;*n+WFI8#28dw zJ%gmNuLuk7;0EW(VzKxwQZxs8^AjcLA@u<@asNokM{L$5OBox!8i84$oV=0{Qi+&(TO2H;3P z`d08cSC@H(VHM2WnTgk@u=|f~QAglerac{tAjg?c6iz)oqvULS{3 = {}, +) => ({ + targeting: 'I' as const, + chatBubbleType, + ...overrides, +}); + +/** + * BMS Commerce 데이터 생성 헬퍼 + */ +export const createBmsCommerce = ( + overrides: { + title?: string; + regularPrice?: number; + discountPrice?: number; + discountRate?: number; + discountFixed?: number; + } = {}, +) => ({ + title: '테스트 상품', + regularPrice: 10000, + ...overrides, +}); + +/** + * BMS 쿠폰 생성 헬퍼 + * 5가지 프리셋 중 하나로 생성 + */ +type CouponTitleType = + | 'won' // N원 할인 쿠폰 + | 'percent' // N% 할인 쿠폰 + | 'shipping' // 배송비 할인 쿠폰 + | 'free' // OOO 무료 쿠폰 + | 'up'; // OOO UP 쿠폰 + +export const createBmsCoupon = (titleType: CouponTitleType = 'won') => { + const titles: Record = { + won: '10000원 할인 쿠폰', + percent: '10% 할인 쿠폰', + shipping: '배송비 할인 쿠폰', + free: '첫구매 무료 쿠폰', + up: '포인트 UP 쿠폰', + }; + + return { + title: titles[titleType], + description: '테스트 쿠폰', + linkMobile: 'https://example.com/coupon', + }; +}; + +/** + * BMS 버튼 생성 헬퍼 + * linkType별로 생성 + */ +type ButtonLinkType = 'WL' | 'AL' | 'AC' | 'BK' | 'MD' | 'BC' | 'BT' | 'BF'; + +export const createBmsButton = (linkType: ButtonLinkType) => { + switch (linkType) { + case 'WL': + return { + name: '웹링크 버튼', + linkType: 'WL' as const, + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }; + case 'AL': + return { + name: '앱링크 버튼', + linkType: 'AL' as const, + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }; + case 'AC': + return { + name: '채널 추가', + linkType: 'AC' as const, + }; + case 'BK': + return { + name: '봇 키워드', + linkType: 'BK' as const, + chatExtra: 'test_keyword', + }; + case 'MD': + return { + name: '메시지 전달', + linkType: 'MD' as const, + chatExtra: 'test_message', + }; + case 'BC': + return { + name: '상담 요청', + linkType: 'BC' as const, + chatExtra: 'test_consult', + }; + case 'BT': + return { + name: '봇 전환', + linkType: 'BT' as const, + chatExtra: 'test_bot', + }; + case 'BF': + return { + name: '비즈니스폼', + linkType: 'BF' as const, + }; + } +}; + +/** + * BMS 링크 버튼 생성 헬퍼 (캐러셀용 - WL, AL만 지원) + */ +export const createBmsLinkButton = (linkType: 'WL' | 'AL' = 'WL') => { + if (linkType === 'WL') { + return { + name: '웹링크 버튼', + linkType: 'WL' as const, + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }; + } + return { + name: '앱링크 버튼', + linkType: 'AL' as const, + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }; +}; + +/** + * 캐러셀 피드 아이템 생성 헬퍼 + */ +export const createCarouselFeedItem = ( + imageId: string, + overrides: Partial = {}, +): BmsCarouselFeedItemSchema => ({ + header: '캐러셀 헤더', + content: '캐러셀 내용입니다.', + imageId, + buttons: [createBmsLinkButton('WL')], + ...overrides, +}); + +/** + * 캐러셀 커머스 아이템 생성 헬퍼 + */ +export const createCarouselCommerceItem = ( + imageId: string, + overrides: Partial = {}, +): BmsCarouselCommerceItemSchema => ({ + commerce: createBmsCommerce(), + imageId, + buttons: [createBmsLinkButton('WL')], + ...overrides, +}); + +/** + * 메인 와이드 아이템 생성 헬퍼 + */ +export const createMainWideItem = ( + imageId: string, + overrides: Partial = {}, +): BmsMainWideItemSchema => ({ + title: '메인 아이템', + imageId, + linkMobile: 'https://example.com/main', + ...overrides, +}); + +/** + * 서브 와이드 아이템 생성 헬퍼 + */ +export const createSubWideItem = ( + imageId: string, + title: string, + overrides: Partial = {}, +): BmsSubWideItemSchema => ({ + title, + imageId, + linkMobile: 'https://example.com/sub', + ...overrides, +}); + +/** + * BMS 이미지 업로드 헬퍼 (타입 지정 가능) + */ +export const uploadBmsImage = async ( + storageService: StorageService, + imagePath: string, + fileType: FileType = 'KAKAO', +): Promise => { + const result = await storageService.uploadFile(imagePath, fileType); + return result.fileId; +}; + +/** + * BMS chatBubbleType별 이미지 업로드 헬퍼 모음 + */ +export const uploadBmsImageForType = { + /** IMAGE, COMMERCE용 이미지 업로드 */ + bms: (storageService: StorageService, imagePath: string) => + uploadBmsImage(storageService, imagePath, 'BMS'), + /** WIDE용 이미지 업로드 */ + wide: (storageService: StorageService, imagePath: string) => + uploadBmsImage(storageService, imagePath, 'BMS_WIDE'), + /** WIDE_ITEM_LIST 메인 아이템용 이미지 업로드 */ + wideMainItem: (storageService: StorageService, imagePath: string) => + uploadBmsImage(storageService, imagePath, 'BMS_WIDE_MAIN_ITEM_LIST'), + /** WIDE_ITEM_LIST 서브 아이템용 이미지 업로드 */ + wideSubItem: (storageService: StorageService, imagePath: string) => + uploadBmsImage(storageService, imagePath, 'BMS_WIDE_SUB_ITEM_LIST'), + /** CAROUSEL_FEED용 이미지 업로드 */ + carouselFeed: (storageService: StorageService, imagePath: string) => + uploadBmsImage(storageService, imagePath, 'BMS_CAROUSEL_FEED_LIST'), + /** CAROUSEL_COMMERCE용 이미지 업로드 */ + carouselCommerce: (storageService: StorageService, imagePath: string) => + uploadBmsImage(storageService, imagePath, 'BMS_CAROUSEL_COMMERCE_LIST'), +}; + +/** + * 테스트용 기본 이미지 경로 반환 + */ +export const getTestImagePath = (dirname: string): string => { + return path.resolve( + dirname, + '../../../examples/javascript/common/images/example.jpg', + ); +}; + +/** + * 테스트용 2:1 비율 이미지 경로 반환 (BMS WIDE_SUB_ITEM_LIST 등) + */ +export const getTestImagePath2to1 = (dirname: string): string => { + return path.resolve(dirname, '../../../test/assets/example-2to1.jpg'); +}; + +/** + * 테스트용 1:1 비율 이미지 경로 반환 (BMS WIDE_MAIN_ITEM_LIST) + */ +export const getTestImagePath1to1 = (dirname: string): string => { + return path.resolve(dirname, '../../../test/assets/example-1to1.jpg'); +}; diff --git a/test/services/messages/bms-free.e2e.test.ts b/test/services/messages/bms-free.e2e.test.ts new file mode 100644 index 0000000..b992129 --- /dev/null +++ b/test/services/messages/bms-free.e2e.test.ts @@ -0,0 +1,1188 @@ +/** + * BMS Free Message E2E 테스트 + * + * ## 환경변수 설정 + * 실제 테스트 실행을 위해서는 다음 환경 변수가 필요합니다: + * - API_KEY: SOLAPI API 키 + * - API_SECRET: SOLAPI API 시크릿 + * - SENDER_NUMBER: SOLAPI에 등록된 발신번호 (fallback: 01000000000) + * + * ## 테스트 특징 + * - 8가지 BMS Free 타입 (TEXT, IMAGE, WIDE, WIDE_ITEM_LIST, COMMERCE, CAROUSEL_FEED, CAROUSEL_COMMERCE, PREMIUM_VIDEO) + * - 카카오 채널이 없으면 테스트 자동 스킵 + * - targeting은 'I' 타입만 사용 (M/N은 인허가 채널 필요) + * + * ## 테스트 실행 + * ```bash + * pnpm vitest run test/services/messages/bms-free.e2e.test.ts + * pnpm test -- -t "TEXT 타입" + * ``` + */ +import {describe, expect, it} from '@effect/vitest'; +import { + createBmsButton, + createBmsCommerce, + createBmsCoupon, + createBmsLinkButton, + createBmsOption, + createCarouselCommerceItem, + createCarouselFeedItem, + createMainWideItem, + createSubWideItem, + getTestImagePath, + getTestImagePath2to1, + uploadBmsImage, + uploadBmsImageForType, +} from '@test/lib/bms-test-utils'; +import { + KakaoChannelServiceTag, + MessageServiceTag, + MessageTestServicesLive, + StorageServiceTag, +} from '@test/lib/test-layers'; +import {Config, Console, Effect} from 'effect'; + +describe('BMS Free Message E2E', () => { + const testPhoneNumber = '01000000000'; + + describe('TEXT 타입', () => { + it.effect('최소 구조 (text만)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS TEXT 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS TEXT 최소 구조 테스트 메시지입니다.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('TEXT'), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect('전체 필드 (adult, content, buttons, coupon)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS TEXT 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS TEXT 전체 필드 테스트 메시지입니다.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('TEXT', { + adult: false, + buttons: [ + createBmsButton('WL'), + createBmsButton('AL'), + createBmsButton('AC'), + createBmsButton('BK'), + ], + coupon: createBmsCoupon('percent'), + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe('IMAGE 타입', () => { + it.effect('최소 구조 (text, imageId)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS IMAGE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.bms(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS IMAGE 최소 구조 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('IMAGE', { + imageId, + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect( + '전체 필드 (adult, content, imageId, imageLink, buttons, coupon)', + () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS IMAGE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.bms(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS IMAGE 전체 필드 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('IMAGE', { + adult: false, + imageId, + imageLink: 'https://example.com/image', + buttons: [ + createBmsButton('WL'), + createBmsButton('AL'), + createBmsButton('AC'), + createBmsButton('BK'), + ], + coupon: createBmsCoupon('won'), + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe('WIDE 타입', () => { + it.effect('최소 구조 (text, imageId)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS WIDE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.wide(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS WIDE 최소 구조 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('WIDE', { + imageId, + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect('전체 필드 (adult, content, imageId, buttons, coupon)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS WIDE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.wide(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS WIDE 전체 필드 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('WIDE', { + adult: false, + imageId, + buttons: [createBmsButton('WL'), createBmsButton('AL')], + coupon: createBmsCoupon('shipping'), + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe.skip('WIDE_ITEM_LIST 타입', () => { + it.effect('최소 구조 (header, mainWideItem, subWideItemList 1개)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS WIDE_ITEM_LIST 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const mainImageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.wideMainItem( + storageService, + getTestImagePath2to1(__dirname), + ), + ); + const subImageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.wideSubItem( + storageService, + getTestImagePath2to1(__dirname), + ), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('WIDE_ITEM_LIST', { + header: '헤더 제목', + mainWideItem: createMainWideItem(mainImageId), + subWideItemList: [ + createSubWideItem(subImageId, '서브 아이템 1'), + ], + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect( + '전체 필드 (adult, header, mainWideItem, subWideItemList, buttons, coupon)', + () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS WIDE_ITEM_LIST 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const mainImageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.wideMainItem( + storageService, + getTestImagePath2to1(__dirname), + ), + ); + const subImageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.wideSubItem( + storageService, + getTestImagePath2to1(__dirname), + ), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('WIDE_ITEM_LIST', { + adult: false, + header: '헤더 제목', + mainWideItem: createMainWideItem(mainImageId), + subWideItemList: [ + createSubWideItem(subImageId, '서브 아이템 1'), + createSubWideItem(subImageId, '서브 아이템 2'), + createSubWideItem(subImageId, '서브 아이템 3'), + ], + buttons: [createBmsButton('WL'), createBmsButton('AL')], + coupon: createBmsCoupon('free'), + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe('COMMERCE 타입', () => { + it.effect('최소 구조 (imageId, commerce title만, buttons 1개)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS COMMERCE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath2to1(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.bms(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + imageId, + commerce: createBmsCommerce(), + buttons: [createBmsButton('WL')], + }, + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect( + '전체 필드 (adult, additionalContent, imageId, commerce 전체, buttons, coupon)', + () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS COMMERCE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath2to1(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.bms(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + adult: false, + additionalContent: '추가 내용입니다.', + imageId, + commerce: createBmsCommerce({ + title: '프리미엄 상품', + regularPrice: 50000, + discountPrice: 35000, + discountRate: 30, + discountFixed: 15000, + }), + buttons: [createBmsButton('WL'), createBmsButton('AL')], + coupon: createBmsCoupon('up'), + }, + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe('CAROUSEL_FEED 타입', () => { + it.effect('최소 구조 (carousel.list 2개)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS CAROUSEL_FEED 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath2to1(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.carouselFeed(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [ + createCarouselFeedItem(imageId, {header: '캐러셀 1'}), + createCarouselFeedItem(imageId, {header: '캐러셀 2'}), + ], + }, + }, + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect('전체 필드 (adult, carousel head/list 전체/tail)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS CAROUSEL_FEED 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath2to1(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.carouselFeed(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_FEED', + adult: false, + carousel: { + list: [ + createCarouselFeedItem(imageId, { + header: '첫 번째 카드', + content: '첫 번째 카드 내용입니다.', + imageLink: 'https://example.com/1', + buttons: [ + createBmsLinkButton('WL'), + createBmsLinkButton('AL'), + ], + coupon: createBmsCoupon('percent'), + }), + createCarouselFeedItem(imageId, { + header: '두 번째 카드', + content: '두 번째 카드 내용입니다.', + buttons: [createBmsLinkButton('WL')], + }), + createCarouselFeedItem(imageId, { + header: '세 번째 카드', + content: '세 번째 카드 내용입니다.', + buttons: [createBmsLinkButton('AL')], + }), + ], + tail: { + linkMobile: 'https://example.com/more', + linkPc: 'https://example.com/more', + }, + }, + }, + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe('CAROUSEL_COMMERCE 타입', () => { + it.effect('최소 구조 (carousel.list 2개)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS CAROUSEL_COMMERCE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath2to1(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.carouselCommerce(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + list: [ + createCarouselCommerceItem(imageId, { + commerce: createBmsCommerce({title: '상품 1'}), + }), + createCarouselCommerceItem(imageId, { + commerce: createBmsCommerce({title: '상품 2'}), + }), + ], + }, + }, + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect( + '전체 필드 (adult, additionalContent, carousel head/list 전체/tail)', + () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS CAROUSEL_COMMERCE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath2to1(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.carouselCommerce(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('CAROUSEL_COMMERCE', { + adult: false, + additionalContent: '추가 안내', + carousel: { + head: { + header: '캐러셀 인트로', + content: '인트로 내용입니다.', + imageId, + linkMobile: 'https://example.com/head', + }, + list: [ + createCarouselCommerceItem(imageId, { + commerce: createBmsCommerce({ + title: '프리미엄 상품 1', + regularPrice: 30000, + discountPrice: 25000, + }), + additionalContent: '추가 정보', + imageLink: 'https://example.com/product1', + buttons: [ + createBmsLinkButton('WL'), + createBmsLinkButton('AL'), + ], + coupon: createBmsCoupon('won'), + }), + createCarouselCommerceItem(imageId, { + commerce: createBmsCommerce({ + title: '프리미엄 상품 2', + regularPrice: 40000, + discountPrice: 35000, + }), + buttons: [createBmsLinkButton('WL')], + }), + ], + tail: { + linkMobile: 'https://example.com/all-products', + }, + }, + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe('PREMIUM_VIDEO 타입', () => { + it.effect('최소 구조 (video.videoUrl)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS PREMIUM_VIDEO 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS PREMIUM_VIDEO 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('PREMIUM_VIDEO', { + video: { + videoUrl: 'https://tv.kakao.com/v/123456789', + }, + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect( + '전체 필드 (adult, header, content, video 전체, buttons, coupon)', + () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS PREMIUM_VIDEO 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImage(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS PREMIUM_VIDEO 전체 필드 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('PREMIUM_VIDEO', { + adult: false, + header: '비디오 헤더', + content: '비디오 내용입니다.', + video: { + videoUrl: 'https://tv.kakao.com/v/123456789', + imageId, + imageLink: 'https://example.com/video', + }, + buttons: [createBmsButton('WL')], + coupon: createBmsCoupon('percent'), + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe('Error Cases', () => { + it.effect('IMAGE without imageId (필수 필드 누락)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log('카카오 채널이 없어서 에러 테스트를 건너뜁니다.'); + return; + } + + const channel = channelsResponse.channelList[0]; + + const result = yield* Effect.either( + Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS IMAGE 에러 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('IMAGE'), + }, + }), + ), + ); + + expect(result._tag).toBe('Left'); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect('COMMERCE without buttons (필수 필드 누락)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log('카카오 채널이 없어서 에러 테스트를 건너뜁니다.'); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.bms(storageService, imagePath), + ); + + const result = yield* Effect.either( + Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS COMMERCE 에러 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('COMMERCE', { + imageId, + commerce: createBmsCommerce(), + // buttons 누락 + }), + }, + }), + ), + ); + + expect(result._tag).toBe('Left'); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect( + 'PREMIUM_VIDEO with invalid videoUrl (tv.kakao.com 아닌 URL)', + () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 에러 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const result = yield* Effect.either( + Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS PREMIUM_VIDEO 에러 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('PREMIUM_VIDEO', { + video: { + videoUrl: 'https://youtube.com/watch?v=invalid', // 잘못된 URL + }, + }), + }, + }), + ), + ); + + expect(result._tag).toBe('Left'); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect('Invalid coupon title (쿠폰 제목 형식 오류)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log('카카오 채널이 없어서 에러 테스트를 건너뜁니다.'); + return; + } + + const channel = channelsResponse.channelList[0]; + + const result = yield* Effect.either( + Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS TEXT 쿠폰 에러 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('TEXT', { + coupon: { + title: '잘못된 쿠폰 제목', // 허용되지 않는 형식 + description: '테스트', + }, + }), + }, + }), + ), + ); + + expect(result._tag).toBe('Left'); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect('CAROUSEL_FEED without carousel (필수 필드 누락)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log('카카오 채널이 없어서 에러 테스트를 건너뜁니다.'); + return; + } + + const channel = channelsResponse.channelList[0]; + + const result = yield* Effect.either( + Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS CAROUSEL_FEED 에러 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('CAROUSEL_FEED'), + }, + }), + ), + ); + + expect(result._tag).toBe('Left'); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); +}); From 9df35df87d319a2ede88ae61842342489379eb63 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 20 Jan 2026 10:07:19 +0900 Subject: [PATCH 08/13] fix(bms): Update test cases for WIDE_ITEM_LIST type - Re-enabled the previously skipped test suite for WIDE_ITEM_LIST type messages. - Updated image path retrieval to use the new `getTestImagePath1to1` function for consistency in test image uploads. These changes ensure that the test suite is comprehensive and utilizes the latest utility functions for image handling. --- test/services/messages/bms-free.e2e.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/services/messages/bms-free.e2e.test.ts b/test/services/messages/bms-free.e2e.test.ts index b992129..eae1d11 100644 --- a/test/services/messages/bms-free.e2e.test.ts +++ b/test/services/messages/bms-free.e2e.test.ts @@ -30,6 +30,7 @@ import { createMainWideItem, createSubWideItem, getTestImagePath, + getTestImagePath1to1, getTestImagePath2to1, uploadBmsImage, uploadBmsImageForType, @@ -346,7 +347,7 @@ describe('BMS Free Message E2E', () => { ); }); - describe.skip('WIDE_ITEM_LIST 타입', () => { + describe('WIDE_ITEM_LIST 타입', () => { it.effect('최소 구조 (header, mainWideItem, subWideItemList 1개)', () => Effect.gen(function* () { const messageService = yield* MessageServiceTag; @@ -378,7 +379,7 @@ describe('BMS Free Message E2E', () => { const subImageId = yield* Effect.tryPromise(() => uploadBmsImageForType.wideSubItem( storageService, - getTestImagePath2to1(__dirname), + getTestImagePath1to1(__dirname), ), ); @@ -439,7 +440,7 @@ describe('BMS Free Message E2E', () => { const subImageId = yield* Effect.tryPromise(() => uploadBmsImageForType.wideSubItem( storageService, - getTestImagePath2to1(__dirname), + getTestImagePath1to1(__dirname), ), ); From cce726e65e40256f9182db62c0f3568c517ec3a0 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 21 Jan 2026 11:47:28 +0900 Subject: [PATCH 09/13] feat(docs): Add comprehensive documentation for AGENTS architecture - Introduced detailed documentation for the AGENTS SDK, covering the overall structure, conventions, and anti-patterns across various layers: core library utilities, models, and services. - Included guidelines for error handling, async operations, and testing practices to ensure consistency and best practices in development. - Enhanced the documentation with clear examples and a structured overview to facilitate understanding and usage of the SDK. These additions improve the clarity and usability of the AGENTS SDK for developers. --- AGENTS.md | 93 ++++++++++++++++++++++++++++++++++++++++++ src/lib/AGENTS.md | 63 ++++++++++++++++++++++++++++ src/models/AGENTS.md | 84 ++++++++++++++++++++++++++++++++++++++ src/services/AGENTS.md | 67 ++++++++++++++++++++++++++++++ 4 files changed, 307 insertions(+) create mode 100644 AGENTS.md create mode 100644 src/lib/AGENTS.md create mode 100644 src/models/AGENTS.md create mode 100644 src/services/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dd07aa5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,93 @@ +# SOLAPI SDK for Node.js + +**Generated:** 2026-01-21 +**Commit:** 9df35df +**Branch:** master + +## OVERVIEW + +Server-side SDK for SMS/LMS/MMS and Kakao messaging in Korea. Uses Effect library for type-safe functional programming with Data.TaggedError-based error handling. + +## STRUCTURE + +``` +solapi-nodejs/ +├── src/ +│ ├── index.ts # SolapiMessageService facade (entry point) +│ ├── errors/ # Data.TaggedError types +│ ├── lib/ # Core utilities (fetcher, auth, error handler) +│ ├── models/ # Schemas, requests, responses (see models/AGENTS.md) +│ ├── services/ # Domain services (see services/AGENTS.md) +│ └── types/ # Shared type definitions +├── test/ # Mirrors src/ structure +├── examples/ # Usage examples (excluded from build) +└── debug/ # Debug scripts +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +|------|----------|-------| +| Add new message type | `src/models/base/messages/` | Extend MessageType union | +| Add new service | `src/services/` | Extend DefaultService | +| Add new error type | `src/errors/defaultError.ts` | Extend Data.TaggedError | +| Add utility function | `src/lib/` | Follow Effect patterns | +| Add Kakao BMS type | `src/models/base/kakao/bms/` | Add to BMS_REQUIRED_FIELDS | +| Fix API request issue | `src/lib/defaultFetcher.ts` | HTTP client with retry | +| Understand error flow | `src/lib/effectErrorHandler.ts` | Effect → Promise conversion | + +## CONVENTIONS + +**Effect Library (MANDATORY)**: +- All errors: `Data.TaggedError` with environment-aware `toString()` +- Async operations: `Effect.gen` + `Effect.tryPromise`, never wrap with try-catch +- Validation: `Effect Schema` with `Schema.filter`, `Schema.transform` +- Error execution: `runSafePromise()` / `runSafeSync()` from effectErrorHandler + +**TypeScript**: +- **NEVER use `any`** — use `unknown` + type guards or Effect Schema +- Strict mode enforced (`noUnusedLocals`, `noUnusedParameters`) +- Path aliases: `@models`, `@lib`, `@services`, `@errors`, `@internal-types` + +**Testing**: +- Unit: `vitest` with `Schema.decodeUnknownEither()` for validation tests +- E2E: `@effect/vitest` with `it.effect()` and `Effect.gen` +- Run: `pnpm test` / `pnpm test:watch` + +## ANTI-PATTERNS + +| Pattern | Why Bad | Do Instead | +|---------|---------|------------| +| `any` type | Loses type safety | `unknown` + type guards | +| `as any`, `@ts-ignore` | Suppresses errors | Fix the type issue | +| try-catch around Effect | Loses Effect benefits | Use `Effect.catchTag` | +| Direct `throw new Error()` | Inconsistent error handling | Use `Data.TaggedError` | +| Empty catch blocks | Swallows errors | Handle or propagate | + +## COMMANDS + +```bash +pnpm dev # Watch mode (tsup) +pnpm build # Lint + build +pnpm lint # Biome check with auto-fix +pnpm test # Run tests once +pnpm test:watch # Watch mode +pnpm docs # Generate TypeDoc +``` + +## ARCHITECTURE NOTES + +**Service Facade Pattern**: `SolapiMessageService` aggregates 7 domain services via `bindServices()` dynamic method binding. All services extend `DefaultService`. + +**Error Flow**: +``` +API Response + → defaultFetcher (creates Effect errors) + → runSafePromise (converts to Promise) + → toCompatibleError (preserves properties on Error) + → Consumer +``` + +**Production vs Development**: Error messages stripped of stack traces and detailed context in production (`process.env.NODE_ENV === 'production'`). + +**Retry Logic**: `defaultFetcher.ts` implements 3x retry with exponential backoff for retryable errors (connection refused, reset, 503). diff --git a/src/lib/AGENTS.md b/src/lib/AGENTS.md new file mode 100644 index 0000000..54b065c --- /dev/null +++ b/src/lib/AGENTS.md @@ -0,0 +1,63 @@ +# Core Library Utilities + +## OVERVIEW + +Cross-cutting utilities used by all services. Effect-based async handling and error management. + +## STRUCTURE + +``` +lib/ +├── defaultFetcher.ts # HTTP client with Effect.gen, retry, Match +├── effectErrorHandler.ts # runSafePromise, toCompatibleError, formatError +├── authenticator.ts # HMAC-SHA256 auth header generation +├── stringifyQuery.ts # URL query string builder +├── fileToBase64.ts # File/URL → Base64 converter +└── stringDateTrasnfer.ts # Date parsing with InvalidDateError +``` + +## WHERE TO LOOK + +| Task | File | Notes | +|------|------|-------| +| HTTP request issues | `defaultFetcher.ts` | Retry logic, error handling | +| Error formatting | `effectErrorHandler.ts` | Production vs dev messages | +| Auth issues | `authenticator.ts` | HMAC signature generation | +| Query params | `stringifyQuery.ts` | Array handling, encoding | +| File handling | `fileToBase64.ts` | URL detection, Base64 encoding | +| Date parsing | `stringDateTrasnfer.ts` | ISO format conversion | + +## CONVENTIONS + +**Effect.tryPromise for Async**: +```typescript +Effect.tryPromise({ + try: () => fetch(url, options), + catch: e => new NetworkError({ url, cause: e }), +}); +``` + +**Effect.gen for Complex Flow**: +```typescript +Effect.gen(function* (_) { + const auth = yield* _(buildAuth(params)); + const response = yield* _(fetchWithRetry(url, auth)); + return yield* _(parseResponse(response)); +}); +``` + +**Error to Promise Conversion**: +```typescript +// Always use runSafePromise for Effect → Promise +return runSafePromise(effect); + +// Never wrap Effect with try-catch +// BAD: try { await Effect.runPromise(...) } catch { } +``` + +## ANTI-PATTERNS + +- Don't bypass `runSafePromise` — loses error formatting +- Don't use try-catch around Effect — use Effect.catchTag +- Don't create new HTTP client — use defaultFetcher +- Don't hardcode API URL — use DefaultService.baseUrl diff --git a/src/models/AGENTS.md b/src/models/AGENTS.md new file mode 100644 index 0000000..333e57f --- /dev/null +++ b/src/models/AGENTS.md @@ -0,0 +1,84 @@ +# Models Layer + +## OVERVIEW + +Three-layer model architecture using Effect Schema for runtime validation. + +## STRUCTURE + +``` +models/ +├── base/ # Core domain entities +│ ├── messages/message.ts # MessageType, messageSchema +│ ├── kakao/ +│ │ ├── kakaoOption.ts # BMS validation, VariableValidationError +│ │ ├── kakaoButton.ts # Discriminated union (8 types) +│ │ └── bms/ # 7 BMS chat bubble schemas +│ ├── rcs/ # RCS options and buttons +│ └── naver/ # Naver Talk Talk +├── requests/ # Input → API payload transformation +│ ├── messages/ # Send, group, query requests +│ ├── kakao/ # Channel/template operations +│ ├── iam/ # Block list management +│ └── common/datePayload.ts # Shared date range type +└── responses/ # API response types (mostly type-only) +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +|------|----------|-------| +| Add message type | `base/messages/message.ts` | Add to MessageType union | +| Add BMS type | `base/kakao/bms/` + `kakaoOption.ts` | Update BMS_REQUIRED_FIELDS | +| Add button variant | `base/kakao/kakaoButton.ts` | Discriminated union pattern | +| Add request validation | `requests/` domain folder | Use Schema.transform | +| Add response type | `responses/` domain folder | Type-only usually sufficient | + +## CONVENTIONS + +**Type + Schema + Class Pattern**: +```typescript +// 1. Type +export type MyType = Schema.Schema.Type; + +// 2. Schema +export const mySchema = Schema.Struct({ + field: Schema.String, + optional: Schema.optional(Schema.Number), +}); + +// 3. Class (optional, for runtime behavior) +export class MyClass { + constructor(parameter: MyType) { /* ... */ } +} +``` + +**Discriminated Union**: +```typescript +export const buttonSchema = Schema.Union( + webButtonSchema, // { linkType: 'WL', ... } + appButtonSchema, // { linkType: 'AL', ... } +); +``` + +**Custom Validation**: +```typescript +Schema.String.pipe( + Schema.filter(isValid, { message: () => 'Error message' }), +); +``` + +**Transform with Validation**: +```typescript +Schema.transform(Schema.String, Schema.String, { + decode: input => normalize(input), + encode: output => output, +}); +``` + +## ANTI-PATTERNS + +- Don't skip schema validation for user input +- Don't use interfaces when schema needed — use Schema.Struct +- Don't duplicate validation logic — compose schemas +- Don't create class without schema — validate first diff --git a/src/services/AGENTS.md b/src/services/AGENTS.md new file mode 100644 index 0000000..692df02 --- /dev/null +++ b/src/services/AGENTS.md @@ -0,0 +1,67 @@ +# Services Layer + +## OVERVIEW + +Domain services extending `DefaultService` base class. Each service handles one API domain. + +## STRUCTURE + +``` +services/ +├── defaultService.ts # Base class: auth, HTTP abstraction +├── messages/ +│ ├── messageService.ts # send(), sendOne(), getMessages() +│ └── groupService.ts # Group operations (create, add, send) +├── kakao/ +│ ├── channels/ # Channel CRUD +│ └── templates/ # Template CRUD with Effect.all +├── cash/cashService.ts # getBalance() +├── iam/iamService.ts # Block lists, 080 rejection +└── storage/storageService.ts # File uploads +``` + +## WHERE TO LOOK + +| Task | File | Notes | +|------|------|-------| +| Add new service | Create in domain folder | Extend DefaultService | +| Modify HTTP behavior | `defaultService.ts` | Base URL, auth handling | +| Complex Effect logic | `messageService.ts` | Reference for Effect.gen pattern | +| Parallel processing | `kakaoTemplateService.ts` | Effect.all example | + +## CONVENTIONS + +**Service Pattern**: +```typescript +export default class MyService extends DefaultService { + constructor(apiKey: string, apiSecret: string) { + super(apiKey, apiSecret); + } + + async myMethod(data: Request): Promise { + return this.request({ + httpMethod: 'POST', + url: 'my/endpoint', + body: data, + }); + } +} +``` + +**Effect.gen Pattern** (for complex logic): +```typescript +async send(messages: Request): Promise { + const effect = Effect.gen(function* (_) { + const validated = yield* _(validateSchema(messages)); + const response = yield* _(Effect.promise(() => this.request(...))); + return response; + }); + return runSafePromise(effect); +} +``` + +## ANTI-PATTERNS + +- Don't call `defaultFetcher` directly — use `this.request()` +- Don't bypass schema validation — always validate input +- Don't mix Effect and Promise styles — pick one per method From d3174ed17de1c4235f9b97770dda900b758beaf8 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 21 Jan 2026 11:49:05 +0900 Subject: [PATCH 10/13] feat(bms): Update BMS Free Message E2E tests with new discount features - Enhanced test cases for BMS Free messages by adding discount rates and fixed discounts to commerce items. - Updated video URLs in tests to valid links and added notes for clarity on video URL handling. - Improved overall test coverage for BMS Free message types, ensuring accurate representation of discount functionalities. These changes ensure that the E2E tests reflect the latest features and maintain robustness in testing BMS messages. --- test/services/messages/bms-free.e2e.test.ts | 22 ++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/test/services/messages/bms-free.e2e.test.ts b/test/services/messages/bms-free.e2e.test.ts index eae1d11..911a9bc 100644 --- a/test/services/messages/bms-free.e2e.test.ts +++ b/test/services/messages/bms-free.e2e.test.ts @@ -763,10 +763,16 @@ describe('BMS Free Message E2E', () => { carousel: { list: [ createCarouselCommerceItem(imageId, { - commerce: createBmsCommerce({title: '상품 1'}), + commerce: createBmsCommerce({ + title: '상품 1', + discountRate: 10, + }), }), createCarouselCommerceItem(imageId, { - commerce: createBmsCommerce({title: '상품 2'}), + commerce: createBmsCommerce({ + title: '상품 2', + discountFixed: 2000, + }), }), ], }, @@ -833,6 +839,7 @@ describe('BMS Free Message E2E', () => { title: '프리미엄 상품 1', regularPrice: 30000, discountPrice: 25000, + discountRate: 17, }), additionalContent: '추가 정보', imageLink: 'https://example.com/product1', @@ -840,13 +847,16 @@ describe('BMS Free Message E2E', () => { createBmsLinkButton('WL'), createBmsLinkButton('AL'), ], - coupon: createBmsCoupon('won'), + coupon: { + ...createBmsCoupon('won'), + }, }), createCarouselCommerceItem(imageId, { commerce: createBmsCommerce({ title: '프리미엄 상품 2', regularPrice: 40000, discountPrice: 35000, + discountFixed: 5000, }), buttons: [createBmsLinkButton('WL')], }), @@ -899,7 +909,8 @@ describe('BMS Free Message E2E', () => { pfId: channel.channelId, bms: createBmsOption('PREMIUM_VIDEO', { video: { - videoUrl: 'https://tv.kakao.com/v/123456789', + // NOTE: 발송 간 유효하지 않은 동영상 URL을 기입하면 발송 상태가 그룹 정보를 찾을 수 없음 오류로 표시됩니다. + videoUrl: 'https://tv.kakao.com/v/460734285', }, }), }, @@ -954,7 +965,8 @@ describe('BMS Free Message E2E', () => { header: '비디오 헤더', content: '비디오 내용입니다.', video: { - videoUrl: 'https://tv.kakao.com/v/123456789', + // NOTE: 발송 간 유효하지 않은 동영상 URL을 기입하면 발송 상태가 그룹 정보를 찾을 수 없음 오류로 표시됩니다. + videoUrl: 'https://tv.kakao.com/v/460734285', imageId, imageLink: 'https://example.com/video', }, From e1d9d2ec774794bda1427daef08437af76462cb9 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 21 Jan 2026 11:52:58 +0900 Subject: [PATCH 11/13] chore(deps): Update development dependencies to latest versions - Upgraded @types/node from ^24.10.1 to ^25.0.9. - Updated typedoc from ^0.28.15 to ^0.28.16. - Bumped vite-tsconfig-paths from ^6.0.3 to ^6.0.4. - Increased vitest version from ^4.0.16 to ^4.0.17. These updates ensure compatibility with the latest features and improvements in the respective packages. --- package.json | 8 +-- pnpm-lock.yaml | 134 ++++++++++++++++++++++++------------------------- 2 files changed, 71 insertions(+), 71 deletions(-) diff --git a/package.json b/package.json index 2e80828..1332454 100644 --- a/package.json +++ b/package.json @@ -45,13 +45,13 @@ "devDependencies": { "@biomejs/biome": "2.3.11", "@effect/vitest": "^0.27.0", - "@types/node": "^24.10.1", + "@types/node": "^25.0.9", "dotenv": "^17.2.3", "tsup": "^8.5.1", - "typedoc": "^0.28.15", + "typedoc": "^0.28.16", "typescript": "^5.9.3", - "vite-tsconfig-paths": "^6.0.3", - "vitest": "^4.0.16" + "vite-tsconfig-paths": "^6.0.4", + "vitest": "^4.0.17" }, "packageManager": "pnpm@10.15.1", "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8cfb4d..14274ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,10 +20,10 @@ importers: version: 2.3.11 '@effect/vitest': specifier: ^0.27.0 - version: 0.27.0(effect@3.19.14)(vitest@4.0.16(@types/node@24.10.1)(yaml@2.8.1)) + version: 0.27.0(effect@3.19.14)(vitest@4.0.17(@types/node@25.0.9)(yaml@2.8.1)) '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.0.9 + version: 25.0.9 dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -31,17 +31,17 @@ importers: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1) typedoc: - specifier: ^0.28.15 - version: 0.28.15(typescript@5.9.3) + specifier: ^0.28.16 + version: 0.28.16(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 vite-tsconfig-paths: - specifier: ^6.0.3 - version: 6.0.3(typescript@5.9.3)(vite@7.1.5(@types/node@24.10.1)(yaml@2.8.1)) + specifier: ^6.0.4 + version: 6.0.4(typescript@5.9.3)(vite@7.1.5(@types/node@25.0.9)(yaml@2.8.1)) vitest: - specifier: ^4.0.16 - version: 4.0.16(@types/node@24.10.1)(yaml@2.8.1) + specifier: ^4.0.17 + version: 4.0.17(@types/node@25.0.9)(yaml@2.8.1) packages: @@ -578,17 +578,17 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - '@types/node@24.10.1': - resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/node@25.0.9': + resolution: {integrity: sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@vitest/expect@4.0.16': - resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} + '@vitest/expect@4.0.17': + resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==} - '@vitest/mocker@4.0.16': - resolution: {integrity: sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==} + '@vitest/mocker@4.0.17': + resolution: {integrity: sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0-0 @@ -598,20 +598,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.0.16': - resolution: {integrity: sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==} + '@vitest/pretty-format@4.0.17': + resolution: {integrity: sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==} - '@vitest/runner@4.0.16': - resolution: {integrity: sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==} + '@vitest/runner@4.0.17': + resolution: {integrity: sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==} - '@vitest/snapshot@4.0.16': - resolution: {integrity: sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==} + '@vitest/snapshot@4.0.17': + resolution: {integrity: sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==} - '@vitest/spy@4.0.16': - resolution: {integrity: sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==} + '@vitest/spy@4.0.17': + resolution: {integrity: sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==} - '@vitest/utils@4.0.16': - resolution: {integrity: sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==} + '@vitest/utils@4.0.17': + resolution: {integrity: sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==} acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} @@ -1031,8 +1031,8 @@ packages: typescript: optional: true - typedoc@0.28.15: - resolution: {integrity: sha512-mw2/2vTL7MlT+BVo43lOsufkkd2CJO4zeOSuWQQsiXoV2VuEn7f6IZp2jsUDPmBMABpgR0R5jlcJ2OGEFYmkyg==} + typedoc@0.28.16: + resolution: {integrity: sha512-x4xW77QC3i5DUFMBp0qjukOTnr/sSg+oEs86nB3LjDslvAmwe/PUGDWbe3GrIqt59oTqoXK5GRK9tAa0sYMiog==} engines: {node: '>= 18', pnpm: '>= 10'} hasBin: true peerDependencies: @@ -1052,8 +1052,8 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - vite-tsconfig-paths@6.0.3: - resolution: {integrity: sha512-7bL7FPX/DSviaZGYUKowWF1AiDVWjMjxNbE8lyaVGDezkedWqfGhlnQ4BZXre0ZN5P4kAgIJfAlgFDVyjrCIyg==} + vite-tsconfig-paths@6.0.4: + resolution: {integrity: sha512-iIsEJ+ek5KqRTK17pmxtgIxXtqr3qDdE6OxrP9mVeGhVDNXRJTKN/l9oMbujTQNzMLe6XZ8qmpztfbkPu2TiFQ==} peerDependencies: vite: '*' peerDependenciesMeta: @@ -1100,18 +1100,18 @@ packages: yaml: optional: true - vitest@4.0.16: - resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} + vitest@4.0.17: + resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.16 - '@vitest/browser-preview': 4.0.16 - '@vitest/browser-webdriverio': 4.0.16 - '@vitest/ui': 4.0.16 + '@vitest/browser-playwright': 4.0.17 + '@vitest/browser-preview': 4.0.17 + '@vitest/browser-webdriverio': 4.0.17 + '@vitest/ui': 4.0.17 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -1194,10 +1194,10 @@ snapshots: '@biomejs/cli-win32-x64@2.3.11': optional: true - '@effect/vitest@0.27.0(effect@3.19.14)(vitest@4.0.16(@types/node@24.10.1)(yaml@2.8.1))': + '@effect/vitest@0.27.0(effect@3.19.14)(vitest@4.0.17(@types/node@25.0.9)(yaml@2.8.1))': dependencies: effect: 3.19.14 - vitest: 4.0.16(@types/node@24.10.1)(yaml@2.8.1) + vitest: 4.0.17(@types/node@25.0.9)(yaml@2.8.1) '@esbuild/aix-ppc64@0.25.9': optional: true @@ -1489,49 +1489,49 @@ snapshots: dependencies: '@types/unist': 3.0.3 - '@types/node@24.10.1': + '@types/node@25.0.9': dependencies: undici-types: 7.16.0 '@types/unist@3.0.3': {} - '@vitest/expect@4.0.16': + '@vitest/expect@4.0.17': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.16 - '@vitest/utils': 4.0.16 + '@vitest/spy': 4.0.17 + '@vitest/utils': 4.0.17 chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.16(vite@7.1.5(@types/node@24.10.1)(yaml@2.8.1))': + '@vitest/mocker@4.0.17(vite@7.1.5(@types/node@25.0.9)(yaml@2.8.1))': dependencies: - '@vitest/spy': 4.0.16 + '@vitest/spy': 4.0.17 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.1.5(@types/node@24.10.1)(yaml@2.8.1) + vite: 7.1.5(@types/node@25.0.9)(yaml@2.8.1) - '@vitest/pretty-format@4.0.16': + '@vitest/pretty-format@4.0.17': dependencies: tinyrainbow: 3.0.3 - '@vitest/runner@4.0.16': + '@vitest/runner@4.0.17': dependencies: - '@vitest/utils': 4.0.16 + '@vitest/utils': 4.0.17 pathe: 2.0.3 - '@vitest/snapshot@4.0.16': + '@vitest/snapshot@4.0.17': dependencies: - '@vitest/pretty-format': 4.0.16 + '@vitest/pretty-format': 4.0.17 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.16': {} + '@vitest/spy@4.0.17': {} - '@vitest/utils@4.0.16': + '@vitest/utils@4.0.17': dependencies: - '@vitest/pretty-format': 4.0.16 + '@vitest/pretty-format': 4.0.17 tinyrainbow: 3.0.3 acorn@8.15.0: {} @@ -1957,7 +1957,7 @@ snapshots: - tsx - yaml - typedoc@0.28.15(typescript@5.9.3): + typedoc@0.28.16(typescript@5.9.3): dependencies: '@gerrit0/mini-shiki': 3.20.0 lunr: 2.3.9 @@ -1974,18 +1974,18 @@ snapshots: undici-types@7.16.0: {} - vite-tsconfig-paths@6.0.3(typescript@5.9.3)(vite@7.1.5(@types/node@24.10.1)(yaml@2.8.1)): + vite-tsconfig-paths@6.0.4(typescript@5.9.3)(vite@7.1.5(@types/node@25.0.9)(yaml@2.8.1)): dependencies: debug: 4.4.1 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.1.5(@types/node@24.10.1)(yaml@2.8.1) + vite: 7.1.5(@types/node@25.0.9)(yaml@2.8.1) transitivePeerDependencies: - supports-color - typescript - vite@7.1.5(@types/node@24.10.1)(yaml@2.8.1): + vite@7.1.5(@types/node@25.0.9)(yaml@2.8.1): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -1994,19 +1994,19 @@ snapshots: rollup: 4.50.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.1 + '@types/node': 25.0.9 fsevents: 2.3.3 yaml: 2.8.1 - vitest@4.0.16(@types/node@24.10.1)(yaml@2.8.1): + vitest@4.0.17(@types/node@25.0.9)(yaml@2.8.1): dependencies: - '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(vite@7.1.5(@types/node@24.10.1)(yaml@2.8.1)) - '@vitest/pretty-format': 4.0.16 - '@vitest/runner': 4.0.16 - '@vitest/snapshot': 4.0.16 - '@vitest/spy': 4.0.16 - '@vitest/utils': 4.0.16 + '@vitest/expect': 4.0.17 + '@vitest/mocker': 4.0.17(vite@7.1.5(@types/node@25.0.9)(yaml@2.8.1)) + '@vitest/pretty-format': 4.0.17 + '@vitest/runner': 4.0.17 + '@vitest/snapshot': 4.0.17 + '@vitest/spy': 4.0.17 + '@vitest/utils': 4.0.17 es-module-lexer: 1.7.0 expect-type: 1.2.2 magic-string: 0.30.21 @@ -2018,10 +2018,10 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.1.5(@types/node@24.10.1)(yaml@2.8.1) + vite: 7.1.5(@types/node@25.0.9)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.10.1 + '@types/node': 25.0.9 transitivePeerDependencies: - jiti - less From dc1d572e5524b1777802b64b472ceca4d88b7c8d Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 21 Jan 2026 12:52:03 +0900 Subject: [PATCH 12/13] feat(bms): Implement validation for WIDE_ITEM_LIST and enhance commerce pricing rules - Added validation to ensure WIDE_ITEM_LIST type has a minimum of 3 sub items. - Introduced a new function to validate pricing combinations for BMS commerce, enforcing rules on the use of regularPrice, discountPrice, discountRate, and discountFixed. - Updated test cases to cover new validation scenarios for both WIDE_ITEM_LIST and commerce pricing combinations, ensuring robust error handling and compliance with business rules. These changes improve the integrity of BMS message types and enhance the overall user experience by providing clearer validation feedback. --- src/models/base/kakao/bms/bmsCommerce.ts | 60 +++++++++++- src/models/base/kakao/kakaoOption.ts | 17 +++- .../models/base/kakao/bms/bmsCommerce.test.ts | 96 ++++++++++++++++--- test/models/base/kakao/bms/bmsOption.test.ts | 74 ++++++++++++-- test/services/messages/bms-free.e2e.test.ts | 9 +- 5 files changed, 226 insertions(+), 30 deletions(-) diff --git a/src/models/base/kakao/bms/bmsCommerce.ts b/src/models/base/kakao/bms/bmsCommerce.ts index 01ddee2..c4e1dd2 100644 --- a/src/models/base/kakao/bms/bmsCommerce.ts +++ b/src/models/base/kakao/bms/bmsCommerce.ts @@ -50,6 +50,59 @@ const NumberOrNumericString: Schema.Schema = }, ) as Schema.Schema; +/** + * BMS 커머스 가격 조합 검증 + * + * 카카오 BMS 커머스 타입은 다음 가격 조합만 허용합니다: + * 1. regularPrice만 사용 (정가만 표기) + * 2. regularPrice + discountPrice + discountRate (할인율 표기) + * 3. regularPrice + discountPrice + discountFixed (정액 할인 표기) + * + * discountRate와 discountFixed를 동시에 사용하거나, + * discountPrice 없이 discountRate/discountFixed만 사용하는 것은 허용되지 않습니다. + */ +const validateCommercePricingCombination = (commerce: { + regularPrice: number; + discountPrice?: number; + discountRate?: number; + discountFixed?: number; +}): boolean | string => { + const hasDiscountPrice = commerce.discountPrice !== undefined; + const hasDiscountRate = commerce.discountRate !== undefined; + const hasDiscountFixed = commerce.discountFixed !== undefined; + + // Case 1: regularPrice만 사용 (정가만 표기) - valid + if (!hasDiscountPrice && !hasDiscountRate && !hasDiscountFixed) { + return true; + } + + // Case 2: regularPrice + discountPrice + discountRate (할인율 표기) - valid + if (hasDiscountPrice && hasDiscountRate && !hasDiscountFixed) { + return true; + } + + // Case 3: regularPrice + discountPrice + discountFixed (정액 할인 표기) - valid + if (hasDiscountPrice && hasDiscountFixed && !hasDiscountRate) { + return true; + } + + // Invalid combinations + if (hasDiscountRate && hasDiscountFixed) { + return 'discountRate와 discountFixed는 동시에 사용할 수 없습니다. 할인율(discountRate) 또는 정액할인(discountFixed) 중 하나만 선택하세요.'; + } + + if (!hasDiscountPrice && (hasDiscountRate || hasDiscountFixed)) { + return 'discountRate 또는 discountFixed를 사용하려면 discountPrice(할인가)도 함께 지정해야 합니다.'; + } + + // discountPrice만 있는 경우 (discountRate/discountFixed 없음) + if (hasDiscountPrice && !hasDiscountRate && !hasDiscountFixed) { + return 'discountPrice를 사용하려면 discountRate(할인율) 또는 discountFixed(정액할인) 중 하나를 함께 지정해야 합니다.'; + } + + return '알 수 없는 가격 조합입니다. regularPrice만 사용하거나, regularPrice + discountPrice + discountRate/discountFixed 조합을 사용하세요.'; +}; + /** * BMS 커머스 정보 스키마 * - title: 상품명 (필수) @@ -57,6 +110,11 @@ const NumberOrNumericString: Schema.Schema = * - discountPrice: 할인가 (선택, 숫자 또는 숫자형 문자열) * - discountRate: 할인율 (선택, 숫자 또는 숫자형 문자열) * - discountFixed: 고정 할인금액 (선택, 숫자 또는 숫자형 문자열) + * + * 가격 조합 규칙: + * - regularPrice만 사용 (정가만 표기) + * - regularPrice + discountPrice + discountRate (할인율 표기) + * - regularPrice + discountPrice + discountFixed (정액 할인 표기) */ export const bmsCommerceSchema = Schema.Struct({ title: Schema.String, @@ -64,6 +122,6 @@ export const bmsCommerceSchema = Schema.Struct({ discountPrice: Schema.optional(NumberOrNumericString), discountRate: Schema.optional(NumberOrNumericString), discountFixed: Schema.optional(NumberOrNumericString), -}); +}).pipe(Schema.filter(validateCommercePricingCombination)); export type BmsCommerceSchema = Schema.Schema.Type; diff --git a/src/models/base/kakao/kakaoOption.ts b/src/models/base/kakao/kakaoOption.ts index da3689a..cf5d19e 100644 --- a/src/models/base/kakao/kakaoOption.ts +++ b/src/models/base/kakao/kakaoOption.ts @@ -97,11 +97,8 @@ const baseBmsSchema = Schema.Struct({ type BaseBmsSchemaType = Schema.Schema.Type; -/** - * chatBubbleType별 필수 필드 검증 및 에러 메시지 반환 - * - 검증 통과 시: true 반환 - * - 검증 실패 시: 에러 메시지 문자열 반환 - */ +const WIDE_ITEM_LIST_MIN_SUB_ITEMS = 3; + const validateBmsRequiredFields = ( bms: BaseBmsSchemaType, ): boolean | string => { @@ -116,6 +113,16 @@ const validateBmsRequiredFields = ( return `BMS ${chatBubbleType} 타입에 필수 필드가 누락되었습니다: ${missingFields.join(', ')}`; } + if (chatBubbleType === 'WIDE_ITEM_LIST') { + const subWideItemList = bms.subWideItemList; + if ( + !subWideItemList || + subWideItemList.length < WIDE_ITEM_LIST_MIN_SUB_ITEMS + ) { + return `WIDE_ITEM_LIST 타입의 subWideItemList는 최소 ${WIDE_ITEM_LIST_MIN_SUB_ITEMS}개 이상이어야 합니다. 현재: ${subWideItemList?.length ?? 0}개`; + } + } + return true; }; diff --git a/test/models/base/kakao/bms/bmsCommerce.test.ts b/test/models/base/kakao/bms/bmsCommerce.test.ts index d621139..c01476e 100644 --- a/test/models/base/kakao/bms/bmsCommerce.test.ts +++ b/test/models/base/kakao/bms/bmsCommerce.test.ts @@ -67,29 +67,26 @@ describe('BMS Commerce Schema', () => { }); describe('선택적 숫자 필드 검증', () => { - it('should accept mixed number and string for optional fields', () => { + it('should accept mixed number and string for discountRate combination', () => { const valid = { title: '상품명', regularPrice: 10000, discountPrice: '8000', discountRate: 20, - discountFixed: '2000', }; const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); expect(result._tag).toBe('Right'); if (result._tag === 'Right') { expect(result.right.discountPrice).toBe(8000); expect(result.right.discountRate).toBe(20); - expect(result.right.discountFixed).toBe(2000); } }); - it('should accept all string values for numeric fields', () => { + it('should accept all string values for discountFixed combination', () => { const valid = { title: '상품명', regularPrice: '15000', discountPrice: '12000', - discountRate: '20', discountFixed: '3000', }; const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); @@ -97,12 +94,9 @@ describe('BMS Commerce Schema', () => { if (result._tag === 'Right') { expect(result.right.regularPrice).toBe(15000); expect(result.right.discountPrice).toBe(12000); - expect(result.right.discountRate).toBe(20); expect(result.right.discountFixed).toBe(3000); - // 모든 필드가 number 타입으로 변환되었는지 확인 expect(typeof result.right.regularPrice).toBe('number'); expect(typeof result.right.discountPrice).toBe('number'); - expect(typeof result.right.discountRate).toBe('number'); expect(typeof result.right.discountFixed).toBe('number'); } }); @@ -151,29 +145,27 @@ describe('BMS Commerce Schema', () => { }); describe('실제 사용 사례 테스트', () => { - it('should handle CAROUSEL_COMMERCE style input (string prices)', () => { - // debug/bms_free/hosy_test.js의 CAROUSEL_COMMERCE 템플릿과 동일한 구조 + it('should handle CAROUSEL_COMMERCE style input (string prices with discountRate)', () => { const valid = { title: '상품명2', regularPrice: '10000', - discountPrice: '50', + discountPrice: '5000', discountRate: '50', }; const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); expect(result._tag).toBe('Right'); if (result._tag === 'Right') { expect(result.right.regularPrice).toBe(10000); - expect(result.right.discountPrice).toBe(50); + expect(result.right.discountPrice).toBe(5000); expect(result.right.discountRate).toBe(50); } }); - it('should handle COMMERCE style input (mixed types)', () => { + it('should handle COMMERCE style input with discountFixed', () => { const valid = { title: '상품명', regularPrice: 1000, discountPrice: '800', - discountRate: 20, discountFixed: '200', }; const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); @@ -181,9 +173,83 @@ describe('BMS Commerce Schema', () => { if (result._tag === 'Right') { expect(result.right.regularPrice).toBe(1000); expect(result.right.discountPrice).toBe(800); - expect(result.right.discountRate).toBe(20); expect(result.right.discountFixed).toBe(200); } }); }); + + describe('가격 조합 검증', () => { + it('should accept regularPrice only (정가만 표기)', () => { + const valid = { + title: '상품명', + regularPrice: 10000, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + }); + + it('should accept regularPrice + discountPrice + discountRate (할인율 표기)', () => { + const valid = { + title: '상품명', + regularPrice: 10000, + discountPrice: 8000, + discountRate: 20, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + }); + + it('should accept regularPrice + discountPrice + discountFixed (정액 할인 표기)', () => { + const valid = { + title: '상품명', + regularPrice: 10000, + discountPrice: 8000, + discountFixed: 2000, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + }); + + it('should reject discountRate and discountFixed together', () => { + const invalid = { + title: '상품명', + regularPrice: 10000, + discountPrice: 8000, + discountRate: 20, + discountFixed: 2000, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid); + expect(result._tag).toBe('Left'); + }); + + it('should reject discountRate without discountPrice', () => { + const invalid = { + title: '상품명', + regularPrice: 10000, + discountRate: 20, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid); + expect(result._tag).toBe('Left'); + }); + + it('should reject discountFixed without discountPrice', () => { + const invalid = { + title: '상품명', + regularPrice: 10000, + discountFixed: 2000, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid); + expect(result._tag).toBe('Left'); + }); + + it('should reject discountPrice without discountRate or discountFixed', () => { + const invalid = { + title: '상품명', + regularPrice: 10000, + discountPrice: 8000, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid); + expect(result._tag).toBe('Left'); + }); + }); }); diff --git a/test/models/base/kakao/bms/bmsOption.test.ts b/test/models/base/kakao/bms/bmsOption.test.ts index c5a4055..03aa849 100644 --- a/test/models/base/kakao/bms/bmsOption.test.ts +++ b/test/models/base/kakao/bms/bmsOption.test.ts @@ -95,7 +95,7 @@ describe('BMS Option Schema in KakaoOption', () => { }).toThrow('BMS WIDE 타입에 필수 필드가 누락되었습니다: imageId'); }); - it('should accept valid BMS_WIDE_ITEM_LIST message', () => { + it('should accept valid BMS_WIDE_ITEM_LIST message with 3 sub items (minimum)', () => { const validBmsWideItemList = { pfId: 'test-pf-id', bms: { @@ -118,6 +118,11 @@ describe('BMS Option Schema in KakaoOption', () => { imageId: 'img-sub-2', linkMobile: 'https://example.com/sub2', }, + { + title: '서브 아이템 3', + imageId: 'img-sub-3', + linkMobile: 'https://example.com/sub3', + }, ], }, }; @@ -137,9 +142,19 @@ describe('BMS Option Schema in KakaoOption', () => { header: '헤더 제목', subWideItemList: [ { - title: '서브 아이템', - imageId: 'img-sub', - linkMobile: 'https://example.com/sub', + title: '서브 아이템 1', + imageId: 'img-sub-1', + linkMobile: 'https://example.com/sub1', + }, + { + title: '서브 아이템 2', + imageId: 'img-sub-2', + linkMobile: 'https://example.com/sub2', + }, + { + title: '서브 아이템 3', + imageId: 'img-sub-3', + linkMobile: 'https://example.com/sub3', }, ], }, @@ -150,6 +165,40 @@ describe('BMS Option Schema in KakaoOption', () => { }).toThrow('BMS WIDE_ITEM_LIST 타입에 필수 필드가 누락되었습니다'); }); + it('should reject BMS_WIDE_ITEM_LIST with less than 3 sub items', () => { + const invalidBmsWideItemList = { + pfId: 'test-pf-id', + bms: { + targeting: 'M', + chatBubbleType: 'WIDE_ITEM_LIST', + header: '헤더 제목', + mainWideItem: { + title: '메인 아이템', + imageId: 'img-main', + linkMobile: 'https://example.com/main', + }, + subWideItemList: [ + { + title: '서브 아이템 1', + imageId: 'img-sub-1', + linkMobile: 'https://example.com/sub1', + }, + { + title: '서브 아이템 2', + imageId: 'img-sub-2', + linkMobile: 'https://example.com/sub2', + }, + ], + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(invalidBmsWideItemList); + }).toThrow( + 'WIDE_ITEM_LIST 타입의 subWideItemList는 최소 3개 이상이어야 합니다', + ); + }); + it('should accept valid BMS_COMMERCE message', () => { const validBmsCommerce = { pfId: 'test-pf-id', @@ -161,6 +210,7 @@ describe('BMS Option Schema in KakaoOption', () => { title: '상품명', regularPrice: 10000, discountPrice: 8000, + discountRate: 20, }, buttons: [ { @@ -422,9 +472,19 @@ describe('BMS Option Schema in KakaoOption', () => { }, subWideItemList: [ { - title: '서브', - imageId: 'img-sub', - linkMobile: 'https://example.com/sub', + title: '서브 1', + imageId: 'img-sub-1', + linkMobile: 'https://example.com/sub1', + }, + { + title: '서브 2', + imageId: 'img-sub-2', + linkMobile: 'https://example.com/sub2', + }, + { + title: '서브 3', + imageId: 'img-sub-3', + linkMobile: 'https://example.com/sub3', }, ], }; diff --git a/test/services/messages/bms-free.e2e.test.ts b/test/services/messages/bms-free.e2e.test.ts index 911a9bc..a017e81 100644 --- a/test/services/messages/bms-free.e2e.test.ts +++ b/test/services/messages/bms-free.e2e.test.ts @@ -348,7 +348,7 @@ describe('BMS Free Message E2E', () => { }); describe('WIDE_ITEM_LIST 타입', () => { - it.effect('최소 구조 (header, mainWideItem, subWideItemList 1개)', () => + it.effect('최소 구조 (header, mainWideItem, subWideItemList 3개)', () => Effect.gen(function* () { const messageService = yield* MessageServiceTag; const kakaoChannelService = yield* KakaoChannelServiceTag; @@ -395,6 +395,8 @@ describe('BMS Free Message E2E', () => { mainWideItem: createMainWideItem(mainImageId), subWideItemList: [ createSubWideItem(subImageId, '서브 아이템 1'), + createSubWideItem(subImageId, '서브 아이템 2'), + createSubWideItem(subImageId, '서브 아이템 3'), ], }), }, @@ -573,7 +575,6 @@ describe('BMS Free Message E2E', () => { regularPrice: 50000, discountPrice: 35000, discountRate: 30, - discountFixed: 15000, }), buttons: [createBmsButton('WL'), createBmsButton('AL')], coupon: createBmsCoupon('up'), @@ -765,12 +766,16 @@ describe('BMS Free Message E2E', () => { createCarouselCommerceItem(imageId, { commerce: createBmsCommerce({ title: '상품 1', + regularPrice: 10000, + discountPrice: 9000, discountRate: 10, }), }), createCarouselCommerceItem(imageId, { commerce: createBmsCommerce({ title: '상품 2', + regularPrice: 10000, + discountPrice: 8000, discountFixed: 2000, }), }), From 0d2b4819f05ab6404c64b324da519a6f8dc39490 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 21 Jan 2026 15:11:21 +0900 Subject: [PATCH 13/13] feat(bms): Add new image examples and enhance BMS message types - Introduced new image examples for BMS messaging, including `example-1to1.jpg` and `example-2to1.jpg`. - Updated various BMS message examples to include detailed instructions on image upload requirements and file types. - Enhanced existing examples to support new features such as coupon integration and improved message structures. These changes provide clearer guidance for developers and improve the overall functionality of BMS messaging services. --- .../javascript/common/images/example-1to1.jpg | Bin 0 -> 37447 bytes .../javascript/common/images/example-2to1.jpg | Bin 0 -> 46454 bytes .../send/send_bms_free_carousel_commerce.js | 308 +++++++++++------- .../kakao/send/send_bms_free_carousel_feed.js | 265 +++++++++------ .../src/kakao/send/send_bms_free_commerce.js | 238 ++++++++------ .../src/kakao/send/send_bms_free_image.js | 95 +++--- .../send/send_bms_free_image_with_buttons.js | 81 ++--- .../kakao/send/send_bms_free_premium_video.js | 178 ++++++---- .../src/kakao/send/send_bms_free_text.js | 59 ++-- .../send/send_bms_free_text_with_buttons.js | 90 +++-- .../src/kakao/send/send_bms_free_wide.js | 60 +++- .../send/send_bms_free_wide_item_list.js | 202 ++++++++---- 12 files changed, 947 insertions(+), 629 deletions(-) create mode 100644 examples/javascript/common/images/example-1to1.jpg create mode 100644 examples/javascript/common/images/example-2to1.jpg diff --git a/examples/javascript/common/images/example-1to1.jpg b/examples/javascript/common/images/example-1to1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..83e3701f3226108a45219daf5c9aae34d25e35f2 GIT binary patch literal 37447 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 L;=mcy;nAA_1(4wX literal 0 HcmV?d00001 diff --git a/examples/javascript/common/images/example-2to1.jpg b/examples/javascript/common/images/example-2to1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ae7ab5bb57e1bc5d40d3b7cbdfc0b7794ffb4156 GIT binary patch literal 46454 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 res.fileId) +// CAROUSEL_COMMERCE 타입은 'BMS_CAROUSEL_COMMERCE_LIST' fileType으로 업로드해야 합니다 (2:1 비율 이미지 필수) messageService - .sendOne({ - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - type: 'BMS_FREE', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - bms: { - targeting: 'I', // I: 전체, M/N: 인허가 채널만 - chatBubbleType: 'CAROUSEL_COMMERCE', - carousel: { - // head: 캐러셀 상단 대표 이미지 및 설명 (선택) - head: { - header: '이번 주 베스트 상품', - content: '인기 상품을 만나보세요!', - imageId: '업로드한 헤드 이미지 ID', - linkMobile: 'https://m.example.com/best', - linkPc: 'https://example.com/best', // 선택 - }, - // list: 상품 카드 목록 (head 있으면 1-5개, 없으면 2-6개) - list: [ - { - additionalContent: '무료배송', // 부가정보 (선택) - imageId: '업로드한 상품 이미지 ID', - coupon: { - title: '10% 할인 쿠폰', - description: '신규 회원 전용', - linkMobile: 'https://m.example.com/coupon1', - }, - commerce: { - title: '상품명 1', - regularPrice: '30000', - discountPrice: '25000', - discountRate: '17', - }, - buttons: [ + .uploadFile( + path.join(__dirname, '../../images/example-2to1.jpg'), + 'BMS_CAROUSEL_COMMERCE_LIST', + ) + .then(res => res.fileId) + .then(imageId => { + // 최소 구조 단건 발송 예제 (carousel.list 2개) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + list: [ { - linkType: 'WL', - name: '구매하기', - linkMobile: 'https://m.example.com/product1', + imageId: imageId, + commerce: { + title: '프리미엄 블루투스 스피커', + regularPrice: 129000, + discountPrice: 99000, + discountRate: 23, + }, + buttons: [ + { + linkType: 'WL', + name: '구매하기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + ], }, - ], - }, - { - additionalContent: '오늘 출발', - imageId: '업로드한 상품 이미지 ID', - commerce: { - title: '상품명 2', - regularPrice: '50000', - discountPrice: '40000', - discountRate: '20', - }, - buttons: [ { - linkType: 'WL', - name: '구매하기', - linkMobile: 'https://m.example.com/product2', + imageId: imageId, + commerce: { + title: '노이즈캔슬링 헤드폰', + regularPrice: 249000, + discountPrice: 199000, + discountFixed: 50000, + }, + buttons: [ + { + linkType: 'WL', + name: '구매하기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + ], }, ], }, - { - imageId: '업로드한 상품 이미지 ID', - commerce: { - title: '상품명 3', - regularPrice: '15000', + }, + }, + }) + .then(res => console.log(res)); + + // 전체 필드 단건 발송 예제 (adult, additionalContent, carousel head/list 전체/tail) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_COMMERCE', + adult: false, + additionalContent: '🔥 이번 주 한정 특가!', + carousel: { + head: { + header: '홍길동님을 위한 추천', + content: '최근 관심 상품과 비슷한 아이템을 모았어요!', + imageId: imageId, + linkMobile: 'https://example.com/recommend', }, - buttons: [ + list: [ + { + imageId: imageId, + commerce: { + title: '에어프라이어 대용량 5.5L', + regularPrice: 159000, + discountPrice: 119000, + discountRate: 25, + }, + additionalContent: '⚡ 무료배송', + imageLink: 'https://example.com/airfryer', + buttons: [ + { + linkType: 'WL', + name: '지금 구매', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + { + linkType: 'AL', + name: '앱에서 보기', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }, + ], + coupon: { + title: '10000원 할인 쿠폰', + description: '첫 구매 고객 전용 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, + }, { - linkType: 'WL', - name: '구매하기', - linkMobile: 'https://m.example.com/product3', + imageId: imageId, + commerce: { + title: '스마트 로봇청소기 프로', + regularPrice: 499000, + discountPrice: 399000, + discountFixed: 100000, + }, + buttons: [ + { + linkType: 'WL', + name: '상세 보기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + ], }, ], + tail: { + linkMobile: 'https://example.com/all-products', + }, }, - ], - // tail: 캐러셀 하단에 "더보기" 링크 (선택) - tail: { - linkMobile: 'https://m.example.com/all-products', - linkPc: 'https://example.com/all-products', // 선택 }, }, - }, - }, - }) - .then(res => console.log(res)); + }) + .then(res => console.log(res)); -// head 없이 상품만 발송하는 예제 -messageService - .sendOne({ - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - type: 'BMS_FREE', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - bms: { - targeting: 'I', - chatBubbleType: 'CAROUSEL_COMMERCE', - carousel: { - list: [ - { - imageId: '업로드한 상품 이미지 ID', - commerce: { - title: '한정 특가 상품 A', - regularPrice: '100000', - discountPrice: '70000', - discountRate: '30', - }, - buttons: [ - { - linkType: 'WL', - name: '바로 구매', - linkMobile: 'https://m.example.com/productA', + // 단건 예약 발송 예제 + messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + list: [ + { + imageId: imageId, + commerce: { + title: '겨울 롱패딩 - 그레이', + regularPrice: 299000, + discountPrice: 199000, + discountRate: 33, + }, + buttons: [ + { + linkType: 'WL', + name: '바로 구매', + linkMobile: 'https://example.com/padding-gray', + }, + ], + }, + { + imageId: imageId, + commerce: { + title: '겨울 롱패딩 - 블랙', + regularPrice: 299000, + discountPrice: 199000, + discountRate: 33, + }, + buttons: [ + { + linkType: 'WL', + name: '바로 구매', + linkMobile: 'https://example.com/padding-black', + }, + ], + }, + ], + tail: { + linkMobile: 'https://example.com/winter-sale', }, - ], - }, - { - imageId: '업로드한 상품 이미지 ID', - commerce: { - title: '한정 특가 상품 B', - regularPrice: '80000', - discountPrice: '60000', - discountRate: '25', }, - buttons: [ - { - linkType: 'WL', - name: '바로 구매', - linkMobile: 'https://m.example.com/productB', - }, - ], }, - ], - tail: { - linkMobile: 'https://m.example.com/sale', }, }, - }, - }, - }) - .then(res => console.log(res)); + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); + }); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_carousel_feed.js b/examples/javascript/common/src/kakao/send/send_bms_free_carousel_feed.js index 2be7133..6afbf69 100644 --- a/examples/javascript/common/src/kakao/send/send_bms_free_carousel_feed.js +++ b/examples/javascript/common/src/kakao/send/send_bms_free_carousel_feed.js @@ -1,136 +1,201 @@ /** * 카카오 BMS 자유형 CAROUSEL_FEED 타입 발송 예제 * 캐러셀 피드 형식으로, 여러 카드를 좌우로 슬라이드하는 구조입니다. - * 각 카드: header, content, imageId, imageLink, coupon, buttons + * 이미지 업로드 시 fileType은 'BMS_CAROUSEL_FEED_LIST'를 사용해야 합니다. (2:1 비율 이미지 필수) * head 없이 2-6개 아이템, head 포함 시 1-5개 아이템 가능합니다. + * 캐러셀 피드 버튼은 WL, AL 타입만 지원합니다. + * 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. * 그 외의 모든 채널은 I 타입만 사용 가능합니다. * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 */ +const path = require('path'); const {SolapiMessageService} = require('solapi'); const messageService = new SolapiMessageService( 'ENTER_YOUR_API_KEY', 'ENTER_YOUR_API_SECRET', ); -// 단일 발송 예제 -// imageId는 미리 업로드한 이미지 ID를 사용합니다. -// 이미지 업로드: messageService.uploadFile(filePath, 'KAKAO').then(res => res.fileId) +// CAROUSEL_FEED 타입은 'BMS_CAROUSEL_FEED_LIST' fileType으로 업로드해야 합니다 (2:1 비율 이미지 필수) messageService - .sendOne({ - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - type: 'BMS_FREE', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - bms: { - targeting: 'I', // I: 전체, M/N: 인허가 채널만 - chatBubbleType: 'CAROUSEL_FEED', - carousel: { - // head 없이 list만 있는 경우 2-6개 아이템 - list: [ - { - header: '첫 번째 카드 헤더', - content: '첫 번째 카드 내용입니다.', - imageId: '업로드한 이미지 ID', - imageLink: 'https://example.com/image1', // 이미지 클릭 시 이동 URL (선택) - coupon: { - title: '10% 할인 쿠폰', - description: '첫 구매 고객 전용', - linkMobile: 'https://m.example.com/coupon1', - }, - buttons: [ + .uploadFile( + path.join(__dirname, '../../images/example-2to1.jpg'), + 'BMS_CAROUSEL_FEED_LIST', + ) + .then(res => res.fileId) + .then(imageId => { + // 최소 구조 단건 발송 예제 (carousel.list 2개) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [ { - linkType: 'WL', // 캐러셀 피드는 WL, AL 버튼만 지원 - name: '자세히 보기', - linkMobile: 'https://m.example.com/detail1', + header: '🍳 오늘의 브런치 레시피', + content: + '15분 만에 완성하는 아보카도 토스트! 간단하지만 영양 만점이에요.', + imageId: imageId, + buttons: [ + { + linkType: 'WL', + name: '레시피 보기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + ], }, - ], - }, - { - header: '두 번째 카드 헤더', - content: '두 번째 카드 내용입니다.', - imageId: '업로드한 이미지 ID', - coupon: { - title: '5000원 할인 쿠폰', - description: '주말 특가 할인', - linkMobile: 'https://m.example.com/coupon2', - }, - buttons: [ { - linkType: 'WL', - name: '자세히 보기', - linkMobile: 'https://m.example.com/detail2', + header: '☕ 홈카페 꿀팁', + content: '집에서 바리스타처럼! 라떼 아트 도전해보세요.', + imageId: imageId, + buttons: [ + { + linkType: 'WL', + name: '영상 보기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + ], }, ], }, - { - header: '세 번째 카드 헤더', - content: '세 번째 카드 내용입니다.', - imageId: '업로드한 이미지 ID', - buttons: [ + }, + }, + }) + .then(res => console.log(res)); + + // 전체 필드 단건 발송 예제 (adult, carousel head/list 전체/tail) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_FEED', + adult: false, + carousel: { + list: [ { - linkType: 'AL', // 앱링크 버튼 - name: '앱에서 보기', - linkAndroid: 'examplescheme://detail3', - linkIos: 'examplescheme://detail3', + header: '🏃 마라톤 완주 도전!', + content: + '첫 마라톤 완주를 목표로 8주 트레이닝 프로그램을 시작해보세요.', + imageId: imageId, + imageLink: 'https://example.com/marathon', + buttons: [ + { + linkType: 'WL', + name: '프로그램 신청', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + { + linkType: 'AL', + name: '앱에서 보기', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }, + ], + coupon: { + title: '10% 할인 쿠폰', + description: '첫 등록 고객 전용 할인 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, + }, + { + header: '🧘 요가 입문 클래스', + content: + '초보자를 위한 기초 요가 동작을 배워보세요. 유연성과 마음의 평화를 함께!', + imageId: imageId, + buttons: [ + { + linkType: 'WL', + name: '클래스 보기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + ], + }, + { + header: '💪 홈트레이닝 루틴', + content: '장비 없이도 OK! 집에서 하는 30분 전신 운동 루틴.', + imageId: imageId, + buttons: [ + { + linkType: 'AL', + name: '영상 시청', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }, + ], }, ], + tail: { + linkMobile: 'https://example.com/more', + linkPc: 'https://example.com/more', + }, }, - ], - // tail: 캐러셀 하단에 "더보기" 링크 (선택) - tail: { - linkMobile: 'https://m.example.com/more', - linkPc: 'https://example.com/more', // 선택 }, }, - }, - }, - }) - .then(res => console.log(res)); + }) + .then(res => console.log(res)); -// 여러 메시지 발송 예제 -messageService - .send([ - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - type: 'BMS_FREE', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - bms: { - targeting: 'I', - chatBubbleType: 'CAROUSEL_FEED', - carousel: { - list: [ - { - header: '이벤트 1', - content: '특별 이벤트 안내입니다.', - imageId: '업로드한 이미지 ID', - buttons: [ + // 단건 예약 발송 예제 + messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [ { - linkType: 'WL', - name: '참여하기', - linkMobile: 'https://m.example.com/event1', + header: '🎄 크리스마스 특별 이벤트', + content: '연말 맞이 특별 할인! 인기 상품 최대 50% OFF', + imageId: imageId, + buttons: [ + { + linkType: 'WL', + name: '이벤트 참여', + linkMobile: 'https://example.com/christmas', + }, + ], }, - ], - }, - { - header: '이벤트 2', - content: '한정 프로모션 안내입니다.', - imageId: '업로드한 이미지 ID', - buttons: [ { - linkType: 'WL', - name: '참여하기', - linkMobile: 'https://m.example.com/event2', + header: '🎁 선물 포장 무료', + content: + '소중한 분께 마음을 전하세요. 고급 선물 포장 무료!', + imageId: imageId, + buttons: [ + { + linkType: 'WL', + name: '선물하기', + linkMobile: 'https://example.com/gift', + }, + ], }, ], }, - ], + }, }, }, - }, - }, - ]) - .then(res => console.log(res)); + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); + }); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_commerce.js b/examples/javascript/common/src/kakao/send/send_bms_free_commerce.js index b125078..22ff541 100644 --- a/examples/javascript/common/src/kakao/send/send_bms_free_commerce.js +++ b/examples/javascript/common/src/kakao/send/send_bms_free_commerce.js @@ -1,121 +1,159 @@ /** * 카카오 BMS 자유형 COMMERCE 타입 발송 예제 * 커머스(상품) 메시지로, 상품 이미지와 가격 정보, 쿠폰을 포함합니다. - * 이미지 + 상품정보(commerce) + 쿠폰(coupon) + 버튼 조합입니다. + * 이미지 업로드 시 fileType은 'BMS'를 사용해야 합니다. (2:1 비율 이미지 권장) + * COMMERCE 타입은 buttons가 필수입니다 (최소 1개). + * 가격 정보(regularPrice, discountPrice, discountRate, discountFixed)는 숫자 타입입니다. + * 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. * 그 외의 모든 채널은 I 타입만 사용 가능합니다. * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 */ +const path = require('path'); const {SolapiMessageService} = require('solapi'); const messageService = new SolapiMessageService( 'ENTER_YOUR_API_KEY', 'ENTER_YOUR_API_SECRET', ); -// 단일 발송 예제 -// imageId는 미리 업로드한 이미지 ID를 사용합니다. -// 이미지 업로드: messageService.uploadFile(filePath, 'KAKAO').then(res => res.fileId) +// COMMERCE 타입은 'BMS' fileType으로 업로드해야 합니다 (2:1 비율 이미지 권장) messageService - .sendOne({ - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - type: 'BMS_FREE', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - bms: { - targeting: 'I', // I: 전체, M/N: 인허가 채널만 - chatBubbleType: 'COMMERCE', - imageId: '업로드한 상품 이미지 ID', - commerce: { - title: '상품명', - regularPrice: '10000', // 정가 - discountPrice: '8000', // 할인가 (선택) - discountRate: '20', // 할인율 % (선택) - discountFixed: '2000', // 할인금액 (선택) - }, - // 쿠폰 정보 (선택) - // 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" - coupon: { - title: '10000원 할인 쿠폰', - description: '신규 회원 전용 할인 쿠폰입니다.', - linkMobile: 'https://m.example.com/coupon', - linkPc: 'https://example.com/coupon', // 선택 - }, - buttons: [ - { - linkType: 'WL', - name: '상품 보기', - linkMobile: 'https://m.example.com/product', - linkPc: 'https://example.com/product', // 선택 - }, - { - linkType: 'WL', - name: '바로 구매', - linkMobile: 'https://m.example.com/buy', + .uploadFile(path.join(__dirname, '../../images/example-2to1.jpg'), 'BMS') + .then(res => res.fileId) + .then(imageId => { + // 최소 구조 단건 발송 예제 (imageId, commerce title만, buttons 1개) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + imageId: imageId, + commerce: { + title: '프리미엄 무선 이어폰', + regularPrice: 89000, + }, + buttons: [ + { + linkType: 'WL', + name: '상품 보기', + linkMobile: 'https://example.com/product', + }, + ], }, - ], - }, - }, - }) - .then(res => console.log(res)); - -// 쿠폰 없이 상품 정보만 발송하는 예제 -messageService - .sendOne({ - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - type: 'BMS_FREE', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - bms: { - targeting: 'I', - chatBubbleType: 'COMMERCE', - imageId: '업로드한 상품 이미지 ID', - commerce: { - title: '한정 특가 상품', - regularPrice: '50000', - discountPrice: '35000', - discountRate: '30', }, - buttons: [ - { - linkType: 'WL', - name: '상품 상세보기', - linkMobile: 'https://m.example.com/detail', + }) + .then(res => console.log(res)); + + // 전체 필드 단건 발송 예제 (adult, additionalContent, imageId, commerce 전체, buttons, coupon) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + adult: false, + additionalContent: '🚀 오늘 주문 시 내일 도착! 무료배송', + imageId: imageId, + commerce: { + title: '스마트 공기청정기 2024 신형', + regularPrice: 299000, + discountPrice: 209000, + discountRate: 30, + }, + buttons: [ + { + linkType: 'WL', + name: '지금 구매하기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + { + linkType: 'AL', + name: '앱에서 보기', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }, + ], + coupon: { + title: '포인트 UP 쿠폰', + description: '구매 시 2배 적립 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, }, - ], - }, - }, - }) - .then(res => console.log(res)); + }, + }) + .then(res => console.log(res)); -// 여러 메시지 발송 예제 -messageService - .send([ - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - type: 'BMS_FREE', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - bms: { - targeting: 'I', - chatBubbleType: 'COMMERCE', - imageId: '업로드한 상품 이미지 ID', - commerce: { - title: '베스트 셀러 상품', - regularPrice: '25000', - discountPrice: '20000', + // 단건 예약 발송 예제 + messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + imageId: imageId, + commerce: { + title: '겨울 패딩 점퍼 - 한정판', + regularPrice: 189000, + discountPrice: 149000, + }, + buttons: [ + { + linkType: 'WL', + name: '바로 구매', + linkMobile: 'https://example.com/buy', + }, + ], + }, }, - buttons: [ - { - linkType: 'WL', - name: '구매하기', - linkMobile: 'https://m.example.com/buy', + }, + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); + + // 다건 발송 예제 + messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + imageId: imageId, + commerce: { + title: '유기농 그래놀라 선물세트', + regularPrice: 45000, + discountPrice: 38000, + }, + buttons: [ + { + linkType: 'WL', + name: '선물하기', + linkMobile: 'https://example.com/gift', + }, + ], }, - ], + }, }, - }, - }, - ]) - .then(res => console.log(res)); + ]) + .then(res => console.log(res)); + }); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_image.js b/examples/javascript/common/src/kakao/send/send_bms_free_image.js index 77d4b0b..d93f3a4 100644 --- a/examples/javascript/common/src/kakao/send/send_bms_free_image.js +++ b/examples/javascript/common/src/kakao/send/send_bms_free_image.js @@ -1,6 +1,7 @@ /** * 카카오 BMS 자유형 IMAGE 타입 발송 예제 * 이미지 업로드 후 imageId를 사용하여 발송합니다. + * 이미지 업로드 시 fileType은 반드시 'BMS'를 사용해야 합니다. * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. * 그 외의 모든 채널은 I 타입만 사용 가능합니다. * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 @@ -12,21 +13,22 @@ const messageService = new SolapiMessageService( 'ENTER_YOUR_API_SECRET', ); +// IMAGE 타입은 반드시 'BMS' fileType으로 업로드해야 합니다 messageService - .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'KAKAO') + .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'BMS') .then(res => res.fileId) .then(fileId => { - // 단일 발송 예제 + // 최소 구조 단건 발송 예제 (text, imageId) messageService - .sendOne({ + .send({ to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '🆕 신상품이 입고되었어요!\n지금 바로 확인해보세요.', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', bms: { - targeting: 'I', // I: 전체, M/N: 인허가 채널만 + targeting: 'I', chatBubbleType: 'IMAGE', imageId: fileId, }, @@ -34,14 +36,38 @@ messageService }) .then(res => console.log(res)); - // 단일 예약 발송 예제 - // 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. + // 전체 필드 단건 발송 예제 (adult, imageId, imageLink, coupon) messageService - .sendOneFuture( + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🎊 홍길동님, VIP 고객 전용 특별 이벤트!\n\n프리미엄 회원만을 위한 시크릿 세일이 시작되었습니다.\n최대 70% 할인 혜택을 놓치지 마세요!', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + adult: false, + imageId: fileId, + imageLink: 'https://example.com/vip-sale', + coupon: { + title: '10000원 할인 쿠폰', + description: 'VIP 고객 전용 할인 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, + }, + }, + }) + .then(res => console.log(res)); + + // 단건 예약 발송 예제 + messageService + .send( { to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '🌸 봄 신상 컬렉션 오픈!\n\n3월 1일 오전 10시, 첫 공개됩니다.\n알림 설정하고 가장 먼저 만나보세요!', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', @@ -52,17 +78,17 @@ messageService }, }, }, - '2025-12-08 00:00:00', + {scheduledDate: '2025-12-08 00:00:00'}, ) .then(res => console.log(res)); - // 여러 메시지 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 + // 다건 발송 예제 messageService .send([ { to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '🏠 새로운 인테리어 아이디어!\n\n이번 시즌 트렌드를 확인해보세요.', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', @@ -76,7 +102,7 @@ messageService { to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '👗 스타일링 꿀팁 대공개!\n\n데일리룩부터 특별한 날까지, 모든 코디를 한번에.', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', @@ -87,49 +113,6 @@ messageService }, }, }, - // 2번째 파라미터 내 항목인 allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. ]) .then(res => console.log(res)); - - // 여러 메시지 예약 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 - // 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. - messageService - .send( - [ - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - type: 'BMS_FREE', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - bms: { - targeting: 'I', - chatBubbleType: 'IMAGE', - imageId: fileId, - }, - }, - }, - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', - type: 'BMS_FREE', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - bms: { - targeting: 'I', - chatBubbleType: 'IMAGE', - imageId: fileId, - }, - }, - }, - ], - { - scheduledDate: '2025-12-08 00:00:00', - // allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. - // allowDuplicates: true, - }, - ) - .then(res => console.log(res)); }); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_image_with_buttons.js b/examples/javascript/common/src/kakao/send/send_bms_free_image_with_buttons.js index 8227254..ae20c09 100644 --- a/examples/javascript/common/src/kakao/send/send_bms_free_image_with_buttons.js +++ b/examples/javascript/common/src/kakao/send/send_bms_free_image_with_buttons.js @@ -1,7 +1,9 @@ /** * 버튼을 포함한 카카오 BMS 자유형 IMAGE 타입 발송 예제 * 이미지 업로드 후 imageId를 사용하여 버튼과 함께 발송합니다. + * 이미지 업로드 시 fileType은 반드시 'BMS'를 사용해야 합니다. * BMS 자유형 버튼 타입: WL(웹링크), AL(앱링크), AC(채널추가), BK(봇키워드), MD(상담요청), BC(상담톡전환), BT(챗봇전환), BF(비즈니스폼) + * 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. * 그 외의 모든 채널은 I 타입만 사용 가능합니다. * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 @@ -13,65 +15,67 @@ const messageService = new SolapiMessageService( 'ENTER_YOUR_API_SECRET', ); +// IMAGE 타입은 반드시 'BMS' fileType으로 업로드해야 합니다 messageService - .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'KAKAO') + .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'BMS') .then(res => res.fileId) .then(fileId => { - // 단일 발송 예제 + // 전체 필드 단건 발송 예제 (adult, imageId, imageLink, buttons, coupon) messageService - .sendOne({ + .send({ to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '🎁 연말 감사 이벤트!\n\n한 해 동안 함께해주셔서 감사합니다.\n특별한 혜택으로 보답드려요!', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', bms: { - targeting: 'I', // I: 전체, M/N: 인허가 채널만 + targeting: 'I', chatBubbleType: 'IMAGE', + adult: false, imageId: fileId, + imageLink: 'https://example.com/year-end-event', buttons: [ { - linkType: 'WL', // 웹링크 - name: '버튼 이름', - linkMobile: 'https://m.example.com', - linkPc: 'https://example.com', // 생략 가능 + linkType: 'WL', + name: '이벤트 참여하기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', }, { - linkType: 'AL', // 앱링크 - name: '앱 실행', - linkAndroid: 'examplescheme://', - linkIos: 'examplescheme://', + linkType: 'AL', + name: '앱에서 보기', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', }, { - linkType: 'BK', // 봇키워드 - name: '봇키워드', - chatExtra: '추가 데이터', // 선택 + linkType: 'AC', + name: '채널 추가', }, { - linkType: 'MD', // 상담요청하기 - name: '상담요청하기', - chatExtra: '추가 데이터', // 선택 - }, - { - linkType: 'BT', // 챗봇 문의 - name: '챗봇 문의', - chatExtra: '추가 데이터', // 선택 + linkType: 'BK', + name: '이벤트 문의', + chatExtra: 'event_inquiry', }, ], + coupon: { + title: '10000원 할인 쿠폰', + description: '연말 감사 할인 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, }, }, }) .then(res => console.log(res)); - // 단일 예약 발송 예제 - // 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. + // 단건 예약 발송 예제 messageService - .sendOneFuture( + .send( { to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '☀️ 이번 주 날씨 좋은 날, 나들이 어때요?\n\n피크닉 용품 최대 40% 할인 중!', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', @@ -82,24 +86,24 @@ messageService buttons: [ { linkType: 'WL', - name: '버튼 이름', - linkMobile: 'https://m.example.com', + name: '피크닉 용품 보기', + linkMobile: 'https://m.example.com/picnic', }, ], }, }, }, - '2025-12-08 00:00:00', + {scheduledDate: '2025-12-08 00:00:00'}, ) .then(res => console.log(res)); - // 여러 메시지 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 + // 다건 발송 예제 messageService .send([ { to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '🍳 오늘의 레시피 추천!\n\n초간단 15분 브런치 만들기', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', @@ -110,8 +114,8 @@ messageService buttons: [ { linkType: 'WL', - name: '버튼 이름', - linkMobile: 'https://m.example.com', + name: '레시피 보기', + linkMobile: 'https://m.example.com/recipe', }, ], }, @@ -120,7 +124,7 @@ messageService { to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '🏋️ 이번 달 운동 목표 달성!\n\n축하드려요! 다음 목표도 함께 도전해요.', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', @@ -131,14 +135,13 @@ messageService buttons: [ { linkType: 'WL', - name: '버튼 이름', - linkMobile: 'https://m.example.com', + name: '새 목표 설정', + linkMobile: 'https://m.example.com/goal', }, ], }, }, }, - // 2번째 파라미터 내 항목인 allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. ]) .then(res => console.log(res)); }); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_premium_video.js b/examples/javascript/common/src/kakao/send/send_bms_free_premium_video.js index ab70af4..564cdd7 100644 --- a/examples/javascript/common/src/kakao/send/send_bms_free_premium_video.js +++ b/examples/javascript/common/src/kakao/send/send_bms_free_premium_video.js @@ -1,101 +1,149 @@ /** * 카카오 BMS 자유형 PREMIUM_VIDEO 타입 발송 예제 * 프리미엄 비디오 메시지로, 카카오TV 영상 URL과 썸네일 이미지를 포함합니다. - * video: { videoUrl, imageId, imageLink } 구조입니다. * videoUrl은 반드시 "https://tv.kakao.com/"으로 시작해야 합니다. + * 유효하지 않은 동영상 URL 기입 시 발송 상태가 그룹 정보를 찾을 수 없음 오류로 표시됩니다. + * 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. * 그 외의 모든 채널은 I 타입만 사용 가능합니다. * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 */ +const path = require('path'); const {SolapiMessageService} = require('solapi'); const messageService = new SolapiMessageService( 'ENTER_YOUR_API_KEY', 'ENTER_YOUR_API_SECRET', ); -// 단일 발송 예제 -// imageId는 미리 업로드한 이미지 ID를 사용합니다. -// 이미지 업로드: messageService.uploadFile(filePath, 'KAKAO').then(res => res.fileId) +// 최소 구조 단건 발송 예제 (video.videoUrl만) messageService - .sendOne({ + .send({ to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '동영상 메시지입니다. 아래 영상을 확인해보세요!', + text: '🎬 이번 시즌 인기 드라마 하이라이트!\n놓치신 분들을 위한 명장면 모음입니다.', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', bms: { - targeting: 'I', // I: 전체, M/N: 인허가 채널만 + targeting: 'I', chatBubbleType: 'PREMIUM_VIDEO', video: { - // videoUrl은 반드시 카카오TV URL이어야 합니다 - videoUrl: 'https://tv.kakao.com/v/123456789', - imageId: '업로드한 썸네일 이미지 ID', // 선택 (영상 썸네일) - imageLink: 'https://example.com/video-detail', // 선택 (이미지 클릭 시 이동 URL) + videoUrl: 'https://tv.kakao.com/v/460734285', }, }, }, }) .then(res => console.log(res)); -// 버튼이 포함된 프리미엄 비디오 발송 예제 +// 썸네일 이미지 업로드 후 전체 필드 발송 messageService - .sendOne({ - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '신제품 소개 영상입니다.', - type: 'BMS_FREE', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - bms: { - targeting: 'I', - chatBubbleType: 'PREMIUM_VIDEO', - video: { - videoUrl: 'https://tv.kakao.com/v/123456789', - imageId: '업로드한 썸네일 이미지 ID', - }, - buttons: [ - { - linkType: 'WL', - name: '제품 상세보기', - linkMobile: 'https://m.example.com/product', + .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'KAKAO') + .then(res => res.fileId) + .then(imageId => { + // 전체 필드 단건 발송 예제 (adult, header, content, video 전체, buttons, coupon) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🍿 주말 영화 추천!\n\n올해 가장 화제가 된 영화를 미리 만나보세요.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'PREMIUM_VIDEO', + adult: false, + header: '🎥 이 주의 추천 영화', + content: + '2024년 최고의 액션 블록버스터! 지금 바로 예고편을 확인해보세요.', + video: { + videoUrl: 'https://tv.kakao.com/v/460734285', + imageId: imageId, + imageLink: 'https://example.com/movie-trailer', + }, + buttons: [ + { + linkType: 'WL', + name: '예매하기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + ], + coupon: { + title: '10% 할인 쿠폰', + description: '영화 예매 시 사용 가능한 할인 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, }, - { - linkType: 'WL', - name: '구매하기', - linkMobile: 'https://m.example.com/buy', + }, + }) + .then(res => console.log(res)); + + // 단건 예약 발송 예제 + messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🎉 신제품 런칭 라이브!\n\n내일 오후 7시, 신제품 공개와 함께 특별 혜택도 준비했어요.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'PREMIUM_VIDEO', + video: { + videoUrl: 'https://tv.kakao.com/v/460734285', + }, + buttons: [ + { + linkType: 'WL', + name: '라이브 알림 신청', + linkMobile: 'https://example.com/live', + }, + ], + }, }, - ], - }, - }, - }) - .then(res => console.log(res)); + }, + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); -// 여러 메시지 발송 예제 -messageService - .send([ - { - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - text: '이벤트 홍보 영상입니다.', - type: 'BMS_FREE', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - bms: { - targeting: 'I', - chatBubbleType: 'PREMIUM_VIDEO', - video: { - videoUrl: 'https://tv.kakao.com/v/123456789', + // 다건 발송 예제 + messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🏋️ 오늘의 운동 루틴!\n\n전문 트레이너가 알려주는 10분 코어 운동입니다.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'PREMIUM_VIDEO', + video: { + videoUrl: 'https://tv.kakao.com/v/460734285', + }, + }, }, - buttons: [ - { - linkType: 'WL', - name: '이벤트 참여하기', - linkMobile: 'https://m.example.com/event', + }, + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🍳 5분 요리 레시피!\n\n바쁜 아침에도 간단하게 만드는 건강 한끼.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'PREMIUM_VIDEO', + video: { + videoUrl: 'https://tv.kakao.com/v/460734285', + }, }, - ], + }, }, - }, - }, - ]) - .then(res => console.log(res)); + ]) + .then(res => console.log(res)); + }); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_text.js b/examples/javascript/common/src/kakao/send/send_bms_free_text.js index e045c31..0cca238 100644 --- a/examples/javascript/common/src/kakao/send/send_bms_free_text.js +++ b/examples/javascript/common/src/kakao/send/send_bms_free_text.js @@ -11,12 +11,12 @@ const messageService = new SolapiMessageService( 'ENTER_YOUR_API_SECRET', ); -// 단일 발송 예제 +// 최소 구조 단건 발송 예제 (text만) messageService - .sendOne({ + .send({ to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '안녕하세요, 홍길동님! 오늘도 좋은 하루 되세요 🌞', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', @@ -28,14 +28,37 @@ messageService }) .then(res => console.log(res)); -// 단일 예약 발송 예제 -// 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. +// 전체 필드 단건 발송 예제 (adult, coupon 포함) +// 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" messageService - .sendOneFuture( + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🎉 홍길동님, 특별 할인 쿠폰이 도착했어요!\n\n지금 바로 사용하시면 10,000원 할인 혜택을 받으실 수 있습니다.\n유효기간: 2025년 12월 31일까지', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + adult: false, + coupon: { + title: '10000원 할인 쿠폰', + description: '신규 회원 전용 웰컴 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, + }, + }, + }) + .then(res => console.log(res)); + +// 단건 예약 발송 예제 +messageService + .send( { to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '📢 오늘 저녁 8시, 깜짝 타임세일이 시작됩니다!\n최대 50% 할인 혜택을 놓치지 마세요.', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', @@ -45,17 +68,17 @@ messageService }, }, }, - '2025-12-08 00:00:00', + {scheduledDate: '2025-12-08 00:00:00'}, ) .then(res => console.log(res)); -// 여러 메시지 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 +// 다건 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 messageService .send([ { to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '📦 주문하신 상품이 발송되었습니다!\n배송 조회: https://example.com/tracking', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', @@ -68,7 +91,7 @@ messageService { to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '✅ 회원가입이 완료되었습니다!\n지금 바로 다양한 혜택을 확인해보세요.', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', @@ -78,19 +101,17 @@ messageService }, }, }, - // 2번째 파라미터 내 항목인 allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. ]) .then(res => console.log(res)); -// 여러 메시지 예약 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 -// 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. +// 다건 예약 발송 예제 messageService .send( [ { to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '🔔 내일 오전 10시에 예약하신 상담이 진행됩니다.\n장소: 강남점 3층', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', @@ -103,7 +124,7 @@ messageService { to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '💝 생일 축하드립니다!\n특별한 생일 혜택이 준비되어 있어요.', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', @@ -114,10 +135,6 @@ messageService }, }, ], - { - scheduledDate: '2025-12-08 00:00:00', - // allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. - // allowDuplicates: true, - }, + {scheduledDate: '2025-12-08 00:00:00'}, ) .then(res => console.log(res)); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_text_with_buttons.js b/examples/javascript/common/src/kakao/send/send_bms_free_text_with_buttons.js index ecbda94..72a3b60 100644 --- a/examples/javascript/common/src/kakao/send/send_bms_free_text_with_buttons.js +++ b/examples/javascript/common/src/kakao/send/send_bms_free_text_with_buttons.js @@ -1,6 +1,7 @@ /** * 버튼을 포함한 카카오 BMS 자유형 TEXT 타입 발송 예제 * BMS 자유형 버튼 타입: WL(웹링크), AL(앱링크), AC(채널추가), BK(봇키워드), MD(상담요청), BC(상담톡전환), BT(챗봇전환), BF(비즈니스폼) + * 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. * 그 외의 모든 채널은 I 타입만 사용 가능합니다. * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 @@ -11,74 +12,60 @@ const messageService = new SolapiMessageService( 'ENTER_YOUR_API_SECRET', ); -// 단일 발송 예제 +// 전체 필드 단건 발송 예제 (adult, buttons, coupon 포함) messageService - .sendOne({ + .send({ to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '🛍️ 홍길동님을 위한 맞춤 추천!\n\n이번 주 베스트 상품을 확인해보세요.\n지금 구매 시 10% 추가 할인!', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', bms: { - targeting: 'I', // I: 전체, M/N: 인허가 채널만 + targeting: 'I', chatBubbleType: 'TEXT', + adult: false, buttons: [ { - linkType: 'WL', // 웹링크 - name: '버튼 이름', - linkMobile: 'https://m.example.com', - linkPc: 'https://example.com', // 생략 가능 + linkType: 'WL', + name: '베스트 상품 보기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', }, { - linkType: 'AL', // 앱링크 - name: '앱 실행', - linkAndroid: 'examplescheme://', - linkIos: 'examplescheme://', + linkType: 'AL', + name: '앱에서 열기', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', }, { - linkType: 'BK', // 봇키워드 (챗봇에게 키워드를 전달합니다) - name: '봇키워드', - chatExtra: '추가 데이터', // 선택 + linkType: 'AC', + name: '채널 추가', }, { - linkType: 'MD', // 상담요청하기 - name: '상담요청하기', - chatExtra: '추가 데이터', // 선택 + linkType: 'BK', + name: '1:1 문의하기', + chatExtra: 'inquiry', }, - { - linkType: 'BT', // 챗봇 문의로 전환 - name: '챗봇 문의', - chatExtra: '추가 데이터', // 선택 - }, - /* - { - linkType: 'AC', // 채널 추가 - }, - { - linkType: 'BC', // 상담톡 전환 (상담톡 서비스 사용 시 가능) - name: '상담톡 전환', - chatExtra: '추가 데이터', // 선택 - }, - { - linkType: 'BF', // 비즈니스폼 - name: '비즈니스폼', - }, - */ ], + coupon: { + title: '10% 할인 쿠폰', + description: '이번 주 한정 특별 할인 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, }, }, }) .then(res => console.log(res)); -// 단일 예약 발송 예제 -// 예약발송 시 현재 시간보다 과거의 시간을 입력할 경우 즉시 발송됩니다. +// 단건 예약 발송 예제 messageService - .sendOneFuture( + .send( { to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '⏰ 장바구니에 담은 상품이 기다리고 있어요!\n\n지금 결제하시면 무료 배송 혜택을 드려요.', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', @@ -88,24 +75,24 @@ messageService buttons: [ { linkType: 'WL', - name: '버튼 이름', - linkMobile: 'https://m.example.com', + name: '장바구니 확인', + linkMobile: 'https://m.example.com/cart', }, ], }, }, }, - '2025-12-08 00:00:00', + {scheduledDate: '2025-12-08 00:00:00'}, ) .then(res => console.log(res)); -// 여러 메시지 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 +// 다건 발송 예제 messageService .send([ { to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '💳 결제가 완료되었습니다!\n\n주문번호: ORD-2025-001234\n결제금액: 45,000원', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', @@ -115,8 +102,8 @@ messageService buttons: [ { linkType: 'WL', - name: '버튼 이름', - linkMobile: 'https://m.example.com', + name: '주문 상세 보기', + linkMobile: 'https://m.example.com/order', }, ], }, @@ -125,7 +112,7 @@ messageService { to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '🏃 오늘의 운동 리포트가 도착했어요!\n\n총 걸음수: 8,542걸음\n소모 칼로리: 320kcal', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', @@ -135,13 +122,12 @@ messageService buttons: [ { linkType: 'WL', - name: '버튼 이름', - linkMobile: 'https://m.example.com', + name: '상세 리포트 보기', + linkMobile: 'https://m.example.com/report', }, ], }, }, }, - // 2번째 파라미터 내 항목인 allowDuplicates 옵션을 true로 설정할 경우 중복 수신번호를 허용합니다. ]) .then(res => console.log(res)); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_wide.js b/examples/javascript/common/src/kakao/send/send_bms_free_wide.js index 170f959..b0d443b 100644 --- a/examples/javascript/common/src/kakao/send/send_bms_free_wide.js +++ b/examples/javascript/common/src/kakao/send/send_bms_free_wide.js @@ -1,7 +1,8 @@ /** * 카카오 BMS 자유형 WIDE 타입 발송 예제 * 와이드 이미지 형식으로, 기본 IMAGE 타입보다 넓은 이미지를 표시합니다. - * 와이드 이미지는 별도의 규격이 필요합니다. + * 이미지 업로드 시 fileType은 반드시 'BMS_WIDE'를 사용해야 합니다. + * 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. * 그 외의 모든 채널은 I 타입만 사용 가능합니다. * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 @@ -13,22 +14,22 @@ const messageService = new SolapiMessageService( 'ENTER_YOUR_API_SECRET', ); -// 와이드 이미지 업로드 (800x600 권장) +// WIDE 타입은 반드시 'BMS_WIDE' fileType으로 업로드해야 합니다 messageService - .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'KAKAO') + .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'BMS_WIDE') .then(res => res.fileId) .then(fileId => { - // 단일 발송 예제 + // 최소 구조 단건 발송 예제 (text, imageId) messageService - .sendOne({ + .send({ to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '🎬 이번 주 신작 영화 개봉!\n\n지금 예매하고 팝콘 세트 할인받으세요.', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', bms: { - targeting: 'I', // I: 전체, M/N: 인허가 채널만 + targeting: 'I', chatBubbleType: 'WIDE', imageId: fileId, }, @@ -36,28 +37,63 @@ messageService }) .then(res => console.log(res)); - // 버튼이 포함된 와이드 이미지 발송 예제 + // 전체 필드 단건 발송 예제 (adult, imageId, buttons, coupon) messageService - .sendOne({ + .send({ to: '수신번호', from: '계정에서 등록한 발신번호 입력', - text: '2,000byte 이내의 메시지 입력', + text: '✈️ 홍길동님, 여행 준비 되셨나요?\n\n얼리버드 예약 시 배송비 무료 혜택!\n여행용품 베스트 아이템을 만나보세요.', type: 'BMS_FREE', kakaoOptions: { pfId: '연동한 비즈니스 채널의 pfId', bms: { targeting: 'I', chatBubbleType: 'WIDE', + adult: false, imageId: fileId, buttons: [ { linkType: 'WL', - name: '자세히 보기', - linkMobile: 'https://m.example.com', + name: '여행용품 보기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + { + linkType: 'AL', + name: '앱에서 열기', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', }, ], + coupon: { + title: '배송비 할인 쿠폰', + description: '얼리버드 고객 전용 무료배송 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, }, }, }) .then(res => console.log(res)); + + // 단건 예약 발송 예제 + messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🌅 제주도 선셋 투어 오픈!\n\n잊지 못할 추억을 만들어드려요.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE', + imageId: fileId, + }, + }, + }, + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); }); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_wide_item_list.js b/examples/javascript/common/src/kakao/send/send_bms_free_wide_item_list.js index 9643722..9d0b231 100644 --- a/examples/javascript/common/src/kakao/send/send_bms_free_wide_item_list.js +++ b/examples/javascript/common/src/kakao/send/send_bms_free_wide_item_list.js @@ -1,67 +1,79 @@ /** * 카카오 BMS 자유형 WIDE_ITEM_LIST 타입 발송 예제 * 와이드 아이템 리스트 형식으로, 메인 와이드 아이템과 서브 와이드 아이템 목록을 표시합니다. - * header + mainWideItem + subWideItemList 구조입니다. + * header + mainWideItem + subWideItemList (최소 3개) 구조입니다. + * 메인 아이템 이미지: 'BMS_WIDE_MAIN_ITEM_LIST' fileType (2:1 비율 이미지 필수) + * 서브 아이템 이미지: 'BMS_WIDE_SUB_ITEM_LIST' fileType (1:1 비율 이미지 필수) + * 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. * 그 외의 모든 채널은 I 타입만 사용 가능합니다. * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 */ +const path = require('path'); const {SolapiMessageService} = require('solapi'); const messageService = new SolapiMessageService( 'ENTER_YOUR_API_KEY', 'ENTER_YOUR_API_SECRET', ); -// 단일 발송 예제 -// imageId는 미리 업로드한 이미지 ID를 사용합니다. -// 이미지 업로드: messageService.uploadFile(filePath, 'KAKAO').then(res => res.fileId) -messageService - .sendOne({ - to: '수신번호', - from: '계정에서 등록한 발신번호 입력', - type: 'BMS_FREE', - kakaoOptions: { - pfId: '연동한 비즈니스 채널의 pfId', - bms: { - targeting: 'I', // I: 전체, M/N: 인허가 채널만 - chatBubbleType: 'WIDE_ITEM_LIST', - header: '헤더 텍스트 (최대 25자)', - mainWideItem: { - title: '메인 와이드 아이템 타이틀 (최대 25자)', // 선택 - imageId: '업로드한 메인 와이드 이미지 ID', - linkMobile: 'https://m.example.com', - linkPc: 'https://example.com', // 선택 - // linkAndroid: 'examplescheme://', // 선택 - // linkIos: 'examplescheme://', // 선택 - }, - subWideItemList: [ - { - title: '서브 와이드 첫번째 아이템 (최대 30자)', - imageId: '업로드한 서브 와이드 이미지 ID', - linkMobile: 'https://m.example.com/item1', - linkPc: 'https://example.com/item1', // 선택 - }, - { - title: '서브 와이드 두번째 아이템 (최대 30자)', - imageId: '업로드한 서브 와이드 이미지 ID', - linkMobile: 'https://m.example.com/item2', - linkPc: 'https://example.com/item2', // 선택 - }, - { - title: '서브 와이드 세번째 아이템 (최대 30자)', - imageId: '업로드한 서브 와이드 이미지 ID', - linkMobile: 'https://m.example.com/item3', +// 메인/서브 이미지 업로드 (각각 다른 fileType 및 비율 사용) +async function uploadImages() { + // 메인 아이템: 2:1 비율 이미지 + BMS_WIDE_MAIN_ITEM_LIST fileType + const mainImage = await messageService.uploadFile( + path.join(__dirname, '../../images/example-2to1.jpg'), + 'BMS_WIDE_MAIN_ITEM_LIST', + ); + // 서브 아이템: 1:1 비율 이미지 + BMS_WIDE_SUB_ITEM_LIST fileType + const subImage = await messageService.uploadFile( + path.join(__dirname, '../../images/example-1to1.jpg'), + 'BMS_WIDE_SUB_ITEM_LIST', + ); + return {mainImageId: mainImage.fileId, subImageId: subImage.fileId}; +} + +uploadImages().then(({mainImageId, subImageId}) => { + // 최소 구조 단건 발송 예제 (header, mainWideItem, subWideItemList 3개) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE_ITEM_LIST', + header: '🛍️ 이번 주 베스트 상품', + mainWideItem: { + title: '프리미엄 블렌드 원두 1kg', + imageId: mainImageId, + linkMobile: 'https://example.com/main', }, - ], + subWideItemList: [ + { + title: '아메리카노 캡슐 30개입', + imageId: subImageId, + linkMobile: 'https://example.com/sub1', + }, + { + title: '핸드드립 필터 100매', + imageId: subImageId, + linkMobile: 'https://example.com/sub2', + }, + { + title: '보온 텀블러 500ml', + imageId: subImageId, + linkMobile: 'https://example.com/sub3', + }, + ], + }, }, - }, - }) - .then(res => console.log(res)); + }) + .then(res => console.log(res)); -// 여러 메시지 발송 예제 -messageService - .send([ - { + // 전체 필드 단건 발송 예제 (adult, header, mainWideItem, subWideItemList, buttons, coupon) + messageService + .send({ to: '수신번호', from: '계정에서 등록한 발신번호 입력', type: 'BMS_FREE', @@ -70,26 +82,94 @@ messageService bms: { targeting: 'I', chatBubbleType: 'WIDE_ITEM_LIST', - header: '신상품 모음', + adult: false, + header: '🎁 홍길동님을 위한 맞춤 추천', mainWideItem: { - title: '이번 주 베스트 상품', - imageId: '업로드한 메인 이미지 ID', - linkMobile: 'https://m.example.com/best', + title: '시그니처 스킨케어 세트', + imageId: mainImageId, + linkMobile: 'https://example.com/main', }, subWideItemList: [ { - title: '추천 상품 1', - imageId: '업로드한 서브 이미지 ID', - linkMobile: 'https://m.example.com/item1', + title: '수분 에센스 50ml', + imageId: subImageId, + linkMobile: 'https://example.com/sub1', }, { - title: '추천 상품 2', - imageId: '업로드한 서브 이미지 ID', - linkMobile: 'https://m.example.com/item2', + title: '영양 크림 30ml', + imageId: subImageId, + linkMobile: 'https://example.com/sub2', + }, + { + title: '선케어 SPF50+ 60ml', + imageId: subImageId, + linkMobile: 'https://example.com/sub3', }, ], + buttons: [ + { + linkType: 'WL', + name: '전체 상품 보기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + { + linkType: 'AL', + name: '앱에서 열기', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }, + ], + coupon: { + title: '첫구매 무료 쿠폰', + description: '첫 구매 고객님께 드리는 특별 혜택입니다.', + linkMobile: 'https://example.com/coupon', + }, + }, + }, + }) + .then(res => console.log(res)); + + // 단건 예약 발송 예제 + messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE_ITEM_LIST', + header: '📚 주간 베스트셀러 TOP4', + mainWideItem: { + title: '올해의 필독서 - 성장의 법칙', + imageId: mainImageId, + linkMobile: 'https://example.com/main', + }, + subWideItemList: [ + { + title: '마음의 정원 - 에세이', + imageId: subImageId, + linkMobile: 'https://example.com/sub1', + }, + { + title: '미래를 읽는 기술', + imageId: subImageId, + linkMobile: 'https://example.com/sub2', + }, + { + title: '요리의 기초 - 레시피북', + imageId: subImageId, + linkMobile: 'https://example.com/sub3', + }, + ], + }, }, }, - }, - ]) - .then(res => console.log(res)); + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); +});