diff --git a/.claude/agents/tidy-first.md b/.claude/agents/tidy-first.md new file mode 100644 index 00000000..d5c8099f --- /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 49e2c826..4cd2ac15 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/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..dd07aa57 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,93 @@ +# SOLAPI SDK for Node.js + +**Generated:** 2026-01-21 +**Commit:** 9df35df +**Branch:** master + +## OVERVIEW + +Server-side SDK for SMS/LMS/MMS and Kakao messaging in Korea. Uses Effect library for type-safe functional programming with Data.TaggedError-based error handling. + +## STRUCTURE + +``` +solapi-nodejs/ +├── src/ +│ ├── index.ts # SolapiMessageService facade (entry point) +│ ├── errors/ # Data.TaggedError types +│ ├── lib/ # Core utilities (fetcher, auth, error handler) +│ ├── models/ # Schemas, requests, responses (see models/AGENTS.md) +│ ├── services/ # Domain services (see services/AGENTS.md) +│ └── types/ # Shared type definitions +├── test/ # Mirrors src/ structure +├── examples/ # Usage examples (excluded from build) +└── debug/ # Debug scripts +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +|------|----------|-------| +| Add new message type | `src/models/base/messages/` | Extend MessageType union | +| Add new service | `src/services/` | Extend DefaultService | +| Add new error type | `src/errors/defaultError.ts` | Extend Data.TaggedError | +| Add utility function | `src/lib/` | Follow Effect patterns | +| Add Kakao BMS type | `src/models/base/kakao/bms/` | Add to BMS_REQUIRED_FIELDS | +| Fix API request issue | `src/lib/defaultFetcher.ts` | HTTP client with retry | +| Understand error flow | `src/lib/effectErrorHandler.ts` | Effect → Promise conversion | + +## CONVENTIONS + +**Effect Library (MANDATORY)**: +- All errors: `Data.TaggedError` with environment-aware `toString()` +- Async operations: `Effect.gen` + `Effect.tryPromise`, never wrap with try-catch +- Validation: `Effect Schema` with `Schema.filter`, `Schema.transform` +- Error execution: `runSafePromise()` / `runSafeSync()` from effectErrorHandler + +**TypeScript**: +- **NEVER use `any`** — use `unknown` + type guards or Effect Schema +- Strict mode enforced (`noUnusedLocals`, `noUnusedParameters`) +- Path aliases: `@models`, `@lib`, `@services`, `@errors`, `@internal-types` + +**Testing**: +- Unit: `vitest` with `Schema.decodeUnknownEither()` for validation tests +- E2E: `@effect/vitest` with `it.effect()` and `Effect.gen` +- Run: `pnpm test` / `pnpm test:watch` + +## ANTI-PATTERNS + +| Pattern | Why Bad | Do Instead | +|---------|---------|------------| +| `any` type | Loses type safety | `unknown` + type guards | +| `as any`, `@ts-ignore` | Suppresses errors | Fix the type issue | +| try-catch around Effect | Loses Effect benefits | Use `Effect.catchTag` | +| Direct `throw new Error()` | Inconsistent error handling | Use `Data.TaggedError` | +| Empty catch blocks | Swallows errors | Handle or propagate | + +## COMMANDS + +```bash +pnpm dev # Watch mode (tsup) +pnpm build # Lint + build +pnpm lint # Biome check with auto-fix +pnpm test # Run tests once +pnpm test:watch # Watch mode +pnpm docs # Generate TypeDoc +``` + +## ARCHITECTURE NOTES + +**Service Facade Pattern**: `SolapiMessageService` aggregates 7 domain services via `bindServices()` dynamic method binding. All services extend `DefaultService`. + +**Error Flow**: +``` +API Response + → defaultFetcher (creates Effect errors) + → runSafePromise (converts to Promise) + → toCompatibleError (preserves properties on Error) + → Consumer +``` + +**Production vs Development**: Error messages stripped of stack traces and detailed context in production (`process.env.NODE_ENV === 'production'`). + +**Retry Logic**: `defaultFetcher.ts` implements 3x retry with exponential backoff for retryable errors (connection refused, reset, 503). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..58639724 --- /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. diff --git a/biome.json b/biome.json index cdfd3fde..e2c8cd72 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/examples/javascript/common/images/example-1to1.jpg b/examples/javascript/common/images/example-1to1.jpg new file mode 100644 index 00000000..83e3701f Binary files /dev/null and b/examples/javascript/common/images/example-1to1.jpg differ diff --git a/examples/javascript/common/images/example-2to1.jpg b/examples/javascript/common/images/example-2to1.jpg new file mode 100644 index 00000000..ae7ab5bb Binary files /dev/null and b/examples/javascript/common/images/example-2to1.jpg differ diff --git a/examples/javascript/common/src/kakao/send/send_bms.js b/examples/javascript/common/src/kakao/send/send_bms.js index 1c202dfc..46dad969 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 00000000..8b9a8082 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_carousel_commerce.js @@ -0,0 +1,219 @@ +/** + * 카카오 BMS 자유형 CAROUSEL_COMMERCE 타입 발송 예제 + * 캐러셀 커머스 형식으로, 여러 상품을 슬라이드로 보여주는 구조입니다. + * 이미지 업로드 시 fileType은 'BMS_CAROUSEL_COMMERCE_LIST'를 사용해야 합니다. (2:1 비율 이미지 필수) + * head + list(상품카드들) + tail 구조입니다. + * head 없이 2-6개 아이템, head 포함 시 1-5개 아이템 가능합니다. + * 가격 정보(regularPrice, discountPrice, discountRate, discountFixed)는 숫자 타입입니다. + * 캐러셀 커머스 버튼은 WL, AL 타입만 지원합니다. + * 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" + * 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', +); + +// CAROUSEL_COMMERCE 타입은 'BMS_CAROUSEL_COMMERCE_LIST' fileType으로 업로드해야 합니다 (2:1 비율 이미지 필수) +messageService + .uploadFile( + path.join(__dirname, '../../images/example-2to1.jpg'), + 'BMS_CAROUSEL_COMMERCE_LIST', + ) + .then(res => res.fileId) + .then(imageId => { + // 최소 구조 단건 발송 예제 (carousel.list 2개) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + list: [ + { + imageId: imageId, + commerce: { + title: '프리미엄 블루투스 스피커', + regularPrice: 129000, + discountPrice: 99000, + discountRate: 23, + }, + buttons: [ + { + linkType: 'WL', + name: '구매하기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + ], + }, + { + imageId: imageId, + commerce: { + title: '노이즈캔슬링 헤드폰', + regularPrice: 249000, + discountPrice: 199000, + discountFixed: 50000, + }, + buttons: [ + { + linkType: 'WL', + name: '구매하기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + ], + }, + ], + }, + }, + }, + }) + .then(res => console.log(res)); + + // 전체 필드 단건 발송 예제 (adult, additionalContent, carousel head/list 전체/tail) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_COMMERCE', + adult: false, + additionalContent: '🔥 이번 주 한정 특가!', + carousel: { + head: { + header: '홍길동님을 위한 추천', + content: '최근 관심 상품과 비슷한 아이템을 모았어요!', + imageId: imageId, + linkMobile: 'https://example.com/recommend', + }, + list: [ + { + imageId: imageId, + commerce: { + title: '에어프라이어 대용량 5.5L', + regularPrice: 159000, + discountPrice: 119000, + discountRate: 25, + }, + additionalContent: '⚡ 무료배송', + imageLink: 'https://example.com/airfryer', + buttons: [ + { + linkType: 'WL', + name: '지금 구매', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + { + linkType: 'AL', + name: '앱에서 보기', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }, + ], + coupon: { + title: '10000원 할인 쿠폰', + description: '첫 구매 고객 전용 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, + }, + { + imageId: imageId, + commerce: { + title: '스마트 로봇청소기 프로', + regularPrice: 499000, + discountPrice: 399000, + discountFixed: 100000, + }, + buttons: [ + { + linkType: 'WL', + name: '상세 보기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + ], + }, + ], + tail: { + linkMobile: 'https://example.com/all-products', + }, + }, + }, + }, + }) + .then(res => console.log(res)); + + // 단건 예약 발송 예제 + messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + list: [ + { + imageId: imageId, + commerce: { + title: '겨울 롱패딩 - 그레이', + regularPrice: 299000, + discountPrice: 199000, + discountRate: 33, + }, + buttons: [ + { + linkType: 'WL', + name: '바로 구매', + linkMobile: 'https://example.com/padding-gray', + }, + ], + }, + { + imageId: imageId, + commerce: { + title: '겨울 롱패딩 - 블랙', + regularPrice: 299000, + discountPrice: 199000, + discountRate: 33, + }, + buttons: [ + { + linkType: 'WL', + name: '바로 구매', + linkMobile: 'https://example.com/padding-black', + }, + ], + }, + ], + tail: { + linkMobile: 'https://example.com/winter-sale', + }, + }, + }, + }, + }, + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); + }); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_carousel_feed.js b/examples/javascript/common/src/kakao/send/send_bms_free_carousel_feed.js new file mode 100644 index 00000000..6afbf695 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_carousel_feed.js @@ -0,0 +1,201 @@ +/** + * 카카오 BMS 자유형 CAROUSEL_FEED 타입 발송 예제 + * 캐러셀 피드 형식으로, 여러 카드를 좌우로 슬라이드하는 구조입니다. + * 이미지 업로드 시 fileType은 'BMS_CAROUSEL_FEED_LIST'를 사용해야 합니다. (2:1 비율 이미지 필수) + * head 없이 2-6개 아이템, head 포함 시 1-5개 아이템 가능합니다. + * 캐러셀 피드 버튼은 WL, AL 타입만 지원합니다. + * 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const path = require('path'); +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// CAROUSEL_FEED 타입은 'BMS_CAROUSEL_FEED_LIST' fileType으로 업로드해야 합니다 (2:1 비율 이미지 필수) +messageService + .uploadFile( + path.join(__dirname, '../../images/example-2to1.jpg'), + 'BMS_CAROUSEL_FEED_LIST', + ) + .then(res => res.fileId) + .then(imageId => { + // 최소 구조 단건 발송 예제 (carousel.list 2개) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [ + { + header: '🍳 오늘의 브런치 레시피', + content: + '15분 만에 완성하는 아보카도 토스트! 간단하지만 영양 만점이에요.', + imageId: imageId, + buttons: [ + { + linkType: 'WL', + name: '레시피 보기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + ], + }, + { + header: '☕ 홈카페 꿀팁', + content: '집에서 바리스타처럼! 라떼 아트 도전해보세요.', + imageId: imageId, + buttons: [ + { + linkType: 'WL', + name: '영상 보기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + ], + }, + ], + }, + }, + }, + }) + .then(res => console.log(res)); + + // 전체 필드 단건 발송 예제 (adult, carousel head/list 전체/tail) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_FEED', + adult: false, + carousel: { + list: [ + { + header: '🏃 마라톤 완주 도전!', + content: + '첫 마라톤 완주를 목표로 8주 트레이닝 프로그램을 시작해보세요.', + imageId: imageId, + imageLink: 'https://example.com/marathon', + buttons: [ + { + linkType: 'WL', + name: '프로그램 신청', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + { + linkType: 'AL', + name: '앱에서 보기', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }, + ], + coupon: { + title: '10% 할인 쿠폰', + description: '첫 등록 고객 전용 할인 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, + }, + { + header: '🧘 요가 입문 클래스', + content: + '초보자를 위한 기초 요가 동작을 배워보세요. 유연성과 마음의 평화를 함께!', + imageId: imageId, + buttons: [ + { + linkType: 'WL', + name: '클래스 보기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + ], + }, + { + header: '💪 홈트레이닝 루틴', + content: '장비 없이도 OK! 집에서 하는 30분 전신 운동 루틴.', + imageId: imageId, + buttons: [ + { + linkType: 'AL', + name: '영상 시청', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }, + ], + }, + ], + tail: { + linkMobile: 'https://example.com/more', + linkPc: 'https://example.com/more', + }, + }, + }, + }, + }) + .then(res => console.log(res)); + + // 단건 예약 발송 예제 + messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [ + { + header: '🎄 크리스마스 특별 이벤트', + content: '연말 맞이 특별 할인! 인기 상품 최대 50% OFF', + imageId: imageId, + buttons: [ + { + linkType: 'WL', + name: '이벤트 참여', + linkMobile: 'https://example.com/christmas', + }, + ], + }, + { + header: '🎁 선물 포장 무료', + content: + '소중한 분께 마음을 전하세요. 고급 선물 포장 무료!', + imageId: imageId, + buttons: [ + { + linkType: 'WL', + name: '선물하기', + linkMobile: 'https://example.com/gift', + }, + ], + }, + ], + }, + }, + }, + }, + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); + }); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_commerce.js b/examples/javascript/common/src/kakao/send/send_bms_free_commerce.js new file mode 100644 index 00000000..22ff5411 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_commerce.js @@ -0,0 +1,159 @@ +/** + * 카카오 BMS 자유형 COMMERCE 타입 발송 예제 + * 커머스(상품) 메시지로, 상품 이미지와 가격 정보, 쿠폰을 포함합니다. + * 이미지 업로드 시 fileType은 'BMS'를 사용해야 합니다. (2:1 비율 이미지 권장) + * COMMERCE 타입은 buttons가 필수입니다 (최소 1개). + * 가격 정보(regularPrice, discountPrice, discountRate, discountFixed)는 숫자 타입입니다. + * 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const path = require('path'); +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// COMMERCE 타입은 'BMS' fileType으로 업로드해야 합니다 (2:1 비율 이미지 권장) +messageService + .uploadFile(path.join(__dirname, '../../images/example-2to1.jpg'), 'BMS') + .then(res => res.fileId) + .then(imageId => { + // 최소 구조 단건 발송 예제 (imageId, commerce title만, buttons 1개) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + imageId: imageId, + commerce: { + title: '프리미엄 무선 이어폰', + regularPrice: 89000, + }, + buttons: [ + { + linkType: 'WL', + name: '상품 보기', + linkMobile: 'https://example.com/product', + }, + ], + }, + }, + }) + .then(res => console.log(res)); + + // 전체 필드 단건 발송 예제 (adult, additionalContent, imageId, commerce 전체, buttons, coupon) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + adult: false, + additionalContent: '🚀 오늘 주문 시 내일 도착! 무료배송', + imageId: imageId, + commerce: { + title: '스마트 공기청정기 2024 신형', + regularPrice: 299000, + discountPrice: 209000, + discountRate: 30, + }, + buttons: [ + { + linkType: 'WL', + name: '지금 구매하기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + { + linkType: 'AL', + name: '앱에서 보기', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }, + ], + coupon: { + title: '포인트 UP 쿠폰', + description: '구매 시 2배 적립 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, + }, + }, + }) + .then(res => console.log(res)); + + // 단건 예약 발송 예제 + messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + imageId: imageId, + commerce: { + title: '겨울 패딩 점퍼 - 한정판', + regularPrice: 189000, + discountPrice: 149000, + }, + buttons: [ + { + linkType: 'WL', + name: '바로 구매', + linkMobile: 'https://example.com/buy', + }, + ], + }, + }, + }, + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); + + // 다건 발송 예제 + messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + imageId: imageId, + commerce: { + title: '유기농 그래놀라 선물세트', + regularPrice: 45000, + discountPrice: 38000, + }, + buttons: [ + { + linkType: 'WL', + name: '선물하기', + linkMobile: 'https://example.com/gift', + }, + ], + }, + }, + }, + ]) + .then(res => console.log(res)); + }); 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 00000000..d93f3a46 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_image.js @@ -0,0 +1,118 @@ +/** + * 카카오 BMS 자유형 IMAGE 타입 발송 예제 + * 이미지 업로드 후 imageId를 사용하여 발송합니다. + * 이미지 업로드 시 fileType은 반드시 'BMS'를 사용해야 합니다. + * 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', +); + +// IMAGE 타입은 반드시 'BMS' fileType으로 업로드해야 합니다 +messageService + .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'BMS') + .then(res => res.fileId) + .then(fileId => { + // 최소 구조 단건 발송 예제 (text, imageId) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🆕 신상품이 입고되었어요!\n지금 바로 확인해보세요.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + imageId: fileId, + }, + }, + }) + .then(res => console.log(res)); + + // 전체 필드 단건 발송 예제 (adult, imageId, imageLink, coupon) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🎊 홍길동님, VIP 고객 전용 특별 이벤트!\n\n프리미엄 회원만을 위한 시크릿 세일이 시작되었습니다.\n최대 70% 할인 혜택을 놓치지 마세요!', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + adult: false, + imageId: fileId, + imageLink: 'https://example.com/vip-sale', + coupon: { + title: '10000원 할인 쿠폰', + description: 'VIP 고객 전용 할인 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, + }, + }, + }) + .then(res => console.log(res)); + + // 단건 예약 발송 예제 + messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🌸 봄 신상 컬렉션 오픈!\n\n3월 1일 오전 10시, 첫 공개됩니다.\n알림 설정하고 가장 먼저 만나보세요!', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + imageId: fileId, + }, + }, + }, + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); + + // 다건 발송 예제 + messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🏠 새로운 인테리어 아이디어!\n\n이번 시즌 트렌드를 확인해보세요.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + imageId: fileId, + }, + }, + }, + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '👗 스타일링 꿀팁 대공개!\n\n데일리룩부터 특별한 날까지, 모든 코디를 한번에.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + imageId: fileId, + }, + }, + }, + ]) + .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 00000000..ae20c097 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_image_with_buttons.js @@ -0,0 +1,147 @@ +/** + * 버튼을 포함한 카카오 BMS 자유형 IMAGE 타입 발송 예제 + * 이미지 업로드 후 imageId를 사용하여 버튼과 함께 발송합니다. + * 이미지 업로드 시 fileType은 반드시 'BMS'를 사용해야 합니다. + * BMS 자유형 버튼 타입: WL(웹링크), AL(앱링크), AC(채널추가), BK(봇키워드), MD(상담요청), BC(상담톡전환), BT(챗봇전환), BF(비즈니스폼) + * 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const path = require('path'); +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// IMAGE 타입은 반드시 'BMS' fileType으로 업로드해야 합니다 +messageService + .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'BMS') + .then(res => res.fileId) + .then(fileId => { + // 전체 필드 단건 발송 예제 (adult, imageId, imageLink, buttons, coupon) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🎁 연말 감사 이벤트!\n\n한 해 동안 함께해주셔서 감사합니다.\n특별한 혜택으로 보답드려요!', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + adult: false, + imageId: fileId, + imageLink: 'https://example.com/year-end-event', + buttons: [ + { + linkType: 'WL', + name: '이벤트 참여하기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + { + linkType: 'AL', + name: '앱에서 보기', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }, + { + linkType: 'AC', + name: '채널 추가', + }, + { + linkType: 'BK', + name: '이벤트 문의', + chatExtra: 'event_inquiry', + }, + ], + coupon: { + title: '10000원 할인 쿠폰', + description: '연말 감사 할인 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, + }, + }, + }) + .then(res => console.log(res)); + + // 단건 예약 발송 예제 + messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '☀️ 이번 주 날씨 좋은 날, 나들이 어때요?\n\n피크닉 용품 최대 40% 할인 중!', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + imageId: fileId, + buttons: [ + { + linkType: 'WL', + name: '피크닉 용품 보기', + linkMobile: 'https://m.example.com/picnic', + }, + ], + }, + }, + }, + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); + + // 다건 발송 예제 + messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🍳 오늘의 레시피 추천!\n\n초간단 15분 브런치 만들기', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + imageId: fileId, + buttons: [ + { + linkType: 'WL', + name: '레시피 보기', + linkMobile: 'https://m.example.com/recipe', + }, + ], + }, + }, + }, + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🏋️ 이번 달 운동 목표 달성!\n\n축하드려요! 다음 목표도 함께 도전해요.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'IMAGE', + imageId: fileId, + buttons: [ + { + linkType: 'WL', + name: '새 목표 설정', + linkMobile: 'https://m.example.com/goal', + }, + ], + }, + }, + }, + ]) + .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 00000000..564cdd7e --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_premium_video.js @@ -0,0 +1,149 @@ +/** + * 카카오 BMS 자유형 PREMIUM_VIDEO 타입 발송 예제 + * 프리미엄 비디오 메시지로, 카카오TV 영상 URL과 썸네일 이미지를 포함합니다. + * videoUrl은 반드시 "https://tv.kakao.com/"으로 시작해야 합니다. + * 유효하지 않은 동영상 URL 기입 시 발송 상태가 그룹 정보를 찾을 수 없음 오류로 표시됩니다. + * 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const path = require('path'); +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// 최소 구조 단건 발송 예제 (video.videoUrl만) +messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🎬 이번 시즌 인기 드라마 하이라이트!\n놓치신 분들을 위한 명장면 모음입니다.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'PREMIUM_VIDEO', + video: { + videoUrl: 'https://tv.kakao.com/v/460734285', + }, + }, + }, + }) + .then(res => console.log(res)); + +// 썸네일 이미지 업로드 후 전체 필드 발송 +messageService + .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'KAKAO') + .then(res => res.fileId) + .then(imageId => { + // 전체 필드 단건 발송 예제 (adult, header, content, video 전체, buttons, coupon) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🍿 주말 영화 추천!\n\n올해 가장 화제가 된 영화를 미리 만나보세요.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'PREMIUM_VIDEO', + adult: false, + header: '🎥 이 주의 추천 영화', + content: + '2024년 최고의 액션 블록버스터! 지금 바로 예고편을 확인해보세요.', + video: { + videoUrl: 'https://tv.kakao.com/v/460734285', + imageId: imageId, + imageLink: 'https://example.com/movie-trailer', + }, + buttons: [ + { + linkType: 'WL', + name: '예매하기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + ], + coupon: { + title: '10% 할인 쿠폰', + description: '영화 예매 시 사용 가능한 할인 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, + }, + }, + }) + .then(res => console.log(res)); + + // 단건 예약 발송 예제 + messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🎉 신제품 런칭 라이브!\n\n내일 오후 7시, 신제품 공개와 함께 특별 혜택도 준비했어요.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'PREMIUM_VIDEO', + video: { + videoUrl: 'https://tv.kakao.com/v/460734285', + }, + buttons: [ + { + linkType: 'WL', + name: '라이브 알림 신청', + linkMobile: 'https://example.com/live', + }, + ], + }, + }, + }, + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); + + // 다건 발송 예제 + messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🏋️ 오늘의 운동 루틴!\n\n전문 트레이너가 알려주는 10분 코어 운동입니다.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'PREMIUM_VIDEO', + video: { + videoUrl: 'https://tv.kakao.com/v/460734285', + }, + }, + }, + }, + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🍳 5분 요리 레시피!\n\n바쁜 아침에도 간단하게 만드는 건강 한끼.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'PREMIUM_VIDEO', + video: { + videoUrl: 'https://tv.kakao.com/v/460734285', + }, + }, + }, + }, + ]) + .then(res => console.log(res)); + }); 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 00000000..0cca2381 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_text.js @@ -0,0 +1,140 @@ +/** + * 카카오 BMS 자유형 TEXT 타입 발송 예제 + * 텍스트만 포함하는 가장 기본적인 BMS 자유형 메시지입니다. + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// 최소 구조 단건 발송 예제 (text만) +messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '안녕하세요, 홍길동님! 오늘도 좋은 하루 되세요 🌞', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', // I: 전체, M/N: 인허가 채널만 + chatBubbleType: 'TEXT', + }, + }, + }) + .then(res => console.log(res)); + +// 전체 필드 단건 발송 예제 (adult, coupon 포함) +// 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" +messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🎉 홍길동님, 특별 할인 쿠폰이 도착했어요!\n\n지금 바로 사용하시면 10,000원 할인 혜택을 받으실 수 있습니다.\n유효기간: 2025년 12월 31일까지', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + adult: false, + coupon: { + title: '10000원 할인 쿠폰', + description: '신규 회원 전용 웰컴 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, + }, + }, + }) + .then(res => console.log(res)); + +// 단건 예약 발송 예제 +messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '📢 오늘 저녁 8시, 깜짝 타임세일이 시작됩니다!\n최대 50% 할인 혜택을 놓치지 마세요.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + }, + }, + }, + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); + +// 다건 발송 예제, 한 번 호출 당 최대 10,000건 까지 발송 가능 +messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '📦 주문하신 상품이 발송되었습니다!\n배송 조회: https://example.com/tracking', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + }, + }, + }, + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '✅ 회원가입이 완료되었습니다!\n지금 바로 다양한 혜택을 확인해보세요.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + }, + }, + }, + ]) + .then(res => console.log(res)); + +// 다건 예약 발송 예제 +messageService + .send( + [ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🔔 내일 오전 10시에 예약하신 상담이 진행됩니다.\n장소: 강남점 3층', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + }, + }, + }, + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '💝 생일 축하드립니다!\n특별한 생일 혜택이 준비되어 있어요.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + }, + }, + }, + ], + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_text_with_buttons.js b/examples/javascript/common/src/kakao/send/send_bms_free_text_with_buttons.js new file mode 100644 index 00000000..72a3b602 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_text_with_buttons.js @@ -0,0 +1,133 @@ +/** + * 버튼을 포함한 카카오 BMS 자유형 TEXT 타입 발송 예제 + * BMS 자유형 버튼 타입: WL(웹링크), AL(앱링크), AC(채널추가), BK(봇키워드), MD(상담요청), BC(상담톡전환), BT(챗봇전환), BF(비즈니스폼) + * 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// 전체 필드 단건 발송 예제 (adult, buttons, coupon 포함) +messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🛍️ 홍길동님을 위한 맞춤 추천!\n\n이번 주 베스트 상품을 확인해보세요.\n지금 구매 시 10% 추가 할인!', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + adult: false, + buttons: [ + { + linkType: 'WL', + name: '베스트 상품 보기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + { + linkType: 'AL', + name: '앱에서 열기', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }, + { + linkType: 'AC', + name: '채널 추가', + }, + { + linkType: 'BK', + name: '1:1 문의하기', + chatExtra: 'inquiry', + }, + ], + coupon: { + title: '10% 할인 쿠폰', + description: '이번 주 한정 특별 할인 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, + }, + }, + }) + .then(res => console.log(res)); + +// 단건 예약 발송 예제 +messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '⏰ 장바구니에 담은 상품이 기다리고 있어요!\n\n지금 결제하시면 무료 배송 혜택을 드려요.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + buttons: [ + { + linkType: 'WL', + name: '장바구니 확인', + linkMobile: 'https://m.example.com/cart', + }, + ], + }, + }, + }, + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); + +// 다건 발송 예제 +messageService + .send([ + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '💳 결제가 완료되었습니다!\n\n주문번호: ORD-2025-001234\n결제금액: 45,000원', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + buttons: [ + { + linkType: 'WL', + name: '주문 상세 보기', + linkMobile: 'https://m.example.com/order', + }, + ], + }, + }, + }, + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🏃 오늘의 운동 리포트가 도착했어요!\n\n총 걸음수: 8,542걸음\n소모 칼로리: 320kcal', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'TEXT', + buttons: [ + { + linkType: 'WL', + name: '상세 리포트 보기', + linkMobile: 'https://m.example.com/report', + }, + ], + }, + }, + }, + ]) + .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 00000000..b0d443b3 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_wide.js @@ -0,0 +1,99 @@ +/** + * 카카오 BMS 자유형 WIDE 타입 발송 예제 + * 와이드 이미지 형식으로, 기본 IMAGE 타입보다 넓은 이미지를 표시합니다. + * 이미지 업로드 시 fileType은 반드시 'BMS_WIDE'를 사용해야 합니다. + * 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const path = require('path'); +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// WIDE 타입은 반드시 'BMS_WIDE' fileType으로 업로드해야 합니다 +messageService + .uploadFile(path.join(__dirname, '../../images/example.jpg'), 'BMS_WIDE') + .then(res => res.fileId) + .then(fileId => { + // 최소 구조 단건 발송 예제 (text, imageId) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🎬 이번 주 신작 영화 개봉!\n\n지금 예매하고 팝콘 세트 할인받으세요.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE', + imageId: fileId, + }, + }, + }) + .then(res => console.log(res)); + + // 전체 필드 단건 발송 예제 (adult, imageId, buttons, coupon) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '✈️ 홍길동님, 여행 준비 되셨나요?\n\n얼리버드 예약 시 배송비 무료 혜택!\n여행용품 베스트 아이템을 만나보세요.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE', + adult: false, + imageId: fileId, + buttons: [ + { + linkType: 'WL', + name: '여행용품 보기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + { + linkType: 'AL', + name: '앱에서 열기', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }, + ], + coupon: { + title: '배송비 할인 쿠폰', + description: '얼리버드 고객 전용 무료배송 쿠폰입니다.', + linkMobile: 'https://example.com/coupon', + }, + }, + }, + }) + .then(res => console.log(res)); + + // 단건 예약 발송 예제 + messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + text: '🌅 제주도 선셋 투어 오픈!\n\n잊지 못할 추억을 만들어드려요.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE', + imageId: fileId, + }, + }, + }, + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); + }); diff --git a/examples/javascript/common/src/kakao/send/send_bms_free_wide_item_list.js b/examples/javascript/common/src/kakao/send/send_bms_free_wide_item_list.js new file mode 100644 index 00000000..9d0b2313 --- /dev/null +++ b/examples/javascript/common/src/kakao/send/send_bms_free_wide_item_list.js @@ -0,0 +1,175 @@ +/** + * 카카오 BMS 자유형 WIDE_ITEM_LIST 타입 발송 예제 + * 와이드 아이템 리스트 형식으로, 메인 와이드 아이템과 서브 와이드 아이템 목록을 표시합니다. + * header + mainWideItem + subWideItemList (최소 3개) 구조입니다. + * 메인 아이템 이미지: 'BMS_WIDE_MAIN_ITEM_LIST' fileType (2:1 비율 이미지 필수) + * 서브 아이템 이미지: 'BMS_WIDE_SUB_ITEM_LIST' fileType (1:1 비율 이미지 필수) + * 쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" + * targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. + * 그 외의 모든 채널은 I 타입만 사용 가능합니다. + * 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 + */ +const path = require('path'); +const {SolapiMessageService} = require('solapi'); +const messageService = new SolapiMessageService( + 'ENTER_YOUR_API_KEY', + 'ENTER_YOUR_API_SECRET', +); + +// 메인/서브 이미지 업로드 (각각 다른 fileType 및 비율 사용) +async function uploadImages() { + // 메인 아이템: 2:1 비율 이미지 + BMS_WIDE_MAIN_ITEM_LIST fileType + const mainImage = await messageService.uploadFile( + path.join(__dirname, '../../images/example-2to1.jpg'), + 'BMS_WIDE_MAIN_ITEM_LIST', + ); + // 서브 아이템: 1:1 비율 이미지 + BMS_WIDE_SUB_ITEM_LIST fileType + const subImage = await messageService.uploadFile( + path.join(__dirname, '../../images/example-1to1.jpg'), + 'BMS_WIDE_SUB_ITEM_LIST', + ); + return {mainImageId: mainImage.fileId, subImageId: subImage.fileId}; +} + +uploadImages().then(({mainImageId, subImageId}) => { + // 최소 구조 단건 발송 예제 (header, mainWideItem, subWideItemList 3개) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE_ITEM_LIST', + header: '🛍️ 이번 주 베스트 상품', + mainWideItem: { + title: '프리미엄 블렌드 원두 1kg', + imageId: mainImageId, + linkMobile: 'https://example.com/main', + }, + subWideItemList: [ + { + title: '아메리카노 캡슐 30개입', + imageId: subImageId, + linkMobile: 'https://example.com/sub1', + }, + { + title: '핸드드립 필터 100매', + imageId: subImageId, + linkMobile: 'https://example.com/sub2', + }, + { + title: '보온 텀블러 500ml', + imageId: subImageId, + linkMobile: 'https://example.com/sub3', + }, + ], + }, + }, + }) + .then(res => console.log(res)); + + // 전체 필드 단건 발송 예제 (adult, header, mainWideItem, subWideItemList, buttons, coupon) + messageService + .send({ + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE_ITEM_LIST', + adult: false, + header: '🎁 홍길동님을 위한 맞춤 추천', + mainWideItem: { + title: '시그니처 스킨케어 세트', + imageId: mainImageId, + linkMobile: 'https://example.com/main', + }, + subWideItemList: [ + { + title: '수분 에센스 50ml', + imageId: subImageId, + linkMobile: 'https://example.com/sub1', + }, + { + title: '영양 크림 30ml', + imageId: subImageId, + linkMobile: 'https://example.com/sub2', + }, + { + title: '선케어 SPF50+ 60ml', + imageId: subImageId, + linkMobile: 'https://example.com/sub3', + }, + ], + buttons: [ + { + linkType: 'WL', + name: '전체 상품 보기', + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }, + { + linkType: 'AL', + name: '앱에서 열기', + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }, + ], + coupon: { + title: '첫구매 무료 쿠폰', + description: '첫 구매 고객님께 드리는 특별 혜택입니다.', + linkMobile: 'https://example.com/coupon', + }, + }, + }, + }) + .then(res => console.log(res)); + + // 단건 예약 발송 예제 + messageService + .send( + { + to: '수신번호', + from: '계정에서 등록한 발신번호 입력', + type: 'BMS_FREE', + kakaoOptions: { + pfId: '연동한 비즈니스 채널의 pfId', + bms: { + targeting: 'I', + chatBubbleType: 'WIDE_ITEM_LIST', + header: '📚 주간 베스트셀러 TOP4', + mainWideItem: { + title: '올해의 필독서 - 성장의 법칙', + imageId: mainImageId, + linkMobile: 'https://example.com/main', + }, + subWideItemList: [ + { + title: '마음의 정원 - 에세이', + imageId: subImageId, + linkMobile: 'https://example.com/sub1', + }, + { + title: '미래를 읽는 기술', + imageId: subImageId, + linkMobile: 'https://example.com/sub2', + }, + { + title: '요리의 기초 - 레시피북', + imageId: subImageId, + linkMobile: 'https://example.com/sub3', + }, + ], + }, + }, + }, + {scheduledDate: '2025-12-08 00:00:00'}, + ) + .then(res => console.log(res)); +}); 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 14c62393..00000000 --- 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 2c0ca2bb..00000000 --- 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 a58299b6..00000000 --- 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 1f4fb76e..00000000 --- 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)); - }); diff --git a/package.json b/package.json index cf592107..1332454c 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", + "@types/node": "^25.0.9", "dotenv": "^17.2.3", "tsup": "^8.5.1", - "typedoc": "^0.28.14", + "typedoc": "^0.28.16", "typescript": "^5.9.3", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "^4.0.14" + "vite-tsconfig-paths": "^6.0.4", + "vitest": "^4.0.17" }, "packageManager": "pnpm@10.15.1", "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e3f8313..14274bac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,18 +12,18 @@ 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.17(@types/node@25.0.9)(yaml@2.8.1)) '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.0.9 + version: 25.0.9 dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -31,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.16 + version: 0.28.16(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.4 + version: 6.0.4(typescript@5.9.3)(vite@7.1.5(@types/node@25.0.9)(yaml@2.8.1)) vitest: - specifier: ^4.0.14 - version: 4.0.14(@types/node@24.10.1)(yaml@2.8.1) + specifier: ^4.0.17 + version: 4.0.17(@types/node@25.0.9)(yaml@2.8.1) packages: - '@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==} @@ -575,17 +578,17 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - '@types/node@24.10.1': - resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/node@25.0.9': + resolution: {integrity: sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@vitest/expect@4.0.14': - resolution: {integrity: sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==} + '@vitest/expect@4.0.17': + resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==} - '@vitest/mocker@4.0.14': - resolution: {integrity: sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==} + '@vitest/mocker@4.0.17': + resolution: {integrity: sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0-0 @@ -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.17': + resolution: {integrity: sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==} - '@vitest/runner@4.0.14': - resolution: {integrity: sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==} + '@vitest/runner@4.0.17': + resolution: {integrity: sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==} - '@vitest/snapshot@4.0.14': - resolution: {integrity: sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==} + '@vitest/snapshot@4.0.17': + resolution: {integrity: sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==} - '@vitest/spy@4.0.14': - resolution: {integrity: sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==} + '@vitest/spy@4.0.17': + resolution: {integrity: sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==} - '@vitest/utils@4.0.14': - resolution: {integrity: sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==} + '@vitest/utils@4.0.17': + resolution: {integrity: sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==} 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.16: + resolution: {integrity: sha512-x4xW77QC3i5DUFMBp0qjukOTnr/sSg+oEs86nB3LjDslvAmwe/PUGDWbe3GrIqt59oTqoXK5GRK9tAa0sYMiog==} 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.4: + resolution: {integrity: sha512-iIsEJ+ek5KqRTK17pmxtgIxXtqr3qDdE6OxrP9mVeGhVDNXRJTKN/l9oMbujTQNzMLe6XZ8qmpztfbkPu2TiFQ==} 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.17: + resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.14 - '@vitest/browser-preview': 4.0.14 - '@vitest/browser-webdriverio': 4.0.14 - '@vitest/ui': 4.0.14 + '@vitest/browser-playwright': 4.0.17 + '@vitest/browser-preview': 4.0.17 + '@vitest/browser-webdriverio': 4.0.17 + '@vitest/ui': 4.0.17 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -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.17(@types/node@25.0.9)(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.17(@types/node@25.0.9)(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': {} @@ -1475,49 +1489,49 @@ snapshots: dependencies: '@types/unist': 3.0.3 - '@types/node@24.10.1': + '@types/node@25.0.9': dependencies: undici-types: 7.16.0 '@types/unist@3.0.3': {} - '@vitest/expect@4.0.14': + '@vitest/expect@4.0.17': 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.17 + '@vitest/utils': 4.0.17 + 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.17(vite@7.1.5(@types/node@25.0.9)(yaml@2.8.1))': dependencies: - '@vitest/spy': 4.0.14 + '@vitest/spy': 4.0.17 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.1.5(@types/node@24.10.1)(yaml@2.8.1) + vite: 7.1.5(@types/node@25.0.9)(yaml@2.8.1) - '@vitest/pretty-format@4.0.14': + '@vitest/pretty-format@4.0.17': dependencies: tinyrainbow: 3.0.3 - '@vitest/runner@4.0.14': + '@vitest/runner@4.0.17': dependencies: - '@vitest/utils': 4.0.14 + '@vitest/utils': 4.0.17 pathe: 2.0.3 - '@vitest/snapshot@4.0.14': + '@vitest/snapshot@4.0.17': dependencies: - '@vitest/pretty-format': 4.0.14 + '@vitest/pretty-format': 4.0.17 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.14': {} + '@vitest/spy@4.0.17': {} - '@vitest/utils@4.0.14': + '@vitest/utils@4.0.17': dependencies: - '@vitest/pretty-format': 4.0.14 + '@vitest/pretty-format': 4.0.17 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.16(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,18 +1974,18 @@ 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.4(typescript@5.9.3)(vite@7.1.5(@types/node@25.0.9)(yaml@2.8.1)): dependencies: debug: 4.4.1 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.1.5(@types/node@24.10.1)(yaml@2.8.1) + vite: 7.1.5(@types/node@25.0.9)(yaml@2.8.1) transitivePeerDependencies: - supports-color - typescript - vite@7.1.5(@types/node@24.10.1)(yaml@2.8.1): + vite@7.1.5(@types/node@25.0.9)(yaml@2.8.1): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -1976,19 +1994,19 @@ snapshots: rollup: 4.50.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.1 + '@types/node': 25.0.9 fsevents: 2.3.3 yaml: 2.8.1 - vitest@4.0.14(@types/node@24.10.1)(yaml@2.8.1): + vitest@4.0.17(@types/node@25.0.9)(yaml@2.8.1): dependencies: - '@vitest/expect': 4.0.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.17 + '@vitest/mocker': 4.0.17(vite@7.1.5(@types/node@25.0.9)(yaml@2.8.1)) + '@vitest/pretty-format': 4.0.17 + '@vitest/runner': 4.0.17 + '@vitest/snapshot': 4.0.17 + '@vitest/spy': 4.0.17 + '@vitest/utils': 4.0.17 es-module-lexer: 1.7.0 expect-type: 1.2.2 magic-string: 0.30.21 @@ -1997,13 +2015,13 @@ 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) + vite: 7.1.5(@types/node@25.0.9)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.10.1 + '@types/node': 25.0.9 transitivePeerDependencies: - jiti - less diff --git a/src/errors/defaultError.ts b/src/errors/defaultError.ts index 2f87e9e6..c9313303 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/AGENTS.md b/src/lib/AGENTS.md new file mode 100644 index 00000000..54b065c9 --- /dev/null +++ b/src/lib/AGENTS.md @@ -0,0 +1,63 @@ +# Core Library Utilities + +## OVERVIEW + +Cross-cutting utilities used by all services. Effect-based async handling and error management. + +## STRUCTURE + +``` +lib/ +├── defaultFetcher.ts # HTTP client with Effect.gen, retry, Match +├── effectErrorHandler.ts # runSafePromise, toCompatibleError, formatError +├── authenticator.ts # HMAC-SHA256 auth header generation +├── stringifyQuery.ts # URL query string builder +├── fileToBase64.ts # File/URL → Base64 converter +└── stringDateTrasnfer.ts # Date parsing with InvalidDateError +``` + +## WHERE TO LOOK + +| Task | File | Notes | +|------|------|-------| +| HTTP request issues | `defaultFetcher.ts` | Retry logic, error handling | +| Error formatting | `effectErrorHandler.ts` | Production vs dev messages | +| Auth issues | `authenticator.ts` | HMAC signature generation | +| Query params | `stringifyQuery.ts` | Array handling, encoding | +| File handling | `fileToBase64.ts` | URL detection, Base64 encoding | +| Date parsing | `stringDateTrasnfer.ts` | ISO format conversion | + +## CONVENTIONS + +**Effect.tryPromise for Async**: +```typescript +Effect.tryPromise({ + try: () => fetch(url, options), + catch: e => new NetworkError({ url, cause: e }), +}); +``` + +**Effect.gen for Complex Flow**: +```typescript +Effect.gen(function* (_) { + const auth = yield* _(buildAuth(params)); + const response = yield* _(fetchWithRetry(url, auth)); + return yield* _(parseResponse(response)); +}); +``` + +**Error to Promise Conversion**: +```typescript +// Always use runSafePromise for Effect → Promise +return runSafePromise(effect); + +// Never wrap Effect with try-catch +// BAD: try { await Effect.runPromise(...) } catch { } +``` + +## ANTI-PATTERNS + +- Don't bypass `runSafePromise` — loses error formatting +- Don't use try-catch around Effect — use Effect.catchTag +- Don't create new HTTP client — use defaultFetcher +- Don't hardcode API URL — use DefaultService.baseUrl diff --git a/src/lib/defaultFetcher.ts b/src/lib/defaultFetcher.ts index 52d4fb3e..e6079a40 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 b1cd5f56..8f7443ad 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) { @@ -38,6 +41,42 @@ export const formatError = (error: unknown): string => { return String(error); }; +/** + * Defect(예측되지 않은 에러)에서 정보 추출 + */ +const extractDefectInfo = ( + defect: unknown, +): {summary: string; details: string} => { + // Effect Tagged Error인 경우 + if (defect && typeof defect === 'object' && '_tag' in defect) { + const tag = (defect as {_tag: string})._tag; + const message = + 'message' in defect ? String((defect as {message: unknown}).message) : ''; + return { + summary: `${tag}${message ? `: ${message}` : ''}`, + details: `Tagged Error [${tag}]: ${JSON.stringify(defect, null, 2)}`, + }; + } + + // 일반 객체인 경우 + if (defect !== null && typeof defect === 'object') { + const keys = Object.keys(defect); + const summary = + keys.length > 0 + ? `Object with keys: ${keys.slice(0, 3).join(', ')}${keys.length > 3 ? '...' : ''}` + : 'Empty object'; + return { + summary, + details: JSON.stringify(defect, null, 2), + }; + } + + return { + summary: String(defect), + details: `Value (${typeof defect}): ${String(defect)}`, + }; +}; + // Effect Cause를 프로덕션용으로 포맷팅 export const formatCauseForProduction = ( cause: Cause.Cause, @@ -46,7 +85,16 @@ export const formatCauseForProduction = ( if (failure._tag === 'Some') { return formatError(failure.value); } - return 'Unknown error occurred'; + + // Defect 정보도 포함 + const defects = Cause.defects(cause); + if (defects.length > 0) { + const firstDefect = Chunk.unsafeGet(defects, 0); + const info = extractDefectInfo(firstDefect); + return `Unexpected error: ${info.summary}`; + } + + return 'Effect execution failed'; }; // Effect 프로그램의 실행 결과를 안전하게 처리 @@ -59,15 +107,30 @@ export const runSafeSync = (effect: Effect.Effect): A => { if (failure._tag === 'Some') { throw toCompatibleError(failure.value); } + // 예측되지 않은 예외(Defect)인지 확인 const defects = Cause.defects(cause); if (defects.length > 0) { const firstDefect = Chunk.unsafeGet(defects, 0); if (firstDefect instanceof Error) { throw firstDefect; } - throw new Error(`Uncaught defect: ${String(firstDefect)}`); + const isProduction = process.env.NODE_ENV === 'production'; + const defectInfo = extractDefectInfo(firstDefect); + const message = isProduction + ? `Unexpected error: ${defectInfo.summary}` + : `Unexpected error: ${defectInfo.details}\nCause: ${Cause.pretty(cause)}`; + const error = new Error(message); + error.name = 'UnexpectedDefectError'; + throw error; } - throw new Error(`Unhandled Exit: ${Cause.pretty(cause)}`); + // 그 외 (예: 중단)의 경우 + const isProduction = process.env.NODE_ENV === 'production'; + const message = isProduction + ? 'Effect execution failed unexpectedly' + : `Unhandled Effect Exit:\n${Cause.pretty(cause)}`; + const error = new Error(message); + error.name = 'UnhandledExitError'; + throw error; }, onSuccess: value => value, }); @@ -91,19 +154,26 @@ export const runSafePromise = ( if (defects.length > 0) { const firstDefect = Chunk.unsafeGet(defects, 0); if (firstDefect instanceof Error) { - // 원본 Error 객체를 그대로 반환 return Promise.reject(firstDefect); } - // Error 객체가 아니면 새로 생성 - return Promise.reject( - new Error(`Uncaught defect: ${String(firstDefect)}`), - ); + const isProduction = process.env.NODE_ENV === 'production'; + const defectInfo = extractDefectInfo(firstDefect); + const message = isProduction + ? `Unexpected error: ${defectInfo.summary}` + : `Unexpected error: ${defectInfo.details}\nCause: ${Cause.pretty(cause)}`; + const error = new Error(message); + error.name = 'UnexpectedDefectError'; + return Promise.reject(error); } - // 3. 그 외 (예: 중단)의 경우, Cause를 문자열로 변환하여 반환 - return Promise.reject( - new Error(`Unhandled Exit: ${Cause.pretty(cause)}`), - ); + // 3. 그 외 (예: 중단)의 경우 + const isProduction = process.env.NODE_ENV === 'production'; + const message = isProduction + ? 'Effect execution failed unexpectedly' + : `Unhandled Effect Exit:\n${Cause.pretty(cause)}`; + const error = new Error(message); + error.name = 'UnhandledExitError'; + return Promise.reject(error); }, onSuccess: value => Promise.resolve(value), }), @@ -147,10 +217,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 +245,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()); @@ -279,10 +386,36 @@ export const toCompatibleError = (effectError: unknown): Error => { return error; } + // Unknown 에러 타입에 대한 개선된 처리 + // Tagged Error 확인 (_tag 속성 존재 여부) + if (effectError && typeof effectError === 'object' && '_tag' in effectError) { + const taggedError = effectError as {_tag: string}; + const formatted = formatError(effectError); + const error = new Error(formatted); + error.name = `UnknownTaggedError_${taggedError._tag}`; + if (!isProduction) { + Object.defineProperty(error, 'originalError', { + value: effectError, + writable: false, + enumerable: true, + }); + } + if (isProduction) { + delete error.stack; + } + return error; + } + const formatted = formatError(effectError); - // 하위 호환성을 위해 여전히 Error 사용하지만 스택 제거 const error = new Error(formatted); - error.name = 'FromSolapiError'; + error.name = 'UnknownSolapiError'; + if (!isProduction) { + Object.defineProperty(error, 'originalError', { + value: effectError, + writable: false, + enumerable: true, + }); + } if (isProduction) { delete error.stack; } diff --git a/src/models/AGENTS.md b/src/models/AGENTS.md new file mode 100644 index 00000000..333e57ff --- /dev/null +++ b/src/models/AGENTS.md @@ -0,0 +1,84 @@ +# Models Layer + +## OVERVIEW + +Three-layer model architecture using Effect Schema for runtime validation. + +## STRUCTURE + +``` +models/ +├── base/ # Core domain entities +│ ├── messages/message.ts # MessageType, messageSchema +│ ├── kakao/ +│ │ ├── kakaoOption.ts # BMS validation, VariableValidationError +│ │ ├── kakaoButton.ts # Discriminated union (8 types) +│ │ └── bms/ # 7 BMS chat bubble schemas +│ ├── rcs/ # RCS options and buttons +│ └── naver/ # Naver Talk Talk +├── requests/ # Input → API payload transformation +│ ├── messages/ # Send, group, query requests +│ ├── kakao/ # Channel/template operations +│ ├── iam/ # Block list management +│ └── common/datePayload.ts # Shared date range type +└── responses/ # API response types (mostly type-only) +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +|------|----------|-------| +| Add message type | `base/messages/message.ts` | Add to MessageType union | +| Add BMS type | `base/kakao/bms/` + `kakaoOption.ts` | Update BMS_REQUIRED_FIELDS | +| Add button variant | `base/kakao/kakaoButton.ts` | Discriminated union pattern | +| Add request validation | `requests/` domain folder | Use Schema.transform | +| Add response type | `responses/` domain folder | Type-only usually sufficient | + +## CONVENTIONS + +**Type + Schema + Class Pattern**: +```typescript +// 1. Type +export type MyType = Schema.Schema.Type; + +// 2. Schema +export const mySchema = Schema.Struct({ + field: Schema.String, + optional: Schema.optional(Schema.Number), +}); + +// 3. Class (optional, for runtime behavior) +export class MyClass { + constructor(parameter: MyType) { /* ... */ } +} +``` + +**Discriminated Union**: +```typescript +export const buttonSchema = Schema.Union( + webButtonSchema, // { linkType: 'WL', ... } + appButtonSchema, // { linkType: 'AL', ... } +); +``` + +**Custom Validation**: +```typescript +Schema.String.pipe( + Schema.filter(isValid, { message: () => 'Error message' }), +); +``` + +**Transform with Validation**: +```typescript +Schema.transform(Schema.String, Schema.String, { + decode: input => normalize(input), + encode: output => output, +}); +``` + +## ANTI-PATTERNS + +- Don't skip schema validation for user input +- Don't use interfaces when schema needed — use Schema.Struct +- Don't duplicate validation logic — compose schemas +- Don't create class without schema — validate first diff --git a/src/models/base/kakao/bms/bmsButton.ts b/src/models/base/kakao/bms/bmsButton.ts new file mode 100644 index 00000000..19bf18df --- /dev/null +++ b/src/models/base/kakao/bms/bmsButton.ts @@ -0,0 +1,195 @@ +import {Schema} from 'effect'; + +/** + * BMS 버튼 링크 타입 + * AC: 채널 추가 + * WL: 웹 링크 + * AL: 앱 링크 + * BK: 봇 키워드 + * MD: 메시지 전달 + * BC: 상담 요청 + * BT: 봇 전환 + * BF: 비즈니스폼 + */ +export const bmsButtonLinkTypeSchema = Schema.Literal( + 'AC', + 'WL', + 'AL', + 'BK', + 'MD', + 'BC', + 'BT', + 'BF', +); + +export type BmsButtonLinkType = Schema.Schema.Type< + typeof bmsButtonLinkTypeSchema +>; + +/** + * BMS 웹 링크 버튼 스키마 (WL) + * - name: 버튼명 (필수) + * - linkMobile: 모바일 링크 (필수) + * - linkPc: PC 링크 (선택) + * - targetOut: 외부 브라우저 열기 (선택) + */ +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 앱 링크 버튼 스키마 (AL) + * - name: 버튼명 (필수) + * - linkMobile, linkAndroid, linkIos 중 하나 이상 필수 + * - targetOut: 외부 브라우저 열기 (선택) + */ +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 채널 추가 버튼 스키마 (AC) + * - name: 서버에서 삭제되므로 선택 + */ +export const bmsChannelAddButtonSchema = Schema.Struct({ + name: Schema.optional(Schema.String), + linkType: Schema.Literal('AC'), +}); + +export type BmsChannelAddButton = Schema.Schema.Type< + typeof bmsChannelAddButtonSchema +>; + +/** + * 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 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 상담 요청 버튼 스키마 (BC) + * - name: 버튼명 (필수) + * - chatExtra: 상담사에게 전달할 추가 정보 (선택) + */ +export const bmsConsultButtonSchema = Schema.Struct({ + name: Schema.String, + linkType: Schema.Literal('BC'), + chatExtra: Schema.optional(Schema.String), +}); + +export type BmsConsultButton = Schema.Schema.Type< + typeof bmsConsultButtonSchema +>; + +/** + * BMS 봇 전환 버튼 스키마 (BT) + * - name: 버튼명 (필수) + * - chatExtra: 봇에 전달할 추가 정보 (선택) + */ +export const bmsBotTransferButtonSchema = Schema.Struct({ + name: Schema.String, + linkType: Schema.Literal('BT'), + chatExtra: Schema.optional(Schema.String), +}); + +export type BmsBotTransferButton = Schema.Schema.Type< + typeof bmsBotTransferButtonSchema +>; + +/** + * BMS 비즈니스폼 버튼 스키마 (BF) + * - name: 버튼명 (필수) + */ +export const bmsBusinessFormButtonSchema = Schema.Struct({ + name: Schema.String, + 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) - 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만 허용) - 캐러셀 등 일부 타입에서 사용 + */ +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 00000000..15d4ae1d --- /dev/null +++ b/src/models/base/kakao/bms/bmsCarousel.ts @@ -0,0 +1,149 @@ +import {Schema} from 'effect'; +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용) + */ +export type BmsCarouselFeedItem = { + header: string; + content: string; + imageId: string; + imageLink?: string; + buttons: ReadonlyArray>; + coupon?: Schema.Schema.Type; +}; + +/** + * BMS 캐러셀 커머스 아이템 타입 (CAROUSEL_COMMERCE용) + */ +export type BmsCarouselCommerceItem = { + commerce: Schema.Schema.Type; + imageId: string; + imageLink?: string; + buttons: ReadonlyArray>; + additionalContent?: string; + coupon?: Schema.Schema.Type; +}; + +/** + * BMS 캐러셀 피드 아이템 스키마 (CAROUSEL_FEED용) + * - header: 헤더 (필수, max 20자) + * - content: 내용 (필수, max 180자) + * - imageId: 이미지 ID (필수, BMS_CAROUSEL_FEED_LIST 타입) + * - imageLink: 이미지 클릭 시 이동 링크 (선택) + * - buttons: 버튼 목록 (필수, 1-2개, WL/AL만) + * - coupon: 쿠폰 (선택) + */ +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), +}); + +export type BmsCarouselFeedItemSchema = Schema.Schema.Type< + typeof bmsCarouselFeedItemSchema +>; + +/** + * BMS 캐러셀 커머스 아이템 스키마 (CAROUSEL_COMMERCE용) + * - commerce: 커머스 정보 (필수) + * - imageId: 이미지 ID (필수, BMS_CAROUSEL_COMMERCE_LIST 타입) + * - imageLink: 이미지 클릭 시 이동 링크 (선택) + * - buttons: 버튼 목록 (필수, 1-2개, WL/AL만) + * - 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), +}); + +export type BmsCarouselCommerceItemSchema = Schema.Schema.Type< + typeof bmsCarouselCommerceItemSchema +>; + +/** + * 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< + typeof bmsCarouselFeedSchema +>; + +/** + * 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 new file mode 100644 index 00000000..c4e1dd22 --- /dev/null +++ b/src/models/base/kakao/bms/bmsCommerce.ts @@ -0,0 +1,127 @@ +import {ParseResult, Schema} from 'effect'; + +/** + * BMS 커머스 정보 타입 + */ +export type BmsCommerce = { + title: string; + regularPrice: number; + discountPrice?: number; + discountRate?: number; + 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 커머스 가격 조합 검증 + * + * 카카오 BMS 커머스 타입은 다음 가격 조합만 허용합니다: + * 1. regularPrice만 사용 (정가만 표기) + * 2. regularPrice + discountPrice + discountRate (할인율 표기) + * 3. regularPrice + discountPrice + discountFixed (정액 할인 표기) + * + * discountRate와 discountFixed를 동시에 사용하거나, + * discountPrice 없이 discountRate/discountFixed만 사용하는 것은 허용되지 않습니다. + */ +const validateCommercePricingCombination = (commerce: { + regularPrice: number; + discountPrice?: number; + discountRate?: number; + discountFixed?: number; +}): boolean | string => { + const hasDiscountPrice = commerce.discountPrice !== undefined; + const hasDiscountRate = commerce.discountRate !== undefined; + const hasDiscountFixed = commerce.discountFixed !== undefined; + + // Case 1: regularPrice만 사용 (정가만 표기) - valid + if (!hasDiscountPrice && !hasDiscountRate && !hasDiscountFixed) { + return true; + } + + // Case 2: regularPrice + discountPrice + discountRate (할인율 표기) - valid + if (hasDiscountPrice && hasDiscountRate && !hasDiscountFixed) { + return true; + } + + // Case 3: regularPrice + discountPrice + discountFixed (정액 할인 표기) - valid + if (hasDiscountPrice && hasDiscountFixed && !hasDiscountRate) { + return true; + } + + // Invalid combinations + if (hasDiscountRate && hasDiscountFixed) { + return 'discountRate와 discountFixed는 동시에 사용할 수 없습니다. 할인율(discountRate) 또는 정액할인(discountFixed) 중 하나만 선택하세요.'; + } + + if (!hasDiscountPrice && (hasDiscountRate || hasDiscountFixed)) { + return 'discountRate 또는 discountFixed를 사용하려면 discountPrice(할인가)도 함께 지정해야 합니다.'; + } + + // discountPrice만 있는 경우 (discountRate/discountFixed 없음) + if (hasDiscountPrice && !hasDiscountRate && !hasDiscountFixed) { + return 'discountPrice를 사용하려면 discountRate(할인율) 또는 discountFixed(정액할인) 중 하나를 함께 지정해야 합니다.'; + } + + return '알 수 없는 가격 조합입니다. regularPrice만 사용하거나, regularPrice + discountPrice + discountRate/discountFixed 조합을 사용하세요.'; +}; + +/** + * BMS 커머스 정보 스키마 + * - title: 상품명 (필수) + * - regularPrice: 정가 (필수, 숫자 또는 숫자형 문자열) + * - discountPrice: 할인가 (선택, 숫자 또는 숫자형 문자열) + * - discountRate: 할인율 (선택, 숫자 또는 숫자형 문자열) + * - discountFixed: 고정 할인금액 (선택, 숫자 또는 숫자형 문자열) + * + * 가격 조합 규칙: + * - regularPrice만 사용 (정가만 표기) + * - regularPrice + discountPrice + discountRate (할인율 표기) + * - regularPrice + discountPrice + discountFixed (정액 할인 표기) + */ +export const bmsCommerceSchema = Schema.Struct({ + title: Schema.String, + regularPrice: NumberOrNumericString, + discountPrice: Schema.optional(NumberOrNumericString), + discountRate: Schema.optional(NumberOrNumericString), + discountFixed: Schema.optional(NumberOrNumericString), +}).pipe(Schema.filter(validateCommercePricingCombination)); + +export type BmsCommerceSchema = Schema.Schema.Type; diff --git a/src/models/base/kakao/bms/bmsCoupon.ts b/src/models/base/kakao/bms/bmsCoupon.ts new file mode 100644 index 00000000..38fa8ebe --- /dev/null +++ b/src/models/base/kakao/bms/bmsCoupon.ts @@ -0,0 +1,86 @@ +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 쿠폰 제목 스키마 + * 5가지 형식 허용: + * - "${숫자}원 할인 쿠폰" (1~99,999,999) + * - "${숫자}% 할인 쿠폰" (1~100) + * - "배송비 할인 쿠폰" + * - "${7자 이내} 무료 쿠폰" + * - "${7자 이내} UP 쿠폰" + */ +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 쿠폰 타입 + */ +export type BmsCoupon = { + title: BmsCouponTitle; + description: string; + linkMobile?: string; + linkPc?: string; + linkAndroid?: string; + linkIos?: string; +}; + +/** + * 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 00000000..470481ac --- /dev/null +++ b/src/models/base/kakao/bms/bmsVideo.ts @@ -0,0 +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 = { + videoUrl: string; + imageId?: string; + imageLink?: string; +}; + +/** + * BMS 비디오 정보 스키마 + * - videoUrl: 카카오TV 동영상 URL (필수, https://tv.kakao.com/으로 시작) + * - imageId: 썸네일 이미지 ID (선택) + * - imageLink: 이미지 클릭 시 이동할 링크 (선택) + */ +export const bmsVideoSchema = Schema.Struct({ + 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 new file mode 100644 index 00000000..dfe77222 --- /dev/null +++ b/src/models/base/kakao/bms/bmsWideItem.ts @@ -0,0 +1,74 @@ +import {Schema} from 'effect'; + +/** + * BMS 메인 와이드 아이템 타입 (WIDE_ITEM_LIST용) + */ +export type BmsMainWideItem = { + title?: string; + imageId: string; + linkMobile: string; + linkPc?: string; + linkAndroid?: string; + linkIos?: string; +}; + +/** + * BMS 서브 와이드 아이템 타입 (WIDE_ITEM_LIST용) + */ +export type BmsSubWideItem = { + title: string; + imageId: string; + linkMobile: string; + linkPc?: string; + linkAndroid?: string; + linkIos?: string; +}; + +/** + * BMS 메인 와이드 아이템 스키마 + * - title: 제목 (선택, max 25자) + * - imageId: 이미지 ID (필수, BMS_WIDE_MAIN_ITEM_LIST 타입) + * - linkMobile: 모바일 링크 (필수) + * - linkPc, linkAndroid, linkIos: 링크 (선택) + */ +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, + imageId: Schema.String, + linkMobile: Schema.String, + linkPc: Schema.optional(Schema.String), + linkAndroid: Schema.optional(Schema.String), + linkIos: Schema.optional(Schema.String), +}); + +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 new file mode 100644 index 00000000..26cf8810 --- /dev/null +++ b/src/models/base/kakao/bms/index.ts @@ -0,0 +1,72 @@ +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 { + type BmsCarouselCommerceItem, + type BmsCarouselCommerceItemSchema, + type BmsCarouselCommerceSchema, + type BmsCarouselFeedItem, + type BmsCarouselFeedItemSchema, + type BmsCarouselFeedSchema, + type BmsCarouselHeadSchema, + type BmsCarouselTailSchema, + bmsCarouselCommerceHeadSchema, + bmsCarouselCommerceItemSchema, + bmsCarouselCommerceSchema, + bmsCarouselCommerceTailSchema, + bmsCarouselFeedItemSchema, + bmsCarouselFeedSchema, + bmsCarouselHeadSchema, + bmsCarouselTailSchema, +} 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 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 27431ed1..cf5d19e8 100644 --- a/src/models/base/kakao/kakaoOption.ts +++ b/src/models/base/kakao/kakaoOption.ts @@ -1,6 +1,16 @@ +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, + bmsMainWideItemSchema, + bmsSubWideItemSchema, + bmsVideoSchema, +} from './bms'; import {KakaoButton, kakaoButtonSchema} from './kakaoButton'; // Effect Data 타입을 활용한 에러 클래스 @@ -15,10 +25,118 @@ 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별 필수 필드 정의 + * - 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: ['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 옵션 기본 스키마 (검증 전) + */ +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), + content: Schema.optional(Schema.String), + + // 복합 타입 필드 + 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), + video: Schema.optional(bmsVideoSchema), }); +type BaseBmsSchemaType = Schema.Schema.Type; + +const WIDE_ITEM_LIST_MIN_SUB_ITEMS = 3; + +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(', ')}`; + } + + if (chatBubbleType === 'WIDE_ITEM_LIST') { + const subWideItemList = bms.subWideItemList; + if ( + !subWideItemList || + subWideItemList.length < WIDE_ITEM_LIST_MIN_SUB_ITEMS + ) { + return `WIDE_ITEM_LIST 타입의 subWideItemList는 최소 ${WIDE_ITEM_LIST_MIN_SUB_ITEMS}개 이상이어야 합니다. 현재: ${subWideItemList?.length ?? 0}개`; + } + } + + return true; +}; + +/** + * 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/src/models/base/messages/message.ts b/src/models/base/messages/message.ts index f84c6dae..1630c100 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/models/requests/messages/groupMessageRequest.ts b/src/models/requests/messages/groupMessageRequest.ts index 86143269..f8eebdf7 100644 --- a/src/models/requests/messages/groupMessageRequest.ts +++ b/src/models/requests/messages/groupMessageRequest.ts @@ -41,7 +41,18 @@ export type FileIds = { fileIds: ReadonlyArray; }; -export type FileType = 'KAKAO' | 'MMS' | 'DOCUMENT' | 'RCS' | 'FAX'; +export type FileType = + | 'KAKAO' + | 'MMS' + | 'DOCUMENT' + | 'RCS' + | 'FAX' + | 'BMS' + | 'BMS_WIDE' + | 'BMS_WIDE_MAIN_ITEM_LIST' + | 'BMS_WIDE_SUB_ITEM_LIST' + | 'BMS_CAROUSEL_FEED_LIST' + | 'BMS_CAROUSEL_COMMERCE_LIST'; export type FileUploadRequest = { file: string; diff --git a/src/services/AGENTS.md b/src/services/AGENTS.md new file mode 100644 index 00000000..692df02e --- /dev/null +++ b/src/services/AGENTS.md @@ -0,0 +1,67 @@ +# Services Layer + +## OVERVIEW + +Domain services extending `DefaultService` base class. Each service handles one API domain. + +## STRUCTURE + +``` +services/ +├── defaultService.ts # Base class: auth, HTTP abstraction +├── messages/ +│ ├── messageService.ts # send(), sendOne(), getMessages() +│ └── groupService.ts # Group operations (create, add, send) +├── kakao/ +│ ├── channels/ # Channel CRUD +│ └── templates/ # Template CRUD with Effect.all +├── cash/cashService.ts # getBalance() +├── iam/iamService.ts # Block lists, 080 rejection +└── storage/storageService.ts # File uploads +``` + +## WHERE TO LOOK + +| Task | File | Notes | +|------|------|-------| +| Add new service | Create in domain folder | Extend DefaultService | +| Modify HTTP behavior | `defaultService.ts` | Base URL, auth handling | +| Complex Effect logic | `messageService.ts` | Reference for Effect.gen pattern | +| Parallel processing | `kakaoTemplateService.ts` | Effect.all example | + +## CONVENTIONS + +**Service Pattern**: +```typescript +export default class MyService extends DefaultService { + constructor(apiKey: string, apiSecret: string) { + super(apiKey, apiSecret); + } + + async myMethod(data: Request): Promise { + return this.request({ + httpMethod: 'POST', + url: 'my/endpoint', + body: data, + }); + } +} +``` + +**Effect.gen Pattern** (for complex logic): +```typescript +async send(messages: Request): Promise { + const effect = Effect.gen(function* (_) { + const validated = yield* _(validateSchema(messages)); + const response = yield* _(Effect.promise(() => this.request(...))); + return response; + }); + return runSafePromise(effect); +} +``` + +## ANTI-PATTERNS + +- Don't call `defaultFetcher` directly — use `this.request()` +- Don't bypass schema validation — always validate input +- Don't mix Effect and Promise styles — pick one per method diff --git a/src/services/messages/messageService.ts b/src/services/messages/messageService.ts index 3d58650f..62acb190 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/assets/example-1to1.jpg b/test/assets/example-1to1.jpg new file mode 100644 index 00000000..83e3701f Binary files /dev/null and b/test/assets/example-1to1.jpg differ diff --git a/test/assets/example-2to1.jpg b/test/assets/example-2to1.jpg new file mode 100644 index 00000000..ae7ab5bb Binary files /dev/null and b/test/assets/example-2to1.jpg differ diff --git a/test/lib/bms-test-utils.ts b/test/lib/bms-test-utils.ts new file mode 100644 index 00000000..49b97ab7 --- /dev/null +++ b/test/lib/bms-test-utils.ts @@ -0,0 +1,283 @@ +import path from 'path'; +import type { + BmsCarouselCommerceItemSchema, + BmsCarouselFeedItemSchema, + BmsMainWideItemSchema, + BmsSubWideItemSchema, +} from '@/models/base/kakao/bms'; +import type {BmsChatBubbleType} from '@/models/base/kakao/kakaoOption'; +import type {FileType} from '@/models/requests/messages/groupMessageRequest'; +import type StorageService from '@/services/storage/storageService'; + +/** + * BMS chatBubbleType별 이미지 업로드 타입 매핑 + */ +export const BMS_IMAGE_TYPES = { + TEXT: 'KAKAO', // 이미지 불필요 + IMAGE: 'BMS', + WIDE: 'BMS_WIDE', + WIDE_ITEM_LIST_MAIN: 'BMS_WIDE_MAIN_ITEM_LIST', + WIDE_ITEM_LIST_SUB: 'BMS_WIDE_SUB_ITEM_LIST', + COMMERCE: 'BMS', + CAROUSEL_FEED: 'BMS_CAROUSEL_FEED_LIST', + CAROUSEL_COMMERCE: 'BMS_CAROUSEL_COMMERCE_LIST', + PREMIUM_VIDEO: 'KAKAO', // 동영상 썸네일용 (선택) +} as const; + +export type BmsImageType = + (typeof BMS_IMAGE_TYPES)[keyof typeof BMS_IMAGE_TYPES]; + +/** + * BMS 테스트용 유틸리티 함수들 + */ + +/** + * BMS 옵션 생성 헬퍼 + */ +export const createBmsOption = ( + chatBubbleType: BmsChatBubbleType, + overrides: Record = {}, +) => ({ + targeting: 'I' as const, + chatBubbleType, + ...overrides, +}); + +/** + * BMS Commerce 데이터 생성 헬퍼 + */ +export const createBmsCommerce = ( + overrides: { + title?: string; + regularPrice?: number; + discountPrice?: number; + discountRate?: number; + discountFixed?: number; + } = {}, +) => ({ + title: '테스트 상품', + regularPrice: 10000, + ...overrides, +}); + +/** + * BMS 쿠폰 생성 헬퍼 + * 5가지 프리셋 중 하나로 생성 + */ +type CouponTitleType = + | 'won' // N원 할인 쿠폰 + | 'percent' // N% 할인 쿠폰 + | 'shipping' // 배송비 할인 쿠폰 + | 'free' // OOO 무료 쿠폰 + | 'up'; // OOO UP 쿠폰 + +export const createBmsCoupon = (titleType: CouponTitleType = 'won') => { + const titles: Record = { + won: '10000원 할인 쿠폰', + percent: '10% 할인 쿠폰', + shipping: '배송비 할인 쿠폰', + free: '첫구매 무료 쿠폰', + up: '포인트 UP 쿠폰', + }; + + return { + title: titles[titleType], + description: '테스트 쿠폰', + linkMobile: 'https://example.com/coupon', + }; +}; + +/** + * BMS 버튼 생성 헬퍼 + * linkType별로 생성 + */ +type ButtonLinkType = 'WL' | 'AL' | 'AC' | 'BK' | 'MD' | 'BC' | 'BT' | 'BF'; + +export const createBmsButton = (linkType: ButtonLinkType) => { + switch (linkType) { + case 'WL': + return { + name: '웹링크 버튼', + linkType: 'WL' as const, + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }; + case 'AL': + return { + name: '앱링크 버튼', + linkType: 'AL' as const, + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }; + case 'AC': + return { + name: '채널 추가', + linkType: 'AC' as const, + }; + case 'BK': + return { + name: '봇 키워드', + linkType: 'BK' as const, + chatExtra: 'test_keyword', + }; + case 'MD': + return { + name: '메시지 전달', + linkType: 'MD' as const, + chatExtra: 'test_message', + }; + case 'BC': + return { + name: '상담 요청', + linkType: 'BC' as const, + chatExtra: 'test_consult', + }; + case 'BT': + return { + name: '봇 전환', + linkType: 'BT' as const, + chatExtra: 'test_bot', + }; + case 'BF': + return { + name: '비즈니스폼', + linkType: 'BF' as const, + }; + } +}; + +/** + * BMS 링크 버튼 생성 헬퍼 (캐러셀용 - WL, AL만 지원) + */ +export const createBmsLinkButton = (linkType: 'WL' | 'AL' = 'WL') => { + if (linkType === 'WL') { + return { + name: '웹링크 버튼', + linkType: 'WL' as const, + linkMobile: 'https://example.com', + linkPc: 'https://example.com', + }; + } + return { + name: '앱링크 버튼', + linkType: 'AL' as const, + linkMobile: 'https://example.com', + linkAndroid: 'examplescheme://path', + linkIos: 'examplescheme://path', + }; +}; + +/** + * 캐러셀 피드 아이템 생성 헬퍼 + */ +export const createCarouselFeedItem = ( + imageId: string, + overrides: Partial = {}, +): BmsCarouselFeedItemSchema => ({ + header: '캐러셀 헤더', + content: '캐러셀 내용입니다.', + imageId, + buttons: [createBmsLinkButton('WL')], + ...overrides, +}); + +/** + * 캐러셀 커머스 아이템 생성 헬퍼 + */ +export const createCarouselCommerceItem = ( + imageId: string, + overrides: Partial = {}, +): BmsCarouselCommerceItemSchema => ({ + commerce: createBmsCommerce(), + imageId, + buttons: [createBmsLinkButton('WL')], + ...overrides, +}); + +/** + * 메인 와이드 아이템 생성 헬퍼 + */ +export const createMainWideItem = ( + imageId: string, + overrides: Partial = {}, +): BmsMainWideItemSchema => ({ + title: '메인 아이템', + imageId, + linkMobile: 'https://example.com/main', + ...overrides, +}); + +/** + * 서브 와이드 아이템 생성 헬퍼 + */ +export const createSubWideItem = ( + imageId: string, + title: string, + overrides: Partial = {}, +): BmsSubWideItemSchema => ({ + title, + imageId, + linkMobile: 'https://example.com/sub', + ...overrides, +}); + +/** + * BMS 이미지 업로드 헬퍼 (타입 지정 가능) + */ +export const uploadBmsImage = async ( + storageService: StorageService, + imagePath: string, + fileType: FileType = 'KAKAO', +): Promise => { + const result = await storageService.uploadFile(imagePath, fileType); + return result.fileId; +}; + +/** + * BMS chatBubbleType별 이미지 업로드 헬퍼 모음 + */ +export const uploadBmsImageForType = { + /** IMAGE, COMMERCE용 이미지 업로드 */ + bms: (storageService: StorageService, imagePath: string) => + uploadBmsImage(storageService, imagePath, 'BMS'), + /** WIDE용 이미지 업로드 */ + wide: (storageService: StorageService, imagePath: string) => + uploadBmsImage(storageService, imagePath, 'BMS_WIDE'), + /** WIDE_ITEM_LIST 메인 아이템용 이미지 업로드 */ + wideMainItem: (storageService: StorageService, imagePath: string) => + uploadBmsImage(storageService, imagePath, 'BMS_WIDE_MAIN_ITEM_LIST'), + /** WIDE_ITEM_LIST 서브 아이템용 이미지 업로드 */ + wideSubItem: (storageService: StorageService, imagePath: string) => + uploadBmsImage(storageService, imagePath, 'BMS_WIDE_SUB_ITEM_LIST'), + /** CAROUSEL_FEED용 이미지 업로드 */ + carouselFeed: (storageService: StorageService, imagePath: string) => + uploadBmsImage(storageService, imagePath, 'BMS_CAROUSEL_FEED_LIST'), + /** CAROUSEL_COMMERCE용 이미지 업로드 */ + carouselCommerce: (storageService: StorageService, imagePath: string) => + uploadBmsImage(storageService, imagePath, 'BMS_CAROUSEL_COMMERCE_LIST'), +}; + +/** + * 테스트용 기본 이미지 경로 반환 + */ +export const getTestImagePath = (dirname: string): string => { + return path.resolve( + dirname, + '../../../examples/javascript/common/images/example.jpg', + ); +}; + +/** + * 테스트용 2:1 비율 이미지 경로 반환 (BMS WIDE_SUB_ITEM_LIST 등) + */ +export const getTestImagePath2to1 = (dirname: string): string => { + return path.resolve(dirname, '../../../test/assets/example-2to1.jpg'); +}; + +/** + * 테스트용 1:1 비율 이미지 경로 반환 (BMS WIDE_MAIN_ITEM_LIST) + */ +export const getTestImagePath1to1 = (dirname: string): string => { + return path.resolve(dirname, '../../../test/assets/example-1to1.jpg'); +}; diff --git a/test/models/base/kakao/bms/bmsButton.test.ts b/test/models/base/kakao/bms/bmsButton.test.ts new file mode 100644 index 00000000..bbafa395 --- /dev/null +++ b/test/models/base/kakao/bms/bmsButton.test.ts @@ -0,0 +1,205 @@ +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 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)(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 any link', () => { + const invalidButton = { + name: '앱버튼', + linkType: 'AL', + }; + + 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/bmsCommerce.test.ts b/test/models/base/kakao/bms/bmsCommerce.test.ts new file mode 100644 index 00000000..c01476e4 --- /dev/null +++ b/test/models/base/kakao/bms/bmsCommerce.test.ts @@ -0,0 +1,255 @@ +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 discountRate combination', () => { + const valid = { + title: '상품명', + regularPrice: 10000, + discountPrice: '8000', + discountRate: 20, + }; + 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); + } + }); + + it('should accept all string values for discountFixed combination', () => { + const valid = { + title: '상품명', + regularPrice: '15000', + discountPrice: '12000', + 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.discountFixed).toBe(3000); + expect(typeof result.right.regularPrice).toBe('number'); + expect(typeof result.right.discountPrice).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 with discountRate)', () => { + const valid = { + title: '상품명2', + regularPrice: '10000', + discountPrice: '5000', + discountRate: '50', + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + if (result._tag === 'Right') { + expect(result.right.regularPrice).toBe(10000); + expect(result.right.discountPrice).toBe(5000); + expect(result.right.discountRate).toBe(50); + } + }); + + it('should handle COMMERCE style input with discountFixed', () => { + const valid = { + title: '상품명', + regularPrice: 1000, + discountPrice: '800', + 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.discountFixed).toBe(200); + } + }); + }); + + describe('가격 조합 검증', () => { + it('should accept regularPrice only (정가만 표기)', () => { + const valid = { + title: '상품명', + regularPrice: 10000, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + }); + + it('should accept regularPrice + discountPrice + discountRate (할인율 표기)', () => { + const valid = { + title: '상품명', + regularPrice: 10000, + discountPrice: 8000, + discountRate: 20, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + }); + + it('should accept regularPrice + discountPrice + discountFixed (정액 할인 표기)', () => { + const valid = { + title: '상품명', + regularPrice: 10000, + discountPrice: 8000, + discountFixed: 2000, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(valid); + expect(result._tag).toBe('Right'); + }); + + it('should reject discountRate and discountFixed together', () => { + const invalid = { + title: '상품명', + regularPrice: 10000, + discountPrice: 8000, + discountRate: 20, + discountFixed: 2000, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid); + expect(result._tag).toBe('Left'); + }); + + it('should reject discountRate without discountPrice', () => { + const invalid = { + title: '상품명', + regularPrice: 10000, + discountRate: 20, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid); + expect(result._tag).toBe('Left'); + }); + + it('should reject discountFixed without discountPrice', () => { + const invalid = { + title: '상품명', + regularPrice: 10000, + discountFixed: 2000, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid); + expect(result._tag).toBe('Left'); + }); + + it('should reject discountPrice without discountRate or discountFixed', () => { + const invalid = { + title: '상품명', + regularPrice: 10000, + discountPrice: 8000, + }; + const result = Schema.decodeUnknownEither(bmsCommerceSchema)(invalid); + expect(result._tag).toBe('Left'); + }); + }); +}); diff --git a/test/models/base/kakao/bms/bmsCoupon.test.ts b/test/models/base/kakao/bms/bmsCoupon.test.ts new file mode 100644 index 00000000..737a17af --- /dev/null +++ b/test/models/base/kakao/bms/bmsCoupon.test.ts @@ -0,0 +1,95 @@ +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 = [ + '1000원 할인 쿠폰', + '99999999원 할인 쿠폰', + '50% 할인 쿠폰', + '100% 할인 쿠폰', + '배송비 할인 쿠폰', + '신규가입 무료 쿠폰', + '포인트 UP 쿠폰', + '신규 가입 무료 쿠폰', // 공백 포함 7자 + ]; + + it.each(validTitles)('should accept valid title: %s', title => { + const result = Schema.decodeUnknownEither(bmsCouponTitleSchema)(title); + expect(result._tag).toBe('Right'); + }); + + 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'); + }); + }); + + describe('bmsCouponSchema', () => { + it('should accept valid coupon with required fields', () => { + const validCoupon = { + title: '10000원 할인 쿠폰', + description: '10% 할인', + }; + + const result = Schema.decodeUnknownEither(bmsCouponSchema)(validCoupon); + expect(result._tag).toBe('Right'); + }); + + it('should accept coupon with all optional fields', () => { + const validCoupon = { + title: '50% 할인 쿠폰', + 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: '10000원 할인 쿠폰', + }; + + 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 00000000..03aa8490 --- /dev/null +++ b/test/models/base/kakao/bms/bmsOption.test.ts @@ -0,0 +1,659 @@ +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 with 3 sub items (minimum)', () => { + const validBmsWideItemList = { + pfId: 'test-pf-id', + bms: { + targeting: 'M', + chatBubbleType: 'WIDE_ITEM_LIST', + header: '헤더 제목', + mainWideItem: { + title: '메인 아이템', + imageId: 'img-main', + linkMobile: 'https://example.com/main', + }, + subWideItemList: [ + { + title: '서브 아이템 1', + imageId: 'img-sub-1', + linkMobile: 'https://example.com/sub1', + }, + { + title: '서브 아이템 2', + imageId: 'img-sub-2', + linkMobile: 'https://example.com/sub2', + }, + { + title: '서브 아이템 3', + imageId: 'img-sub-3', + linkMobile: 'https://example.com/sub3', + }, + ], + }, + }; + + 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', + header: '헤더 제목', + subWideItemList: [ + { + title: '서브 아이템 1', + imageId: 'img-sub-1', + linkMobile: 'https://example.com/sub1', + }, + { + title: '서브 아이템 2', + imageId: 'img-sub-2', + linkMobile: 'https://example.com/sub2', + }, + { + title: '서브 아이템 3', + imageId: 'img-sub-3', + linkMobile: 'https://example.com/sub3', + }, + ], + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(invalidBmsWideItemList); + }).toThrow('BMS WIDE_ITEM_LIST 타입에 필수 필드가 누락되었습니다'); + }); + + it('should reject BMS_WIDE_ITEM_LIST with less than 3 sub items', () => { + const invalidBmsWideItemList = { + pfId: 'test-pf-id', + bms: { + targeting: 'M', + chatBubbleType: 'WIDE_ITEM_LIST', + header: '헤더 제목', + mainWideItem: { + title: '메인 아이템', + imageId: 'img-main', + linkMobile: 'https://example.com/main', + }, + subWideItemList: [ + { + title: '서브 아이템 1', + imageId: 'img-sub-1', + linkMobile: 'https://example.com/sub1', + }, + { + title: '서브 아이템 2', + imageId: 'img-sub-2', + linkMobile: 'https://example.com/sub2', + }, + ], + }, + }; + + expect(() => { + Schema.decodeUnknownSync(baseKakaoOptionSchema)(invalidBmsWideItemList); + }).toThrow( + 'WIDE_ITEM_LIST 타입의 subWideItemList는 최소 3개 이상이어야 합니다', + ); + }); + + it('should accept valid BMS_COMMERCE message', () => { + const validBmsCommerce = { + pfId: 'test-pf-id', + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + imageId: 'img-commerce', + commerce: { + title: '상품명', + regularPrice: 10000, + discountPrice: 8000, + discountRate: 20, + }, + 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: { + videoUrl: 'https://tv.kakao.com/v/123456789', + imageId: '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, + header: '헤더 제목', + mainWideItem: { + title: '메인', + imageId: 'img-main', + linkMobile: 'https://example.com/main', + }, + subWideItemList: [ + { + title: '서브 1', + imageId: 'img-sub-1', + linkMobile: 'https://example.com/sub1', + }, + { + title: '서브 2', + imageId: 'img-sub-2', + linkMobile: 'https://example.com/sub2', + }, + { + title: '서브 3', + imageId: 'img-sub-3', + linkMobile: 'https://example.com/sub3', + }, + ], + }; + break; + case 'COMMERCE': + bms = { + ...bms, + imageId: 'img-commerce', + 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: { + videoUrl: 'https://tv.kakao.com/v/123456789', + imageId: '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: '10000원 할인 쿠폰', + 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'); + }); + }); +}); diff --git a/test/services/messages/bms-free.e2e.test.ts b/test/services/messages/bms-free.e2e.test.ts new file mode 100644 index 00000000..a017e814 --- /dev/null +++ b/test/services/messages/bms-free.e2e.test.ts @@ -0,0 +1,1206 @@ +/** + * BMS Free Message E2E 테스트 + * + * ## 환경변수 설정 + * 실제 테스트 실행을 위해서는 다음 환경 변수가 필요합니다: + * - API_KEY: SOLAPI API 키 + * - API_SECRET: SOLAPI API 시크릿 + * - SENDER_NUMBER: SOLAPI에 등록된 발신번호 (fallback: 01000000000) + * + * ## 테스트 특징 + * - 8가지 BMS Free 타입 (TEXT, IMAGE, WIDE, WIDE_ITEM_LIST, COMMERCE, CAROUSEL_FEED, CAROUSEL_COMMERCE, PREMIUM_VIDEO) + * - 카카오 채널이 없으면 테스트 자동 스킵 + * - targeting은 'I' 타입만 사용 (M/N은 인허가 채널 필요) + * + * ## 테스트 실행 + * ```bash + * pnpm vitest run test/services/messages/bms-free.e2e.test.ts + * pnpm test -- -t "TEXT 타입" + * ``` + */ +import {describe, expect, it} from '@effect/vitest'; +import { + createBmsButton, + createBmsCommerce, + createBmsCoupon, + createBmsLinkButton, + createBmsOption, + createCarouselCommerceItem, + createCarouselFeedItem, + createMainWideItem, + createSubWideItem, + getTestImagePath, + getTestImagePath1to1, + getTestImagePath2to1, + uploadBmsImage, + uploadBmsImageForType, +} from '@test/lib/bms-test-utils'; +import { + KakaoChannelServiceTag, + MessageServiceTag, + MessageTestServicesLive, + StorageServiceTag, +} from '@test/lib/test-layers'; +import {Config, Console, Effect} from 'effect'; + +describe('BMS Free Message E2E', () => { + const testPhoneNumber = '01000000000'; + + describe('TEXT 타입', () => { + it.effect('최소 구조 (text만)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS TEXT 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS TEXT 최소 구조 테스트 메시지입니다.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('TEXT'), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect('전체 필드 (adult, content, buttons, coupon)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS TEXT 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS TEXT 전체 필드 테스트 메시지입니다.', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('TEXT', { + adult: false, + buttons: [ + createBmsButton('WL'), + createBmsButton('AL'), + createBmsButton('AC'), + createBmsButton('BK'), + ], + coupon: createBmsCoupon('percent'), + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe('IMAGE 타입', () => { + it.effect('최소 구조 (text, imageId)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS IMAGE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.bms(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS IMAGE 최소 구조 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('IMAGE', { + imageId, + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect( + '전체 필드 (adult, content, imageId, imageLink, buttons, coupon)', + () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS IMAGE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.bms(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS IMAGE 전체 필드 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('IMAGE', { + adult: false, + imageId, + imageLink: 'https://example.com/image', + buttons: [ + createBmsButton('WL'), + createBmsButton('AL'), + createBmsButton('AC'), + createBmsButton('BK'), + ], + coupon: createBmsCoupon('won'), + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe('WIDE 타입', () => { + it.effect('최소 구조 (text, imageId)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS WIDE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.wide(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS WIDE 최소 구조 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('WIDE', { + imageId, + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect('전체 필드 (adult, content, imageId, buttons, coupon)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS WIDE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.wide(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS WIDE 전체 필드 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('WIDE', { + adult: false, + imageId, + buttons: [createBmsButton('WL'), createBmsButton('AL')], + coupon: createBmsCoupon('shipping'), + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe('WIDE_ITEM_LIST 타입', () => { + it.effect('최소 구조 (header, mainWideItem, subWideItemList 3개)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS WIDE_ITEM_LIST 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const mainImageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.wideMainItem( + storageService, + getTestImagePath2to1(__dirname), + ), + ); + const subImageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.wideSubItem( + storageService, + getTestImagePath1to1(__dirname), + ), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('WIDE_ITEM_LIST', { + header: '헤더 제목', + mainWideItem: createMainWideItem(mainImageId), + subWideItemList: [ + createSubWideItem(subImageId, '서브 아이템 1'), + createSubWideItem(subImageId, '서브 아이템 2'), + createSubWideItem(subImageId, '서브 아이템 3'), + ], + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect( + '전체 필드 (adult, header, mainWideItem, subWideItemList, buttons, coupon)', + () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS WIDE_ITEM_LIST 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const mainImageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.wideMainItem( + storageService, + getTestImagePath2to1(__dirname), + ), + ); + const subImageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.wideSubItem( + storageService, + getTestImagePath1to1(__dirname), + ), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('WIDE_ITEM_LIST', { + adult: false, + header: '헤더 제목', + mainWideItem: createMainWideItem(mainImageId), + subWideItemList: [ + createSubWideItem(subImageId, '서브 아이템 1'), + createSubWideItem(subImageId, '서브 아이템 2'), + createSubWideItem(subImageId, '서브 아이템 3'), + ], + buttons: [createBmsButton('WL'), createBmsButton('AL')], + coupon: createBmsCoupon('free'), + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe('COMMERCE 타입', () => { + it.effect('최소 구조 (imageId, commerce title만, buttons 1개)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS COMMERCE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath2to1(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.bms(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + imageId, + commerce: createBmsCommerce(), + buttons: [createBmsButton('WL')], + }, + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect( + '전체 필드 (adult, additionalContent, imageId, commerce 전체, buttons, coupon)', + () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS COMMERCE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath2to1(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.bms(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: { + targeting: 'I', + chatBubbleType: 'COMMERCE', + adult: false, + additionalContent: '추가 내용입니다.', + imageId, + commerce: createBmsCommerce({ + title: '프리미엄 상품', + regularPrice: 50000, + discountPrice: 35000, + discountRate: 30, + }), + buttons: [createBmsButton('WL'), createBmsButton('AL')], + coupon: createBmsCoupon('up'), + }, + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe('CAROUSEL_FEED 타입', () => { + it.effect('최소 구조 (carousel.list 2개)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS CAROUSEL_FEED 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath2to1(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.carouselFeed(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_FEED', + carousel: { + list: [ + createCarouselFeedItem(imageId, {header: '캐러셀 1'}), + createCarouselFeedItem(imageId, {header: '캐러셀 2'}), + ], + }, + }, + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect('전체 필드 (adult, carousel head/list 전체/tail)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS CAROUSEL_FEED 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath2to1(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.carouselFeed(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_FEED', + adult: false, + carousel: { + list: [ + createCarouselFeedItem(imageId, { + header: '첫 번째 카드', + content: '첫 번째 카드 내용입니다.', + imageLink: 'https://example.com/1', + buttons: [ + createBmsLinkButton('WL'), + createBmsLinkButton('AL'), + ], + coupon: createBmsCoupon('percent'), + }), + createCarouselFeedItem(imageId, { + header: '두 번째 카드', + content: '두 번째 카드 내용입니다.', + buttons: [createBmsLinkButton('WL')], + }), + createCarouselFeedItem(imageId, { + header: '세 번째 카드', + content: '세 번째 카드 내용입니다.', + buttons: [createBmsLinkButton('AL')], + }), + ], + tail: { + linkMobile: 'https://example.com/more', + linkPc: 'https://example.com/more', + }, + }, + }, + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe('CAROUSEL_COMMERCE 타입', () => { + it.effect('최소 구조 (carousel.list 2개)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS CAROUSEL_COMMERCE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath2to1(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.carouselCommerce(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: { + targeting: 'I', + chatBubbleType: 'CAROUSEL_COMMERCE', + carousel: { + list: [ + createCarouselCommerceItem(imageId, { + commerce: createBmsCommerce({ + title: '상품 1', + regularPrice: 10000, + discountPrice: 9000, + discountRate: 10, + }), + }), + createCarouselCommerceItem(imageId, { + commerce: createBmsCommerce({ + title: '상품 2', + regularPrice: 10000, + discountPrice: 8000, + discountFixed: 2000, + }), + }), + ], + }, + }, + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect( + '전체 필드 (adult, additionalContent, carousel head/list 전체/tail)', + () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS CAROUSEL_COMMERCE 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath2to1(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.carouselCommerce(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('CAROUSEL_COMMERCE', { + adult: false, + additionalContent: '추가 안내', + carousel: { + head: { + header: '캐러셀 인트로', + content: '인트로 내용입니다.', + imageId, + linkMobile: 'https://example.com/head', + }, + list: [ + createCarouselCommerceItem(imageId, { + commerce: createBmsCommerce({ + title: '프리미엄 상품 1', + regularPrice: 30000, + discountPrice: 25000, + discountRate: 17, + }), + additionalContent: '추가 정보', + imageLink: 'https://example.com/product1', + buttons: [ + createBmsLinkButton('WL'), + createBmsLinkButton('AL'), + ], + coupon: { + ...createBmsCoupon('won'), + }, + }), + createCarouselCommerceItem(imageId, { + commerce: createBmsCommerce({ + title: '프리미엄 상품 2', + regularPrice: 40000, + discountPrice: 35000, + discountFixed: 5000, + }), + buttons: [createBmsLinkButton('WL')], + }), + ], + tail: { + linkMobile: 'https://example.com/all-products', + }, + }, + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe('PREMIUM_VIDEO 타입', () => { + it.effect('최소 구조 (video.videoUrl)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS PREMIUM_VIDEO 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS PREMIUM_VIDEO 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('PREMIUM_VIDEO', { + video: { + // NOTE: 발송 간 유효하지 않은 동영상 URL을 기입하면 발송 상태가 그룹 정보를 찾을 수 없음 오류로 표시됩니다. + videoUrl: 'https://tv.kakao.com/v/460734285', + }, + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect( + '전체 필드 (adult, header, content, video 전체, buttons, coupon)', + () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 BMS PREMIUM_VIDEO 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImage(storageService, imagePath), + ); + + const result = yield* Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS PREMIUM_VIDEO 전체 필드 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('PREMIUM_VIDEO', { + adult: false, + header: '비디오 헤더', + content: '비디오 내용입니다.', + video: { + // NOTE: 발송 간 유효하지 않은 동영상 URL을 기입하면 발송 상태가 그룹 정보를 찾을 수 없음 오류로 표시됩니다. + videoUrl: 'https://tv.kakao.com/v/460734285', + imageId, + imageLink: 'https://example.com/video', + }, + buttons: [createBmsButton('WL')], + coupon: createBmsCoupon('percent'), + }), + }, + }), + ); + + expect(result).toBeDefined(); + expect(result.groupInfo).toBeDefined(); + expect(result.groupInfo.count.total).toBeGreaterThan(0); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); + + describe('Error Cases', () => { + it.effect('IMAGE without imageId (필수 필드 누락)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log('카카오 채널이 없어서 에러 테스트를 건너뜁니다.'); + return; + } + + const channel = channelsResponse.channelList[0]; + + const result = yield* Effect.either( + Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS IMAGE 에러 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('IMAGE'), + }, + }), + ), + ); + + expect(result._tag).toBe('Left'); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect('COMMERCE without buttons (필수 필드 누락)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const storageService = yield* StorageServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log('카카오 채널이 없어서 에러 테스트를 건너뜁니다.'); + return; + } + + const channel = channelsResponse.channelList[0]; + + const imagePath = getTestImagePath(__dirname); + const imageId = yield* Effect.tryPromise(() => + uploadBmsImageForType.bms(storageService, imagePath), + ); + + const result = yield* Effect.either( + Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS COMMERCE 에러 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('COMMERCE', { + imageId, + commerce: createBmsCommerce(), + // buttons 누락 + }), + }, + }), + ), + ); + + expect(result._tag).toBe('Left'); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect( + 'PREMIUM_VIDEO with invalid videoUrl (tv.kakao.com 아닌 URL)', + () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log( + '카카오 채널이 없어서 에러 테스트를 건너뜁니다.', + ); + return; + } + + const channel = channelsResponse.channelList[0]; + + const result = yield* Effect.either( + Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS PREMIUM_VIDEO 에러 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('PREMIUM_VIDEO', { + video: { + videoUrl: 'https://youtube.com/watch?v=invalid', // 잘못된 URL + }, + }), + }, + }), + ), + ); + + expect(result._tag).toBe('Left'); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect('Invalid coupon title (쿠폰 제목 형식 오류)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log('카카오 채널이 없어서 에러 테스트를 건너뜁니다.'); + return; + } + + const channel = channelsResponse.channelList[0]; + + const result = yield* Effect.either( + Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS TEXT 쿠폰 에러 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('TEXT', { + coupon: { + title: '잘못된 쿠폰 제목', // 허용되지 않는 형식 + description: '테스트', + }, + }), + }, + }), + ), + ); + + expect(result._tag).toBe('Left'); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + + it.effect('CAROUSEL_FEED without carousel (필수 필드 누락)', () => + Effect.gen(function* () { + const messageService = yield* MessageServiceTag; + const kakaoChannelService = yield* KakaoChannelServiceTag; + const senderNumber = yield* Config.string('SENDER_NUMBER').pipe( + Config.withDefault('01000000000'), + ); + + const channelsResponse = yield* Effect.tryPromise(() => + kakaoChannelService.getKakaoChannels({limit: 1}), + ); + + if (channelsResponse.channelList.length === 0) { + yield* Console.log('카카오 채널이 없어서 에러 테스트를 건너뜁니다.'); + return; + } + + const channel = channelsResponse.channelList[0]; + + const result = yield* Effect.either( + Effect.tryPromise(() => + messageService.send({ + to: testPhoneNumber, + from: senderNumber, + text: 'BMS CAROUSEL_FEED 에러 테스트', + type: 'BMS_FREE', + kakaoOptions: { + pfId: channel.channelId, + bms: createBmsOption('CAROUSEL_FEED'), + }, + }), + ), + ); + + expect(result._tag).toBe('Left'); + }).pipe(Effect.provide(MessageTestServicesLive)), + ); + }); +});