From 90b37db63f62d7109cd88c09c6af92bf0750e94d Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 5 Jan 2026 10:00:56 +0900 Subject: [PATCH 1/6] 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/6] =?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/6] =?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/6] 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/6] 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/6] 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));