UNPKG

@k-msg/provider

Version:

Complete provider system with adapters and implementations for K-Message platform

1 lines 122 kB
{"version":3,"sources":["../src/index.ts","../src/registry/plugin-registry.ts","../src/middleware/index.ts","../src/utils/base-plugin.ts","../src/utils/index.ts","../src/abstract/provider.base.ts","../src/adapters/request.adapter.ts","../src/adapters/response.adapter.ts","../src/services/provider.manager.ts","../src/iwinv/contracts/messaging.contract.ts","../src/iwinv/contracts/template.contract.ts","../src/iwinv/contracts/channel.contract.ts","../src/iwinv/contracts/analytics.contract.ts","../src/iwinv/contracts/account.contract.ts","../src/iwinv/provider.ts"],"sourcesContent":["/**\n * @k-msg/provider\n * Complete provider system with adapters and implementations\n */\n\n// =============================================================================\n// CORE PROVIDER SYSTEM\n// =============================================================================\n\n// Base types and interfaces - avoid conflicts with explicit exports\nexport type {\n MessageChannel,\n MessageType,\n SendOptions,\n MediaAttachment,\n HealthCheckResult,\n SendResult,\n TemplateResult,\n BaseProvider,\n MessageContent,\n MessageButton,\n TemplateCreateRequest,\n TemplateUpdateRequest,\n SenderNumber,\n SenderVerificationResult,\n ChannelInfo,\n BaseProviderConfig,\n TemplateFilters,\n HistoryFilters\n} from './types/base';\n\n// Interfaces\nexport type {\n NotificationRequest,\n NotificationResponse\n} from './interfaces';\n\n// Plugin interfaces\nexport * from './interfaces/plugin';\n\n// Provider registry and plugin system\nexport * from './registry';\nexport * from './middleware';\nexport * from './utils';\n\n// =============================================================================\n// PROVIDER CONTRACTS AND ADAPTERS\n// =============================================================================\n\n// Provider contracts (from provider-adapter)\nexport type {\n ProviderCapabilities,\n ProviderConfiguration,\n ConfigurationField,\n MessagingContract,\n TemplateContract,\n ChannelContract,\n AnalyticsContract,\n AccountContract,\n ScheduleResult\n} from './contracts/provider.contract';\n\n// Abstract base provider (from provider-adapter)\nexport { BaseAlimTalkProvider } from './abstract/provider.base';\n\n// Request/Response adapters (from provider-adapter)\nexport {\n BaseRequestAdapter,\n IWINVRequestAdapter,\n AligoRequestAdapter,\n KakaoRequestAdapter,\n RequestAdapterFactory\n} from './adapters/request.adapter';\n\nexport {\n BaseResponseAdapter,\n IWINVResponseAdapter,\n AligoResponseAdapter,\n KakaoResponseAdapter,\n NHNResponseAdapter,\n ResponseAdapterFactory\n} from './adapters/response.adapter';\n\n// Provider manager service (from provider-adapter)\nexport * from './services/provider.manager';\n\n// =============================================================================\n// PROVIDER IMPLEMENTATIONS\n// =============================================================================\n\n// IWINV Provider\nexport { IWINVProvider } from './iwinv/provider';\nexport type * from './iwinv/types/iwinv';","import type { \n ProviderPlugin, \n ProviderConfig, \n PluginContext, \n Logger,\n MetricsCollector,\n PluginStorage \n} from '../interfaces';\nimport { EventEmitter } from 'events';\n\nexport class PluginRegistry {\n private plugins = new Map<string, ProviderPlugin>();\n private instances = new Map<string, ProviderPlugin>();\n\n register(plugin: ProviderPlugin): void {\n const id = plugin.metadata.name.toLowerCase();\n \n if (this.plugins.has(id)) {\n throw new Error(`Plugin ${id} is already registered`);\n }\n \n this.plugins.set(id, plugin);\n }\n\n async create(\n pluginId: string, \n config: ProviderConfig,\n options: {\n logger?: Logger;\n metrics?: MetricsCollector;\n storage?: PluginStorage;\n } = {}\n ): Promise<ProviderPlugin> {\n const plugin = this.plugins.get(pluginId.toLowerCase());\n \n if (!plugin) {\n throw new Error(`Plugin ${pluginId} not found`);\n }\n\n // 새 인스턴스 생성\n const PluginClass = plugin.constructor as new() => ProviderPlugin;\n const instance = new PluginClass();\n\n // 컨텍스트 생성\n const context: PluginContext = {\n config,\n logger: options.logger || new ConsoleLogger(),\n metrics: options.metrics || new NoOpMetricsCollector(),\n storage: options.storage || new MemoryStorage(),\n eventBus: new EventEmitter(),\n };\n\n // 초기화\n await instance.initialize(context);\n\n const instanceKey = `${pluginId}-${Date.now()}`;\n this.instances.set(instanceKey, instance);\n\n return instance;\n }\n\n async loadAndCreate(\n pluginId: string,\n config: ProviderConfig,\n options?: any\n ): Promise<ProviderPlugin> {\n // 동적 로딩 지원 (나중에 구현)\n return this.create(pluginId, config, options);\n }\n\n getSupportedTypes(): string[] {\n return Array.from(this.plugins.keys());\n }\n\n validateProviderConfig(type: string, config: ProviderConfig): boolean {\n const plugin = this.plugins.get(type.toLowerCase());\n if (!plugin) return false;\n\n // 기본 검증 로직\n return !!(config.apiUrl && config.apiKey);\n }\n\n async destroyAll(): Promise<void> {\n const destroyPromises = Array.from(this.instances.values()).map(\n instance => instance.destroy()\n );\n \n await Promise.all(destroyPromises);\n this.instances.clear();\n }\n}\n\n// 기본 구현체들\nclass ConsoleLogger implements Logger {\n info(message: string, ...args: any[]): void {\n console.log(`[INFO] ${message}`, ...args);\n }\n \n error(message: string, error?: any): void {\n console.error(`[ERROR] ${message}`, error);\n }\n \n debug(message: string, ...args: any[]): void {\n console.debug(`[DEBUG] ${message}`, ...args);\n }\n \n warn(message: string, ...args: any[]): void {\n console.warn(`[WARN] ${message}`, ...args);\n }\n}\n\nclass NoOpMetricsCollector implements MetricsCollector {\n increment(_metric: string, _labels?: Record<string, string>): void {}\n histogram(_metric: string, _value: number, _labels?: Record<string, string>): void {}\n gauge(_metric: string, _value: number, _labels?: Record<string, string>): void {}\n}\n\nclass MemoryStorage implements PluginStorage {\n private store = new Map<string, { value: any; expiry?: number }>();\n\n async get(key: string): Promise<any> {\n const item = this.store.get(key);\n \n if (!item) return undefined;\n \n if (item.expiry && Date.now() > item.expiry) {\n this.store.delete(key);\n return undefined;\n }\n \n return item.value;\n }\n\n async set(key: string, value: any, ttl?: number): Promise<void> {\n const expiry = ttl ? Date.now() + (ttl * 1000) : undefined;\n this.store.set(key, { value, expiry });\n }\n\n async delete(key: string): Promise<void> {\n this.store.delete(key);\n }\n}","import type { ProviderMiddleware, MiddlewareContext } from '../interfaces';\n\nexport function createRetryMiddleware(options: {\n maxRetries: number;\n retryDelay: number;\n retryableErrors?: string[];\n retryableStatusCodes?: number[];\n}): ProviderMiddleware {\n return {\n name: 'retry',\n error: async (error: any, context: MiddlewareContext) => {\n const retries = context.metadata.retries || 0;\n \n if (retries >= options.maxRetries) {\n throw error;\n }\n \n // 재시도 가능한 에러인지 확인\n const isRetryable = \n options.retryableErrors?.includes(error.code) ||\n options.retryableStatusCodes?.includes(error.status) ||\n error.code === 'ETIMEDOUT' ||\n error.code === 'ECONNRESET';\n \n if (!isRetryable) {\n throw error;\n }\n \n // 지연 후 재시도\n await new Promise(resolve => \n setTimeout(resolve, options.retryDelay * (retries + 1))\n );\n \n context.metadata.retries = retries + 1;\n // 실제 재시도 로직은 호출하는 쪽에서 처리\n throw { ...error, shouldRetry: true };\n }\n };\n}\n\nexport function createRateLimitMiddleware(options: {\n messagesPerSecond?: number;\n messagesPerMinute?: number;\n messagesPerHour?: number;\n messagesPerDay?: number;\n strategy?: 'sliding-window' | 'fixed-window';\n}): ProviderMiddleware {\n const windows = new Map<string, number[]>();\n \n return {\n name: 'rate-limit',\n pre: async (context: MiddlewareContext) => {\n const now = Date.now();\n const key = 'global'; // 프로바이더별로 구분 가능\n \n if (!windows.has(key)) {\n windows.set(key, []);\n }\n \n const timestamps = windows.get(key)!;\n \n // 초당 제한 확인\n if (options.messagesPerSecond) {\n const recentCount = timestamps.filter(t => now - t < 1000).length;\n if (recentCount >= options.messagesPerSecond) {\n throw new Error('Rate limit exceeded: messages per second');\n }\n }\n \n // 분당 제한 확인\n if (options.messagesPerMinute) {\n const recentCount = timestamps.filter(t => now - t < 60000).length;\n if (recentCount >= options.messagesPerMinute) {\n throw new Error('Rate limit exceeded: messages per minute');\n }\n }\n \n // 타임스탬프 추가\n timestamps.push(now);\n \n // 오래된 타임스탬프 정리 (1시간 이상)\n const cutoff = now - 3600000;\n const filtered = timestamps.filter(t => t > cutoff);\n windows.set(key, filtered);\n }\n };\n}\n\nexport function createLoggingMiddleware(options: {\n logger: any;\n logLevel?: string;\n}): ProviderMiddleware {\n return {\n name: 'logging',\n pre: async (context: MiddlewareContext) => {\n if (options.logLevel === 'debug') {\n options.logger.debug('Request started', {\n metadata: context.metadata,\n timestamp: context.startTime\n });\n }\n },\n post: async (context: MiddlewareContext) => {\n const duration = Date.now() - context.startTime;\n options.logger.info('Request completed', {\n duration,\n success: true\n });\n },\n error: async (error: Error, context: MiddlewareContext) => {\n const duration = Date.now() - context.startTime;\n options.logger.error('Request failed', {\n error: error.message,\n duration,\n stack: error.stack\n });\n }\n };\n}\n\nexport function createMetricsMiddleware(options: {\n collector: any;\n labels?: Record<string, string>;\n}): ProviderMiddleware {\n return {\n name: 'metrics',\n pre: async (context: MiddlewareContext) => {\n options.collector.increment('requests_total', options.labels);\n },\n post: async (context: MiddlewareContext) => {\n const duration = Date.now() - context.startTime;\n options.collector.histogram('request_duration_ms', duration, options.labels);\n options.collector.increment('requests_success_total', options.labels);\n },\n error: async (error: Error, context: MiddlewareContext) => {\n const duration = Date.now() - context.startTime;\n options.collector.histogram('request_duration_ms', duration, options.labels);\n options.collector.increment('requests_error_total', {\n ...options.labels,\n error_type: error.constructor.name\n });\n }\n };\n}\n\nexport function createCircuitBreakerMiddleware(options: {\n threshold: number;\n timeout: number;\n resetTimeout: number;\n}): ProviderMiddleware {\n let state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';\n let failures = 0;\n let nextAttempt = 0;\n \n return {\n name: 'circuit-breaker',\n pre: async (context: MiddlewareContext) => {\n const now = Date.now();\n \n if (state === 'OPEN') {\n if (now < nextAttempt) {\n throw new Error('Circuit breaker is OPEN');\n }\n state = 'HALF_OPEN';\n }\n },\n post: async (context: MiddlewareContext) => {\n if (state === 'HALF_OPEN') {\n state = 'CLOSED';\n failures = 0;\n }\n },\n error: async (error: Error, context: MiddlewareContext) => {\n failures++;\n \n if (failures >= options.threshold) {\n state = 'OPEN';\n nextAttempt = Date.now() + options.resetTimeout;\n }\n \n throw error;\n }\n };\n}","import type {\n ProviderPlugin,\n ProviderMetadata,\n ProviderCapabilities,\n PluginContext,\n ProviderMiddleware,\n ProviderImplementation\n} from '../interfaces';\n// Response adapters are in provider-interface package\n// import { BaseRequestAdapter, BaseResponseAdapter } from '@k-msg/provider-interface';\n\nexport abstract class BasePlugin implements ProviderPlugin {\n abstract readonly metadata: ProviderMetadata;\n abstract readonly capabilities: ProviderCapabilities;\n\n protected context!: PluginContext;\n public middleware: ProviderMiddleware[] = [];\n\n async initialize(context: PluginContext): Promise<void> {\n this.context = context;\n this.context.logger.info(`Initializing plugin: ${this.metadata.name}`);\n }\n\n async destroy(): Promise<void> {\n this.context.logger.info(`Destroying plugin: ${this.metadata.name}`);\n // 서브클래스에서 오버라이드 가능\n }\n\n abstract getImplementation(): ProviderImplementation;\n\n protected async executeMiddleware(\n phase: 'pre' | 'post' | 'error',\n context: any,\n error?: Error\n ): Promise<void> {\n for (const middleware of this.middleware) {\n try {\n if (phase === 'pre' && middleware.pre) {\n await middleware.pre(context);\n } else if (phase === 'post' && middleware.post) {\n await middleware.post(context);\n } else if (phase === 'error' && middleware.error && error) {\n await middleware.error(error, context);\n }\n } catch (err) {\n this.context.logger.error(`Middleware ${middleware.name} failed`, err);\n throw err;\n }\n }\n }\n\n protected createMiddlewareContext(request: any, metadata: Record<string, any> = {}) {\n return {\n request,\n response: undefined as any,\n metadata: {\n ...metadata,\n pluginName: this.metadata.name,\n pluginVersion: this.metadata.version\n },\n startTime: Date.now()\n };\n }\n\n protected validateConfig(config: any, required: string[]): void {\n for (const field of required) {\n if (!config[field]) {\n throw new Error(`${this.metadata.name}: Missing required config field: ${field}`);\n }\n }\n }\n\n protected async makeRequest(\n url: string,\n options: RequestInit,\n metadata: Record<string, any> = {}\n ): Promise<Response> {\n const context = this.createMiddlewareContext({ url, options }, metadata);\n\n try {\n await this.executeMiddleware('pre', context);\n\n const response = await fetch(url, {\n ...options,\n headers: {\n 'User-Agent': `K-OTP-${this.metadata.name}/${this.metadata.version}`,\n ...this.context.config.headers,\n ...options.headers,\n },\n signal: AbortSignal.timeout(this.context.config.timeout || 30000)\n });\n\n context.response = response;\n await this.executeMiddleware('post', context);\n\n return response;\n } catch (error) {\n await this.executeMiddleware('error', context, error as Error);\n throw error;\n }\n }\n\n /**\n * Make HTTP request and parse JSON response\n * Subclasses should use their specific response adapters to transform the result\n */\n protected async makeJSONRequest<T = any>(\n url: string,\n options: RequestInit,\n metadata: Record<string, any> = {}\n ): Promise<T> {\n const response = await this.makeRequest(url, options, metadata);\n\n if (!response.ok) {\n const error = new Error(`HTTP ${response.status}: ${response.statusText}`);\n (error as any).response = response;\n (error as any).status = response.status;\n throw error;\n }\n\n try {\n return await response.json() as T;\n } catch (parseError) {\n const error = new Error('Failed to parse JSON response');\n (error as any).response = response;\n (error as any).parseError = parseError;\n throw error;\n }\n }\n\n /**\n * Helper method for logging provider-specific operations\n */\n protected logOperation(operation: string, data?: any): void {\n this.context.logger.info(`${this.metadata.name}: ${operation}`, data);\n }\n\n /**\n * Helper method for logging provider-specific errors\n */\n protected logError(operation: string, error: any, data?: any): void {\n this.context.logger.error(`${this.metadata.name}: ${operation} failed`, { error, data });\n }\n}","export * from './base-plugin';\n\nexport function normalizePhoneNumber(phone: string): string {\n // 한국 휴대폰 번호 정규화\n const cleaned = phone.replace(/[^\\d]/g, '');\n \n // 국가코드 제거\n if (cleaned.startsWith('82')) {\n return '0' + cleaned.substring(2);\n }\n \n // 이미 0으로 시작하면 그대로\n if (cleaned.startsWith('0')) {\n return cleaned;\n }\n \n // 10~11자리 숫자면 앞에 0 추가\n if (cleaned.length >= 10 && cleaned.length <= 11) {\n return '0' + cleaned;\n }\n \n return cleaned;\n}\n\nexport function validatePhoneNumber(phone: string): boolean {\n const normalized = normalizePhoneNumber(phone);\n return /^01[0-9]{8,9}$/.test(normalized);\n}\n\nexport function formatDateTime(date: Date): string {\n // yyyy-MM-dd HH:mm:ss 형식\n const year = date.getFullYear();\n const month = String(date.getMonth() + 1).padStart(2, '0');\n const day = String(date.getDate()).padStart(2, '0');\n const hours = String(date.getHours()).padStart(2, '0');\n const minutes = String(date.getMinutes()).padStart(2, '0');\n const seconds = String(date.getSeconds()).padStart(2, '0');\n \n return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;\n}\n\nexport function parseTemplate(template: string, variables: Record<string, string>): string {\n let result = template;\n \n // #{변수명} 형태의 변수 치환\n for (const [key, value] of Object.entries(variables)) {\n const regex = new RegExp(`#{${key}}`, 'g');\n result = result.replace(regex, value);\n }\n \n // {{변수명}} 형태의 변수도 지원\n for (const [key, value] of Object.entries(variables)) {\n const regex = new RegExp(`{{${key}}}`, 'g');\n result = result.replace(regex, value);\n }\n \n return result;\n}\n\nexport function extractVariables(template: string): string[] {\n const variables = new Set<string>();\n \n // #{변수명} 형태 추출\n const hashMatches = template.match(/#\\{([^}]+)\\}/g);\n if (hashMatches) {\n hashMatches.forEach(match => {\n const variable = match.slice(2, -1); // #{ 와 } 제거\n variables.add(variable);\n });\n }\n \n // {{변수명}} 형태 추출\n const braceMatches = template.match(/\\{\\{([^}]+)\\}\\}/g);\n if (braceMatches) {\n braceMatches.forEach(match => {\n const variable = match.slice(2, -2); // {{ 와 }} 제거\n variables.add(variable);\n });\n }\n \n return Array.from(variables);\n}\n\nexport function delay(ms: number): Promise<void> {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nexport function retry<T>(\n fn: () => Promise<T>,\n options: {\n maxRetries: number;\n delay: number;\n backoff?: 'linear' | 'exponential';\n }\n): Promise<T> {\n return new Promise(async (resolve, reject) => {\n let lastError: Error;\n \n for (let attempt = 0; attempt <= options.maxRetries; attempt++) {\n try {\n if (attempt > 0) {\n const delayMs = options.backoff === 'exponential' \n ? options.delay * Math.pow(2, attempt - 1)\n : options.delay * attempt;\n await delay(delayMs);\n }\n \n const result = await fn();\n resolve(result);\n return;\n } catch (error) {\n lastError = error as Error;\n \n if (attempt === options.maxRetries) {\n reject(lastError);\n return;\n }\n }\n }\n });\n}","import { \n AlimTalkProvider, \n ProviderCapabilities,\n TemplateContract,\n ChannelContract,\n MessagingContract,\n AnalyticsContract,\n AccountContract,\n ProviderConfiguration,\n ConfigurationField\n} from '../contracts/provider.contract';\n\nexport abstract class BaseAlimTalkProvider implements AlimTalkProvider {\n public abstract readonly id: string;\n public abstract readonly name: string;\n public abstract readonly capabilities: ProviderCapabilities;\n \n protected config: Record<string, unknown> = {};\n protected isConfigured: boolean = false;\n\n // Abstract contracts - must be implemented by concrete providers\n public abstract templates: TemplateContract;\n public abstract channels: ChannelContract;\n public abstract messaging: MessagingContract;\n public abstract analytics: AnalyticsContract;\n public abstract account: AccountContract;\n\n constructor(config?: Record<string, unknown>) {\n if (config) {\n this.configure(config);\n }\n }\n\n /**\n * Configure the provider with necessary credentials and settings\n */\n public configure(config: Record<string, unknown>): void {\n this.validateConfiguration(config);\n this.config = { ...config };\n this.isConfigured = true;\n this.onConfigured();\n }\n\n /**\n * Get the configuration schema for this provider\n */\n public abstract getConfigurationSchema(): ProviderConfiguration;\n\n /**\n * Validate the provided configuration\n */\n protected validateConfiguration(config: Record<string, unknown>): void {\n const schema = this.getConfigurationSchema();\n \n // Check required fields\n for (const field of schema.required) {\n if (!(field.key in config)) {\n throw new Error(`Required configuration field '${field.key}' is missing`);\n }\n \n this.validateFieldValue(field, config[field.key]);\n }\n\n // Check optional fields if provided\n for (const field of schema.optional) {\n if (field.key in config) {\n this.validateFieldValue(field, config[field.key]);\n }\n }\n }\n\n private validateFieldValue(field: ConfigurationField, value: unknown): void {\n // Type validation\n switch (field.type) {\n case 'string':\n if (typeof value !== 'string') {\n throw new Error(`Field '${field.key}' must be a string`);\n }\n break;\n case 'number':\n if (typeof value !== 'number') {\n throw new Error(`Field '${field.key}' must be a number`);\n }\n break;\n case 'boolean':\n if (typeof value !== 'boolean') {\n throw new Error(`Field '${field.key}' must be a boolean`);\n }\n break;\n case 'url':\n try {\n new URL(String(value));\n } catch {\n throw new Error(`Field '${field.key}' must be a valid URL`);\n }\n break;\n }\n\n // Additional validation\n if (field.validation) {\n if (field.validation.pattern) {\n const regex = new RegExp(field.validation.pattern);\n if (!regex.test(String(value))) {\n throw new Error(`Field '${field.key}' does not match required pattern`);\n }\n }\n \n if (field.validation.min !== undefined && Number(value) < field.validation.min) {\n throw new Error(`Field '${field.key}' must be at least ${field.validation.min}`);\n }\n \n if (field.validation.max !== undefined && Number(value) > field.validation.max) {\n throw new Error(`Field '${field.key}' must be at most ${field.validation.max}`);\n }\n }\n }\n\n /**\n * Called after configuration is set\n */\n protected onConfigured(): void {\n // Override in concrete implementations if needed\n }\n\n /**\n * Check if the provider is properly configured\n */\n public isReady(): boolean {\n return this.isConfigured;\n }\n\n /**\n * Get configuration value\n */\n protected getConfig<T = unknown>(key: string): T {\n if (!this.isConfigured) {\n throw new Error('Provider is not configured');\n }\n return this.config[key] as T;\n }\n\n /**\n * Check if a configuration key exists\n */\n protected hasConfig(key: string): boolean {\n return key in this.config;\n }\n\n /**\n * Perform health check on the provider\n */\n public async healthCheck(): Promise<{\n healthy: boolean;\n issues: string[];\n latency?: number;\n }> {\n const issues: string[] = [];\n const startTime = Date.now();\n\n try {\n if (!this.isReady()) {\n issues.push('Provider is not configured');\n return { healthy: false, issues };\n }\n\n // Test basic connectivity\n await this.testConnectivity();\n \n // Test authentication\n await this.testAuthentication();\n\n const latency = Date.now() - startTime;\n \n return {\n healthy: issues.length === 0,\n issues,\n latency\n };\n\n } catch (error) {\n issues.push(`Health check failed: ${error instanceof Error ? error.message : 'Unknown error'}`);\n return { healthy: false, issues };\n }\n }\n\n /**\n * Test basic connectivity to the provider\n */\n protected abstract testConnectivity(): Promise<void>;\n\n /**\n * Test authentication with the provider\n */\n protected abstract testAuthentication(): Promise<void>;\n\n /**\n * Get provider information\n */\n public getInfo(): {\n id: string;\n name: string;\n version: string;\n capabilities: ProviderCapabilities;\n configured: boolean;\n } {\n return {\n id: this.id,\n name: this.name,\n version: this.getVersion(),\n capabilities: this.capabilities,\n configured: this.isConfigured\n };\n }\n\n /**\n * Get provider version\n */\n protected abstract getVersion(): string;\n\n /**\n * Cleanup resources when provider is destroyed\n */\n public destroy(): void {\n this.config = {};\n this.isConfigured = false;\n this.onDestroy();\n }\n\n /**\n * Called when provider is being destroyed\n */\n protected onDestroy(): void {\n // Override in concrete implementations if needed\n }\n\n /**\n * Create standardized error\n */\n protected createError(code: string, message: string, details?: Record<string, unknown>): Error {\n const error = new Error(message) as Error & { code?: string; provider?: string; details?: Record<string, unknown> };\n error.code = code;\n error.provider = this.id;\n error.details = details;\n return error;\n }\n\n /**\n * Log provider activity\n */\n protected log(level: 'info' | 'warn' | 'error', message: string, data?: unknown): void {\n const logData: Record<string, unknown> = {\n provider: this.id,\n level,\n message,\n timestamp: new Date().toISOString()\n };\n\n if (data) {\n logData.data = data;\n }\n\n // In a real implementation, this would use a proper logging system\n console.log(JSON.stringify(logData));\n }\n\n /**\n * Handle rate limiting\n */\n protected async handleRateLimit(operation: string): Promise<void> {\n // In a real implementation, this would check rate limits and implement backoff\n const rateLimit = this.capabilities.messaging.maxRequestsPerSecond;\n \n // Simple implementation - can be enhanced with proper rate limiting\n if (rateLimit > 0) {\n const delay = 1000 / rateLimit;\n await new Promise(resolve => setTimeout(resolve, delay));\n }\n }\n\n /**\n * Retry mechanism for failed operations\n */\n protected async withRetry<T>(\n operation: () => Promise<T>,\n options: {\n maxRetries?: number;\n initialDelay?: number;\n maxDelay?: number;\n backoffFactor?: number;\n } = {}\n ): Promise<T> {\n const {\n maxRetries = 3,\n initialDelay = 1000,\n maxDelay = 10000,\n backoffFactor = 2\n } = options;\n\n let lastError: Error;\n let delay = initialDelay;\n\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\n try {\n return await operation();\n } catch (error) {\n lastError = error as Error;\n \n if (attempt === maxRetries) {\n break; // No more retries\n }\n\n this.log('warn', `Operation failed, retrying in ${delay}ms`, {\n attempt: attempt + 1,\n maxRetries,\n error: lastError.message\n });\n\n await new Promise(resolve => setTimeout(resolve, delay));\n delay = Math.min(delay * backoffFactor, maxDelay);\n }\n }\n\n throw lastError!;\n }\n}","import { ProviderMessageRequest, TemplateCreateRequest } from '../contracts/provider.contract';\n\n/**\n * Base request adapter for transforming platform requests to provider-specific format\n */\nexport abstract class BaseRequestAdapter {\n /**\n * Transform a generic message request to provider-specific format\n */\n abstract transformMessageRequest(request: ProviderMessageRequest): unknown;\n\n /**\n * Transform a generic template request to provider-specific format\n */\n abstract transformTemplateRequest(request: TemplateCreateRequest): unknown;\n\n /**\n * Common transformation utilities\n */\n protected formatPhoneNumber(phoneNumber: string, countryCode: string = 'KR'): string {\n // Remove any non-digit characters\n const digits = phoneNumber.replace(/\\D/g, '');\n \n if (countryCode === 'KR') {\n // Korean phone number formatting\n if (digits.startsWith('82')) {\n // International format, remove country code\n return digits.substring(2);\n }\n \n if (digits.startsWith('0')) {\n // Domestic format with leading 0\n return digits;\n }\n \n // Add leading 0 if missing\n return '0' + digits;\n }\n \n return phoneNumber;\n }\n\n protected formatVariables(variables: Record<string, unknown>): Record<string, string> {\n const formatted: Record<string, string> = {};\n \n for (const [key, value] of Object.entries(variables)) {\n if (value instanceof Date) {\n formatted[key] = value.toISOString();\n } else if (typeof value === 'object') {\n formatted[key] = JSON.stringify(value);\n } else {\n formatted[key] = String(value);\n }\n }\n \n return formatted;\n }\n\n protected validateRequiredFields(data: unknown, requiredFields: string[]): void {\n const obj = data as Record<string, unknown>;\n for (const field of requiredFields) {\n if (!(field in obj) || obj[field] === undefined || obj[field] === null) {\n throw new Error(`Required field '${field}' is missing`);\n }\n }\n }\n}\n\n/**\n * Request adapter for IWINV provider\n */\nexport class IWINVRequestAdapter extends BaseRequestAdapter {\n transformMessageRequest(request: ProviderMessageRequest): unknown {\n this.validateRequiredFields(request, ['templateCode', 'phoneNumber']);\n\n return {\n profile_key: this.getProfileKey(),\n template_code: request.templateCode,\n phone_number: this.formatPhoneNumber(request.phoneNumber),\n message_variables: this.formatVariables(request.variables),\n sender_number: request.senderNumber,\n reserve_time: request.options?.scheduledAt ? \n Math.floor(new Date(request.options.scheduledAt).getTime() / 1000) : undefined\n };\n }\n\n transformTemplateRequest(request: TemplateCreateRequest): unknown {\n this.validateRequiredFields(request, ['name', 'content']);\n\n return {\n profile_key: this.getProfileKey(),\n template_name: request.name,\n template_content: request.content,\n template_category: this.mapCategory(request.category),\n template_variables: request.variables?.map(v => ({\n name: v.name,\n type: v.type,\n required: v.required ? 'Y' : 'N',\n max_length: v.maxLength\n })),\n template_buttons: request.buttons?.map(b => ({\n type: b.type,\n name: b.name,\n url_mobile: b.linkMobile,\n url_pc: b.linkPc,\n scheme_ios: b.schemeIos,\n scheme_android: b.schemeAndroid\n }))\n };\n }\n\n private getProfileKey(): string {\n // This should come from configuration\n return process.env.IWINV_PROFILE_KEY || '';\n }\n\n private mapCategory(category: string): string {\n const categoryMap: Record<string, string> = {\n 'AUTHENTICATION': 'A',\n 'NOTIFICATION': 'N',\n 'PROMOTION': 'P',\n 'INFORMATION': 'I'\n };\n \n return categoryMap[category] || 'I';\n }\n}\n\n/**\n * Request adapter for Aligo provider\n */\nexport class AligoRequestAdapter extends BaseRequestAdapter {\n transformMessageRequest(request: ProviderMessageRequest): unknown {\n this.validateRequiredFields(request, ['templateCode', 'phoneNumber']);\n\n return {\n apikey: this.getApiKey(),\n userid: this.getUserId(),\n senderkey: this.getSenderKey(),\n template_code: request.templateCode,\n receiver: this.formatPhoneNumber(request.phoneNumber),\n subject: 'AlimTalk',\n message: this.buildMessage(request),\n button: request.variables.buttons ? JSON.stringify(request.variables.buttons) : undefined,\n reservation: request.options?.scheduledAt ? \n this.formatDateTime(new Date(request.options.scheduledAt)) : undefined\n };\n }\n\n transformTemplateRequest(request: TemplateCreateRequest): unknown {\n this.validateRequiredFields(request, ['name', 'content']);\n\n return {\n apikey: this.getApiKey(),\n userid: this.getUserId(),\n senderkey: this.getSenderKey(),\n template_name: request.name,\n template_content: request.content,\n template_emphasis: this.extractEmphasis(request.content),\n template_extra: this.buildTemplateExtra(request),\n template_ad: this.isPromotional(request.category) ? 'Y' : 'N'\n };\n }\n\n private getApiKey(): string {\n return process.env.ALIGO_API_KEY || '';\n }\n\n private getUserId(): string {\n return process.env.ALIGO_USER_ID || '';\n }\n\n private getSenderKey(): string {\n return process.env.ALIGO_SENDER_KEY || '';\n }\n\n private buildMessage(request: ProviderMessageRequest): string {\n // This would typically involve replacing template variables\n // For now, return the template code as placeholder\n return request.templateCode;\n }\n\n private formatDateTime(date: Date): string {\n return date.toISOString().replace(/[-:]/g, '').replace('T', '').substring(0, 12);\n }\n\n private extractEmphasis(content: string): string {\n // Extract emphasized parts (bold, etc.) from content\n const emphasisMatch = content.match(/\\*\\*(.*?)\\*\\*/);\n return emphasisMatch ? emphasisMatch[1] : '';\n }\n\n private buildTemplateExtra(request: TemplateCreateRequest): string {\n const extra: Record<string, unknown> = {};\n \n if (request.buttons) {\n extra.buttons = request.buttons;\n }\n \n if (request.variables) {\n extra.variables = request.variables;\n }\n \n return JSON.stringify(extra);\n }\n\n private isPromotional(category: string): boolean {\n return category === 'PROMOTION';\n }\n}\n\n/**\n * Request adapter for Kakao provider (direct API)\n */\nexport class KakaoRequestAdapter extends BaseRequestAdapter {\n transformMessageRequest(request: ProviderMessageRequest): unknown {\n this.validateRequiredFields(request, ['templateCode', 'phoneNumber']);\n\n return {\n template_object: {\n object_type: 'text',\n text: this.buildTemplateText(request),\n link: this.buildTemplateLink(request),\n button_title: request.variables.buttonTitle || ''\n },\n user_ids: [this.formatPhoneNumber(request.phoneNumber)]\n };\n }\n\n transformTemplateRequest(request: TemplateCreateRequest): unknown {\n this.validateRequiredFields(request, ['name', 'content']);\n\n return {\n template: {\n name: request.name,\n content: request.content,\n category_code: this.mapCategoryCode(request.category),\n template_message_type: 'BA', // Basic AlimTalk\n template_emphasis_type: this.extractEmphasisType(request.content),\n template_title: request.name,\n template_subtitle: '',\n template_imageurl: '',\n template_header: '',\n template_item_highlight: {\n title: '',\n description: ''\n },\n template_item: {\n list: []\n },\n template_button: this.buildTemplateButtons(request.buttons || [])\n }\n };\n }\n\n private buildTemplateText(request: ProviderMessageRequest): string {\n // Build the actual message text by replacing variables\n let text = request.templateCode; // This should be the actual template content\n \n for (const [key, value] of Object.entries(request.variables)) {\n text = text.replace(new RegExp(`#{${key}}`, 'g'), String(value));\n }\n \n return text;\n }\n\n private buildTemplateLink(request: ProviderMessageRequest): unknown {\n if (request.variables.linkUrl) {\n return {\n web_url: request.variables.linkUrl,\n mobile_web_url: request.variables.linkUrl\n };\n }\n return {};\n }\n\n private mapCategoryCode(category: string): string {\n const categoryMap: Record<string, string> = {\n 'AUTHENTICATION': '999999',\n 'NOTIFICATION': '999998',\n 'PROMOTION': '999997',\n 'INFORMATION': '999996'\n };\n \n return categoryMap[category] || '999999';\n }\n\n private extractEmphasisType(content: string): string {\n if (content.includes('**')) return 'BOLD';\n if (content.includes('__')) return 'UNDERLINE';\n return 'NONE';\n }\n\n private buildTemplateButtons(buttons: unknown[]): unknown[] {\n return buttons.map(button => {\n const btn = button as Record<string, unknown>;\n return {\n name: btn.name,\n type: btn.type,\n url_mobile: btn.linkMobile,\n url_pc: btn.linkPc,\n scheme_ios: btn.schemeIos,\n scheme_android: btn.schemeAndroid\n };\n });\n }\n}\n\n/**\n * Factory for creating request adapters\n */\ntype RequestAdapterConstructor = new () => BaseRequestAdapter;\n\nexport class RequestAdapterFactory {\n private static adapters: Map<string, RequestAdapterConstructor> = new Map();\n\n static {\n RequestAdapterFactory.adapters.set('iwinv', IWINVRequestAdapter);\n RequestAdapterFactory.adapters.set('aligo', AligoRequestAdapter);\n RequestAdapterFactory.adapters.set('kakao', KakaoRequestAdapter);\n }\n\n static create(providerId: string): BaseRequestAdapter {\n const AdapterClass = this.adapters.get(providerId.toLowerCase());\n \n if (!AdapterClass) {\n throw new Error(`No request adapter found for provider: ${providerId}`);\n }\n \n return new AdapterClass();\n }\n\n static register(providerId: string, adapterClass: new () => BaseRequestAdapter): void {\n this.adapters.set(providerId.toLowerCase(), adapterClass);\n }\n}","import { \n ProviderMessageResult, \n MessageStatus, \n ProviderError,\n TemplateCreateResult,\n TemplateStatus,\n ProviderResponse \n} from '../contracts/provider.contract';\n\n/**\n * Base response adapter for transforming provider responses to platform format\n */\nexport abstract class BaseResponseAdapter {\n /**\n * Transform provider message response to standard format\n */\n abstract transformMessageResponse(providerResponse: unknown): ProviderMessageResult;\n\n /**\n * Transform provider template response to standard format\n */\n abstract transformTemplateResponse(providerResponse: unknown): TemplateCreateResult;\n\n /**\n * Common error transformation\n */\n protected transformError(providerError: unknown): ProviderError {\n return {\n code: this.extractErrorCode(providerError),\n message: this.extractErrorMessage(providerError),\n details: this.extractErrorDetails(providerError)\n };\n }\n\n protected abstract extractErrorCode(providerError: unknown): string;\n protected abstract extractErrorMessage(providerError: unknown): string;\n protected abstract extractErrorDetails(providerError: unknown): Record<string, unknown>;\n\n /**\n * Common status mapping utilities\n */\n protected mapMessageStatus(providerStatus: string): MessageStatus {\n // Default implementation - override in specific adapters\n const statusMap: Record<string, MessageStatus> = {\n 'queued': MessageStatus.QUEUED,\n 'sending': MessageStatus.SENDING,\n 'sent': MessageStatus.SENT,\n 'delivered': MessageStatus.DELIVERED,\n 'failed': MessageStatus.FAILED,\n 'cancelled': MessageStatus.CANCELLED\n };\n\n return statusMap[providerStatus.toLowerCase()] || MessageStatus.FAILED;\n }\n\n protected mapTemplateStatus(providerStatus: string): TemplateStatus {\n // Default implementation - override in specific adapters\n const statusMap: Record<string, TemplateStatus> = {\n 'draft': TemplateStatus.DRAFT,\n 'pending': TemplateStatus.PENDING,\n 'approved': TemplateStatus.APPROVED,\n 'rejected': TemplateStatus.REJECTED,\n 'disabled': TemplateStatus.DISABLED\n };\n\n return statusMap[providerStatus.toLowerCase()] || TemplateStatus.DRAFT;\n }\n\n protected parseDate(dateString: string): Date | undefined {\n if (!dateString) return undefined;\n \n try {\n return new Date(dateString);\n } catch {\n return undefined;\n }\n }\n}\n\n/**\n * Response adapter for IWINV provider\n */\nexport class IWINVResponseAdapter extends BaseResponseAdapter {\n transformMessageResponse(providerResponse: unknown): ProviderMessageResult {\n const response = providerResponse as Record<string, unknown>;\n return {\n messageId: (response.msg_id || response.msgid) as string,\n status: this.mapIWINVMessageStatus(response.result_code as string),\n sentAt: this.parseDate(response.send_time as string),\n error: response.result_code !== '1' ? this.transformError(providerResponse) : undefined\n };\n }\n\n transformTemplateResponse(providerResponse: unknown): TemplateCreateResult {\n const response = providerResponse as Record<string, unknown>;\n return {\n templateId: response.template_id as string,\n providerTemplateCode: response.template_code as string,\n status: this.mapIWINVTemplateStatus(response.status as string),\n message: (response.message || response.comment) as string\n };\n }\n\n private mapIWINVMessageStatus(resultCode: string): MessageStatus {\n const statusMap: Record<string, MessageStatus> = {\n '1': MessageStatus.SENT,\n '0': MessageStatus.FAILED,\n '-1': MessageStatus.FAILED,\n '-2': MessageStatus.FAILED,\n '-3': MessageStatus.FAILED,\n '-4': MessageStatus.FAILED\n };\n\n return statusMap[resultCode] || MessageStatus.FAILED;\n }\n\n private mapIWINVTemplateStatus(status: string): TemplateStatus {\n const statusMap: Record<string, TemplateStatus> = {\n 'R': TemplateStatus.PENDING, // Request\n 'A': TemplateStatus.APPROVED, // Approved\n 'C': TemplateStatus.REJECTED, // Cancelled/Rejected\n 'S': TemplateStatus.PENDING // Standby\n };\n\n return statusMap[status] || TemplateStatus.DRAFT;\n }\n\n protected extractErrorCode(providerError: unknown): string {\n const error = providerError as Record<string, unknown>;\n return (error.result_code || error.error_code || 'UNKNOWN_ERROR') as string;\n }\n\n protected extractErrorMessage(providerError: unknown): string {\n const error = providerError as Record<string, unknown>;\n return (error.message || error.error_message || 'Unknown error occurred') as string;\n }\n\n protected extractErrorDetails(providerError: unknown): Record<string, unknown> {\n const error = providerError as Record<string, unknown>;\n return {\n resultCode: error.result_code,\n originalResponse: providerError\n };\n }\n}\n\n/**\n * Response adapter for Aligo provider\n */\nexport class AligoResponseAdapter extends BaseResponseAdapter {\n transformMessageResponse(providerResponse: unknown): ProviderMessageResult {\n const response = providerResponse as Record<string, unknown>;\n return {\n messageId: (response.msg_id || response.mid) as string,\n status: this.mapAligoMessageStatus(response.result_code as string),\n sentAt: this.parseDate(response.send_time as string),\n error: response.result_code !== '1' ? this.transformError(providerResponse) : undefined\n };\n }\n\n transformTemplateResponse(providerResponse: unknown): TemplateCreateResult {\n const response = providerResponse as Record<string, unknown>;\n return {\n templateId: response.template_code as string,\n providerTemplateCode: response.template_code as string,\n status: this.mapAligoTemplateStatus(response.inspect_status as string),\n message: response.comment as string\n };\n }\n\n private mapAligoMessageStatus(resultCode: string): MessageStatus {\n const statusMap: Record<string, MessageStatus> = {\n '1': MessageStatus.SENT,\n '0': MessageStatus.FAILED,\n '-1': MessageStatus.FAILED,\n '-101': MessageStatus.FAILED,\n '-102': MessageStatus.FAILED\n };\n\n return statusMap[resultCode] || MessageStatus.FAILED;\n }\n\n private mapAligoTemplateStatus(inspectStatus: string): TemplateStatus {\n const statusMap: Record<string, TemplateStatus> = {\n 'REG': TemplateStatus.PENDING, // Registered\n 'REQ': TemplateStatus.PENDING, // Request\n 'APR': TemplateStatus.APPROVED, // Approved\n 'REJ': TemplateStatus.REJECTED, // Rejected\n 'STOP': TemplateStatus.DISABLED // Stopped\n };\n\n return statusMap[inspectStatus] || TemplateStatus.DRAFT;\n }\n\n protected extractErrorCode(providerError: unknown): string {\n const error = providerError as Record<string, unknown>;\n return (error.result_code || error.code || 'UNKNOWN_ERROR') as string;\n }\n\n protected extractErrorMessage(providerError: unknown): string {\n const error = providerError as Record<string, unknown>;\n return (error.message || error.error || 'Unknown error occurred') as string;\n }\n\n protected extractErrorDetails(providerError: unknown): Record<string, unknown> {\n const error = providerError as Record<string, unknown>;\n return {\n resultCode: error.result_code,\n inspectStatus: error.inspect_status,\n originalResponse: providerError\n };\n }\n}\n\n/**\n * Response adapter for Kakao provider (direct API)\n */\nexport class KakaoResponseAdapter extends BaseResponseAdapter {\n transformMessageResponse(providerResponse: unknown): ProviderMessageResult {\n const response = providerResponse as Record<string, unknown>;\n return {\n messageId: response.message_id as string,\n status: this.mapKakaoMessageStatus(response.result_code as number),\n sentAt: this.parseDate(response.sent_time as string),\n error: response.result_code !== 0 ? this.transformError(providerResponse) : undefined\n };\n }\n\n transformTemplateResponse(providerResponse: unknown): TemplateCreateResult {\n const response = providerResponse as Record<string, unknown>;\n return {\n templateId: response.template_id as string,\n providerTemplateCode: response.template_code as string,\n status: this.mapKakaoTemplateStatus(response.status as string),\n message: response.comments as string\n };\n }\n\n private mapKakaoMessageStatus(resultCode: number): MessageStatus {\n const statusMap: Record<number, MessageStatus> = {\n 0: MessageStatus.SENT,\n [-1]: MessageStatus.FAILED,\n [-2]: MessageStatus.FAILED,\n [-3]: MessageStatus.FAILED,\n [-999]: MessageStatus.FAILED\n };\n\n return statusMap[resultCode] || MessageStatus.FAILED;\n }\n\n private mapKakaoTemplateStatus(status: string): TemplateStatus {\n const statusMap: Record<string, TemplateStatus> = {\n 'TSC01': TemplateStatus.PENDING, // Under review\n 'TSC02': TemplateStatus.APPROVED, // Approved\n 'TSC03': TemplateStatus.REJECTED, // Rejected\n 'TSC04': TemplateStatus.DISABLED // Disabled\n };\n\n return statusMap[status] || TemplateStatus.DRAFT;\n }\n\n protected extractErrorCode(providerError: unknown): string {\n const error = providerError as Record<string, unknown>;\n return String(error.result_code || error.error_code || 'UNKNOWN_ERROR');\n }\n\n protected extractErrorMessage(providerError: unknown): string {\n const error = providerError as Record<string, unknown>;\n return (error.message || error.error_message || 'Unknown error occurred') as string;\n }\n\n protected extractErrorDetails(providerError: unknown): Record<string, unknown> {\n const error = providerError as Record<string, unknown>;\n return {\n resultCode: error.result_code,\n originalResponse: providerError\n };\n }\n}\n\n/**\n * Response adapter for NHN provider\n */\nexport class NHNResponseAdapter extends BaseResponseAdapter {\n transformMessageResponse(providerResponse: unknown): ProviderMessageResult {\n const response = providerResponse as Record<string, unknown>;\n return {\n messageId: response.requestId as string,\n status: this.mapNHNMessageStatus(response.statusCode as string),\n sentAt: this.parseDate(response.statusDateTime as string),\n error: response.statusCode !== 'SSS' ? this.transformError(providerResponse) : undefined\n };\n }\n\n transformTemplateResponse(providerResponse: unknown): TemplateCreateResult {\n const response = providerResponse as Record<string, unknown>;\n return {\