From 90b37db63f62d7109cd88c09c6af92bf0750e94d Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 5 Jan 2026 10:00:56 +0900 Subject: [PATCH 1/8] 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 2/8] =?UTF-8?q?chore(deps):=20=EC=9D=98=EC=A1=B4=EC=84=B1?= =?UTF-8?q?=20=EB=B2=84=EC=A0=84=20=EC=97=85=EB=8D=B0=EC=9D=B4=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 3/8] =?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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 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 = {}, +) => ({ + 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 8/8] 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), ), );