From 1d933f5c12eb495fee1d6bf83c1cfcb6663fffd6 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 27 Jan 2026 15:00:00 +0900 Subject: [PATCH 01/43] docs: expand CLAUDE.md with package structure, commands, and version details Add detailed package structure diagram, single test execution command, JAR generation commands, exception hierarchy documentation, and update dependency versions to reflect current build configuration. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f072492..ecd7e93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co # Run tests only ./gradlew test +# Run a single test +./gradlew test --tests "ClassName.methodName" + # Build without tests ./gradlew build -x test ``` @@ -20,6 +23,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ```bash # Generate Dokka documentation (outputs to ./docs/) ./gradlew dokkaGeneratePublicationHtml + +# Generate Javadoc JAR +./gradlew dokkaJavadocJar + +# Generate sources JAR +./gradlew sourcesJar ``` ### Publishing @@ -33,6 +42,30 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Architecture +### Package Structure +``` +com.solapi.sdk/ +├── SolapiClient.kt # Main entry point, factory methods +├── NurigoApp.kt # Application configuration +└── message/ + ├── dto/ + │ ├── request/ # API request DTOs + │ │ └── kakao/ # Kakao-specific requests + │ └── response/ # API response DTOs + │ ├── common/ # Shared response types + │ └── kakao/ # Kakao-specific responses + ├── exception/ # Custom exception hierarchy + ├── lib/ # Utility classes (Authenticator, helpers) + ├── model/ # Core domain models + │ ├── fax/ # Fax options + │ ├── group/ # Group messaging models + │ ├── kakao/ # Kakao templates and options + │ ├── naver/ # Naver options + │ ├── rcs/ # RCS options + │ └── voice/ # Voice message options + └── service/ # Service layer implementations +``` + ### Core Structure - **Package Migration**: Recently migrated from `net.nurigo.sdk` to `com.solapi.sdk` - **Main Entry Point**: `SolapiClient` object provides factory methods for creating service instances @@ -50,6 +83,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Authenticator**: `Authenticator.kt` handles HMAC-based API authentication - **Auto-injection**: Authentication headers are automatically added via OkHttp interceptor +#### Exception Hierarchy +- `SolapiException` - Base exception class + - `SolapiApiKeyException` - API key related errors + - `SolapiInvalidApiKeyException` - Invalid API key + - `SolapiBadRequestException` - Bad request errors + - `SolapiEmptyResponseException` - Empty response from server + - `SolapiFileUploadException` - File upload failures + - `SolapiMessageNotReceivedException` - Message delivery failures + - `SolapiUnknownException` - Unclassified errors + #### Specialized Features - **Kakao Integration**: Full support for Alimtalk and Brand Message templates - **File Upload**: Base64 encoding for MMS, Fax, and other file-based messages @@ -64,16 +107,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Build Configuration - **Target**: Java 8 compatibility -- **Kotlin**: Version 2.2.0 with coroutines and serialization -- **Dependencies**: OkHttp, Retrofit, Kotlinx Serialization, Apache Commons Codec +- **Kotlin**: Version 2.2.10 with kotlinx-serialization +- **Gradle**: Version 8.14.3 (via wrapper) +- **Dependencies**: OkHttp 5.1.0, Retrofit 3.0.0, Kotlinx Serialization 1.9.0, Apache Commons Codec 1.18.0 - **Shadow JAR**: Dependencies relocated to `com.solapi.shadow` namespace -- **Version Generation**: Build script auto-generates `Version.kt` with current version +- **Version Generation**: Build script auto-generates `Version.kt` at `build/generated/source/kotlin/com/solapi/sdk/Version.kt` ## Testing -- **Framework**: JUnit 5 Jupiter +- **Framework**: JUnit 5 Jupiter (configured but no tests written yet) - **Run Command**: `./gradlew test` -- **Test Location**: `src/test/java/` +- **Test Location**: `src/test/java/` (to be created) ## Documentation - **API Docs**: Generated with Dokka to `./docs/` directory -- **Examples**: Referenced external repository for usage examples \ No newline at end of file +- **Examples**: Referenced external repository for usage examples From 8eb0072393d1ce73add0deb62cf68d87c63a7ccb Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 27 Jan 2026 15:21:31 +0900 Subject: [PATCH 02/43] chore: upgrade Gradle 9.3.0, Kotlin 2.3.0, and dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump version to 1.1.0 - Upgrade Gradle wrapper from 8.14.3 to 9.3.0 - Upgrade Kotlin from 2.2.10 to 2.3.0 - Update dependencies: - commons-codec: 1.18.0 → 1.20.0 - okhttp: 5.1.0 → 5.3.0 - kotlinx-serialization-json: 1.9.0 → 1.10.0 - junit-jupiter: 5.10.0 → 5.14.2 - dokka: 2.0.0 → 2.1.0 - shadow: 8.3.8 → 9.3.1 - maven-publish: 0.34.0 → 0.36.0 - Simplify dokka configuration Co-Authored-By: Claude Opus 4.5 --- build.gradle.kts | 37 +-- gradle/wrapper/gradle-wrapper.jar | Bin 59536 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew | 286 ++++++++++++++--------- gradlew.bat | 41 ++-- 5 files changed, 221 insertions(+), 147 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 0b27823..12e28cd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,28 +1,33 @@ import org.gradle.api.publish.maven.tasks.AbstractPublishToMaven -import org.jetbrains.dokka.gradle.DokkaTaskPartial import org.jetbrains.dokka.gradle.tasks.DokkaGeneratePublicationTask import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - kotlin("jvm") version "2.2.10" - kotlin("plugin.serialization") version "2.2.10" - id("org.jetbrains.dokka") version "2.0.0" - id("com.gradleup.shadow") version "8.3.8" + kotlin("jvm") version "2.3.0" + kotlin("plugin.serialization") version "2.3.0" + id("org.jetbrains.dokka") version "2.1.0" + id("com.gradleup.shadow") version "9.3.1" java `java-library` `maven-publish` signing - id("com.vanniktech.maven.publish") version "0.34.0" + id("com.vanniktech.maven.publish") version "0.36.0" } group = "com.solapi" -version = "1.0.3" +version = "1.1.0" repositories { mavenCentral() } +dokka { + dokkaPublications.html { + suppressInheritedMembers.set(true) + } +} + mavenPublishing { // Central Portal 사용 (OSSRH가 아닌) publishToMavenCentral(automaticRelease = false) @@ -77,17 +82,17 @@ mavenPublishing { dependencies { implementation(kotlin("stdlib-jdk8")) implementation(kotlin("reflect")) - implementation("commons-codec:commons-codec:1.18.0") - implementation("com.squareup.okhttp3:okhttp:5.1.0") - implementation("com.squareup.okhttp3:logging-interceptor:5.1.0") + implementation("commons-codec:commons-codec:1.20.0") + implementation("com.squareup.okhttp3:okhttp:5.3.0") + implementation("com.squareup.okhttp3:logging-interceptor:5.3.0") implementation("com.squareup.retrofit2:retrofit:3.0.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") implementation("com.squareup.retrofit2:converter-kotlinx-serialization:3.0.0") - testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.0") + testImplementation("org.junit.jupiter:junit-jupiter-api:5.14.2") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.14.2") - dokkaHtmlPlugin("org.jetbrains.dokka:kotlin-as-java-plugin:2.0.0") + dokkaHtmlPlugin("org.jetbrains.dokka:kotlin-as-java-plugin:2.1.0") } val generatedSrcDir = layout.buildDirectory.dir("generated/source/kotlin") @@ -154,10 +159,6 @@ compileTestKotlin.compilerOptions { jvmTarget.set(JvmTarget.JVM_1_8) } -tasks.withType().configureEach { - outputDirectory.set(project.rootDir.resolve("docs")) -} - tasks.withType().configureEach { dependsOn(generateVersionFile) outputDirectory.set(project.rootDir.resolve("docs")) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7454180f2ae8848c63b8b4dea2cb829da983f2fa..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch literal 43764 zcma&OWmKeVvL#I6?i3D%6z=Zs?ofE*?rw#G$eqJB ziT4y8-Y@s9rkH0Tz>ll(^xkcTl)CY?rS&9VNd66Yc)g^6)JcWaY(5$5gt z8gr3SBXUTN;~cBgz&})qX%#!Fxom2Yau_`&8)+6aSN7YY+pS410rRUU*>J}qL0TnJ zRxt*7QeUqTh8j)Q&iavh<}L+$Jqz))<`IfKussVk%%Ah-Ti?Eo0hQH!rK%K=#EAw0 zwq@@~XNUXRnv8$;zv<6rCRJ6fPD^hfrh;0K?n z=p!u^3xOgWZ%f3+?+>H)9+w^$Tn1e;?UpVMJb!!;f)`6f&4|8mr+g)^@x>_rvnL0< zvD0Hu_N>$(Li7|Jgu0mRh&MV+<}`~Wi*+avM01E)Jtg=)-vViQKax!GeDc!xv$^mL z{#OVBA$U{(Zr8~Xm|cP@odkHC*1R8z6hcLY#N@3E-A8XEvpt066+3t9L_6Zg6j@9Q zj$$%~yO-OS6PUVrM2s)(T4#6=JpI_@Uz+!6=GdyVU?`!F=d;8#ZB@(5g7$A0(`eqY z8_i@3w$0*es5mrSjhW*qzrl!_LQWs4?VfLmo1Sd@Ztt53+etwzAT^8ow_*7Jp`Y|l z*UgSEwvxq+FYO!O*aLf-PinZYne7Ib6ny3u>MjQz=((r3NTEeU4=-i0LBq3H-VJH< z^>1RE3_JwrclUn9vb7HcGUaFRA0QHcnE;6)hnkp%lY1UII#WPAv?-;c?YH}LWB8Nl z{sx-@Z;QxWh9fX8SxLZk8;kMFlGD3Jc^QZVL4nO)1I$zQwvwM&_!kW+LMf&lApv#< zur|EyC|U@5OQuph$TC_ZU`{!vJp`13e9alaR0Dbn5ikLFH7>eIz4QbV|C=%7)F=qo z_>M&5N)d)7G(A%c>}UCrW!Ql_6_A{?R7&CL`;!KOb3 z8Z=$YkV-IF;c7zs{3-WDEFJzuakFbd*4LWd<_kBE8~BFcv}js_2OowRNzWCtCQ6&k z{&~Me92$m*@e0ANcWKuz)?YjB*VoSTx??-3Cc0l2U!X^;Bv@m87eKHukAljrD54R+ zE;@_w4NPe1>3`i5Qy*3^E9x#VB6?}v=~qIprrrd5|DFkg;v5ixo0IsBmik8=Y;zv2 z%Bcf%NE$a44bk^`i4VwDLTbX=q@j9;JWT9JncQ!+Y%2&HHk@1~*L8-{ZpY?(-a9J-1~<1ltr9i~D9`P{XTIFWA6IG8c4;6bFw*lzU-{+?b&%OcIoCiw00n>A1ra zFPE$y@>ebbZlf(sN_iWBzQKDV zmmaLX#zK!@ZdvCANfwV}9@2O&w)!5gSgQzHdk2Q`jG6KD7S+1R5&F)j6QTD^=hq&7 zHUW+r^da^%V(h(wonR(j?BOiC!;y=%nJvz?*aW&5E87qq;2z`EI(f zBJNNSMFF9U{sR-af5{IY&AtoGcoG)Iq-S^v{7+t0>7N(KRoPj;+2N5;9o_nxIGjJ@ z7bYQK)bX)vEhy~VL%N6g^NE@D5VtV+Q8U2%{ji_=6+i^G%xeskEhH>Sqr194PJ$fB zu1y^){?9Vkg(FY2h)3ZHrw0Z<@;(gd_dtF#6y_;Iwi{yX$?asr?0N0_B*CifEi7<6 zq`?OdQjCYbhVcg+7MSgIM|pJRu~`g?g3x?Tl+V}#$It`iD1j+!x+!;wS0+2e>#g?Z z*EA^k7W{jO1r^K~cD#5pamp+o@8&yw6;%b|uiT?{Wa=4+9<}aXWUuL#ZwN1a;lQod zW{pxWCYGXdEq9qAmvAB904}?97=re$>!I%wxPV#|f#@A*Y=qa%zHlDv^yWbR03%V0 zprLP+b(#fBqxI%FiF*-n8HtH6$8f(P6!H3V^ysgd8de-N(@|K!A< z^qP}jp(RaM9kQ(^K(U8O84?D)aU(g?1S8iWwe)gqpHCaFlJxb*ilr{KTnu4_@5{K- z)n=CCeCrPHO0WHz)dDtkbZfUfVBd?53}K>C5*-wC4hpDN8cGk3lu-ypq+EYpb_2H; z%vP4@&+c2p;thaTs$dc^1CDGlPG@A;yGR5@$UEqk6p58qpw#7lc<+W(WR;(vr(D>W z#(K$vE#uBkT=*q&uaZwzz=P5mjiee6>!lV?c}QIX%ZdkO1dHg>Fa#xcGT6~}1*2m9 zkc7l3ItD6Ie~o_aFjI$Ri=C!8uF4!Ky7iG9QTrxVbsQroi|r)SAon#*B*{}TB-?=@ z8~jJs;_R2iDd!$+n$%X6FO&PYS{YhDAS+U2o4su9x~1+U3z7YN5o0qUK&|g^klZ6X zj_vrM5SUTnz5`*}Hyts9ADwLu#x_L=nv$Z0`HqN`Zo=V>OQI)fh01n~*a%01%cx%0 z4LTFVjmW+ipVQv5rYcn3;d2o4qunWUY!p+?s~X~(ost@WR@r@EuDOSs8*MT4fiP>! zkfo^!PWJJ1MHgKS2D_hc?Bs?isSDO61>ebl$U*9*QY(b=i&rp3@3GV@z>KzcZOxip z^dzA~44;R~cnhWz7s$$v?_8y-k!DZys}Q?4IkSyR!)C0j$(Gm|t#e3|QAOFaV2}36 z?dPNY;@I=FaCwylc_;~kXlZsk$_eLkNb~TIl8QQ`mmH&$*zwwR8zHU*sId)rxHu*K z;yZWa8UmCwju%aSNLwD5fBl^b0Ux1%q8YR*uG`53Mi<`5uA^Dc6Ync)J3N7;zQ*75)hf%a@{$H+%S?SGT)ks60)?6j$ zspl|4Ad6@%-r1t*$tT(en!gIXTUDcsj?28ZEzz)dH)SV3bZ+pjMaW0oc~rOPZP@g! zb9E+ndeVO_Ib9c_>{)`01^`ZS198 z)(t=+{Azi11$eu%aU7jbwuQrO`vLOixuh~%4z@mKr_Oc;F%Uq01fA)^W&y+g16e?rkLhTxV!EqC%2}sx_1u7IBq|}Be&7WI z4I<;1-9tJsI&pQIhj>FPkQV9{(m!wYYV@i5h?A0#BN2wqlEwNDIq06|^2oYVa7<~h zI_OLan0Do*4R5P=a3H9`s5*>xU}_PSztg`+2mv)|3nIy=5#Z$%+@tZnr> zLcTI!Mxa`PY7%{;KW~!=;*t)R_sl<^b>eNO@w#fEt(tPMg_jpJpW$q_DoUlkY|uo> z0-1{ouA#;t%spf*7VjkK&$QrvwUERKt^Sdo)5@?qAP)>}Y!h4(JQ!7{wIdkA+|)bv z&8hBwoX4v|+fie}iTslaBX^i*TjwO}f{V)8*!dMmRPi%XAWc8<_IqK1jUsApk)+~R zNFTCD-h>M5Y{qTQ&0#j@I@tmXGj%rzhTW5%Bkh&sSc=$Fv;M@1y!zvYG5P2(2|(&W zlcbR1{--rJ&s!rB{G-sX5^PaM@3EqWVz_y9cwLR9xMig&9gq(voeI)W&{d6j1jh&< zARXi&APWE1FQWh7eoZjuP z;vdgX>zep^{{2%hem;e*gDJhK1Hj12nBLIJoL<=0+8SVEBx7!4Ea+hBY;A1gBwvY<)tj~T=H`^?3>zeWWm|LAwo*S4Z%bDVUe z6r)CH1H!(>OH#MXFJ2V(U(qxD{4Px2`8qfFLG+=a;B^~Te_Z!r3RO%Oc#ZAHKQxV5 zRYXxZ9T2A%NVJIu5Pu7!Mj>t%YDO$T@M=RR(~mi%sv(YXVl`yMLD;+WZ{vG9(@P#e zMo}ZiK^7^h6TV%cG+;jhJ0s>h&VERs=tuZz^Tlu~%d{ZHtq6hX$V9h)Bw|jVCMudd zwZ5l7In8NT)qEPGF$VSKg&fb0%R2RnUnqa){)V(X(s0U zkCdVZe6wy{+_WhZh3qLp245Y2RR$@g-!9PjJ&4~0cFSHMUn=>dapv)hy}|y91ZWTV zCh=z*!S3_?`$&-eZ6xIXUq8RGl9oK0BJw*TdU6A`LJqX9eS3X@F)g$jLkBWFscPhR zpCv8#KeAc^y>>Y$k^=r|K(DTC}T$0#jQBOwB#@`P6~*IuW_8JxCG}J4va{ zsZzt}tt+cv7=l&CEuVtjD6G2~_Meh%p4RGuY?hSt?(sreO_F}8r7Kp$qQdvCdZnDQ zxzc*qchE*E2=WK)^oRNa>Ttj`fpvF-JZ5tu5>X1xw)J@1!IqWjq)ESBG?J|ez`-Tc zi5a}GZx|w-h%5lNDE_3ho0hEXMoaofo#Z;$8|2;EDF&*L+e$u}K=u?pb;dv$SXeQM zD-~7P0i_`Wk$#YP$=hw3UVU+=^@Kuy$>6?~gIXx636jh{PHly_a2xNYe1l60`|y!7 z(u%;ILuW0DDJ)2%y`Zc~hOALnj1~txJtcdD#o4BCT68+8gZe`=^te6H_egxY#nZH&P*)hgYaoJ^qtmpeea`35Fw)cy!w@c#v6E29co8&D9CTCl%^GV|X;SpneSXzV~LXyRn-@K0Df z{tK-nDWA!q38M1~`xUIt_(MO^R(yNY#9@es9RQbY@Ia*xHhD&=k^T+ zJi@j2I|WcgW=PuAc>hs`(&CvgjL2a9Rx zCbZyUpi8NWUOi@S%t+Su4|r&UoU|ze9SVe7p@f1GBkrjkkq)T}X%Qo1g!SQ{O{P?m z-OfGyyWta+UCXH+-+(D^%kw#A1-U;?9129at7MeCCzC{DNgO zeSqsV>W^NIfTO~4({c}KUiuoH8A*J!Cb0*sp*w-Bg@YfBIPZFH!M}C=S=S7PLLcIG zs7K77g~W)~^|+mx9onzMm0qh(f~OsDTzVmRtz=aZTllgR zGUn~_5hw_k&rll<4G=G+`^Xlnw;jNYDJz@bE?|r866F2hA9v0-8=JO3g}IHB#b`hy zA42a0>{0L7CcabSD+F7?pGbS1KMvT{@1_@k!_+Ki|5~EMGt7T%u=79F)8xEiL5!EJ zzuxQ`NBliCoJMJdwu|);zRCD<5Sf?Y>U$trQ-;xj6!s5&w=9E7)%pZ+1Nh&8nCCwM zv5>Ket%I?cxr3vVva`YeR?dGxbG@pi{H#8@kFEf0Jq6~K4>kt26*bxv=P&jyE#e$| zDJB_~imk^-z|o!2njF2hL*|7sHCnzluhJjwLQGDmC)Y9 zr9ZN`s)uCd^XDvn)VirMgW~qfn1~SaN^7vcX#K1G`==UGaDVVx$0BQnubhX|{e z^i0}>k-;BP#Szk{cFjO{2x~LjK{^Upqd&<+03_iMLp0$!6_$@TbX>8U-f*-w-ew1?`CtD_0y_Lo|PfKi52p?`5$Jzx0E8`M0 zNIb?#!K$mM4X%`Ry_yhG5k@*+n4||2!~*+&pYLh~{`~o(W|o64^NrjP?-1Lgu?iK^ zTX6u3?#$?R?N!{599vg>G8RGHw)Hx&=|g4599y}mXNpM{EPKKXB&+m?==R3GsIq?G zL5fH={=zawB(sMlDBJ+{dgb)Vx3pu>L=mDV0{r1Qs{0Pn%TpopH{m(By4;{FBvi{I z$}x!Iw~MJOL~&)p93SDIfP3x%ROjg}X{Sme#hiJ&Yk&a;iR}V|n%PriZBY8SX2*;6 z4hdb^&h;Xz%)BDACY5AUsV!($lib4>11UmcgXKWpzRL8r2Srl*9Y(1uBQsY&hO&uv znDNff0tpHlLISam?o(lOp#CmFdH<6HmA0{UwfU#Y{8M+7od8b8|B|7ZYR9f<#+V|ZSaCQvI$~es~g(Pv{2&m_rKSB2QQ zMvT}$?Ll>V+!9Xh5^iy3?UG;dF-zh~RL#++roOCsW^cZ&({6q|?Jt6`?S8=16Y{oH zp50I7r1AC1(#{b`Aq5cw>ypNggHKM9vBx!W$eYIzD!4KbLsZGr2o8>g<@inmS3*>J zx8oG((8f!ei|M@JZB`p7+n<Q}?>h249<`7xJ?u}_n;Gq(&km#1ULN87CeTO~FY zS_Ty}0TgQhV zOh3T7{{x&LSYGQfKR1PDIkP!WnfC1$l+fs@Di+d4O=eVKeF~2fq#1<8hEvpwuqcaH z4A8u~r^gnY3u6}zj*RHjk{AHhrrDqaj?|6GaVJbV%o-nATw}ASFr!f`Oz|u_QPkR# z0mDudY1dZRlk@TyQ?%Eti=$_WNFtLpSx9=S^be{wXINp%MU?a`F66LNU<c;0&ngifmP9i;bj6&hdGMW^Kf8e6ZDXbQD&$QAAMo;OQ)G zW(qlHh;}!ZP)JKEjm$VZjTs@hk&4{?@+NADuYrr!R^cJzU{kGc1yB?;7mIyAWwhbeA_l_lw-iDVi7wcFurf5 z#Uw)A@a9fOf{D}AWE%<`s1L_AwpZ?F!Vac$LYkp<#A!!`XKaDC{A%)~K#5z6>Hv@V zBEqF(D5?@6r3Pwj$^krpPDCjB+UOszqUS;b2n>&iAFcw<*im2(b3|5u6SK!n9Sg4I z0KLcwA6{Mq?p%t>aW0W!PQ>iUeYvNjdKYqII!CE7SsS&Rj)eIw-K4jtI?II+0IdGq z2WT|L3RL?;GtGgt1LWfI4Ka`9dbZXc$TMJ~8#Juv@K^1RJN@yzdLS8$AJ(>g!U9`# zx}qr7JWlU+&m)VG*Se;rGisutS%!6yybi%B`bv|9rjS(xOUIvbNz5qtvC$_JYY+c& za*3*2$RUH8p%pSq>48xR)4qsp!Q7BEiJ*`^>^6INRbC@>+2q9?x(h0bpc>GaNFi$K zPH$6!#(~{8@0QZk=)QnM#I=bDx5vTvjm$f4K}%*s+((H2>tUTf==$wqyoI`oxI7>C z&>5fe)Yg)SmT)eA(|j@JYR1M%KixxC-Eceknf-;N=jJTwKvk#@|J^&5H0c+%KxHUI z6dQbwwVx3p?X<_VRVb2fStH?HH zFR@Mp=qX%#L3XL)+$PXKV|o|#DpHAoqvj6uQKe@M-mnhCSou7Dj4YuO6^*V`m)1lf z;)@e%1!Qg$10w8uEmz{ENb$^%u}B;J7sDd zump}onoD#!l=agcBR)iG!3AF0-63%@`K9G(CzKrm$VJ{v7^O9Ps7Zej|3m= zVXlR&yW6=Y%mD30G@|tf=yC7-#L!16Q=dq&@beWgaIL40k0n% z)QHrp2Jck#evLMM1RGt3WvQ936ZC9vEje0nFMfvmOHVI+&okB_K|l-;|4vW;qk>n~ z+|kk8#`K?x`q>`(f6A${wfw9Cx(^)~tX7<#TpxR#zYG2P+FY~mG{tnEkv~d6oUQA+ z&hNTL=~Y@rF`v-RZlts$nb$3(OL1&@Y11hhL9+zUb6)SP!;CD)^GUtUpCHBE`j1te zAGud@miCVFLk$fjsrcpjsadP__yj9iEZUW{Ll7PPi<$R;m1o!&Xdl~R_v0;oDX2z^!&8}zNGA}iYG|k zmehMd1%?R)u6R#<)B)1oe9TgYH5-CqUT8N7K-A-dm3hbm_W21p%8)H{O)xUlBVb+iUR}-v5dFaCyfSd zC6Bd7=N4A@+Bna=!-l|*_(nWGDpoyU>nH=}IOrLfS+-d40&(Wo*dDB9nQiA2Tse$R z;uq{`X7LLzP)%Y9aHa4YQ%H?htkWd3Owv&UYbr5NUDAH^<l@Z0Cx%`N+B*i!!1u>D8%;Qt1$ zE5O0{-`9gdDxZ!`0m}ywH!;c{oBfL-(BH<&SQ~smbcobU!j49O^f4&IIYh~f+hK*M zZwTp%{ZSAhMFj1qFaOA+3)p^gnXH^=)`NTYgTu!CLpEV2NF=~-`(}7p^Eof=@VUbd z_9U|8qF7Rueg&$qpSSkN%%%DpbV?8E8ivu@ensI0toJ7Eas^jyFReQ1JeY9plb^{m z&eQO)qPLZQ6O;FTr*aJq=$cMN)QlQO@G&%z?BKUs1&I^`lq>=QLODwa`(mFGC`0H< zOlc*|N?B5&!U6BuJvkL?s1&nsi$*5cCv7^j_*l&$-sBmRS85UIrE--7eD8Gr3^+o? zqG-Yl4S&E;>H>k^a0GdUI(|n1`ws@)1%sq2XBdK`mqrNq_b4N{#VpouCXLzNvjoFv zo9wMQ6l0+FT+?%N(ka*;%m~(?338bu32v26!{r)|w8J`EL|t$}TA4q_FJRX5 zCPa{hc_I(7TGE#@rO-(!$1H3N-C0{R$J=yPCXCtGk{4>=*B56JdXU9cQVwB`6~cQZ zf^qK21x_d>X%dT!!)CJQ3mlHA@ z{Prkgfs6=Tz%63$6Zr8CO0Ak3A)Cv#@BVKr&aiKG7RYxY$Yx>Bj#3gJk*~Ps-jc1l z;4nltQwwT4@Z)}Pb!3xM?+EW0qEKA)sqzw~!C6wd^{03-9aGf3Jmt=}w-*!yXupLf z;)>-7uvWN4Unn8b4kfIza-X=x*e4n5pU`HtgpFFd))s$C@#d>aUl3helLom+RYb&g zI7A9GXLRZPl}iQS*d$Azxg-VgcUr*lpLnbPKUV{QI|bsG{8bLG<%CF( zMoS4pRDtLVYOWG^@ox^h8xL~afW_9DcE#^1eEC1SVSb1BfDi^@g?#f6e%v~Aw>@w- zIY0k+2lGWNV|aA*e#`U3=+oBDmGeInfcL)>*!w|*;mWiKNG6wP6AW4-4imN!W)!hE zA02~S1*@Q`fD*+qX@f3!2yJX&6FsEfPditB%TWo3=HA;T3o2IrjS@9SSxv%{{7&4_ zdS#r4OU41~GYMiib#z#O;zohNbhJknrPPZS6sN$%HB=jUnlCO_w5Gw5EeE@KV>soy z2EZ?Y|4RQDDjt5y!WBlZ(8M)|HP<0YyG|D%RqD+K#e7-##o3IZxS^wQ5{Kbzb6h(i z#(wZ|^ei>8`%ta*!2tJzwMv+IFHLF`zTU8E^Mu!R*45_=ccqI};Zbyxw@U%a#2}%f zF>q?SrUa_a4H9l+uW8JHh2Oob>NyUwG=QH~-^ZebU*R@67DcXdz2{HVB4#@edz?B< z5!rQH3O0>A&ylROO%G^fimV*LX7>!%re{_Sm6N>S{+GW1LCnGImHRoF@csnFzn@P0 zM=jld0z%oz;j=>c7mMwzq$B^2mae7NiG}%>(wtmsDXkWk{?BeMpTrIt3Mizq?vRsf zi_WjNp+61uV(%gEU-Vf0;>~vcDhe(dzWdaf#4mH3o^v{0EWhj?E?$5v02sV@xL0l4 zX0_IMFtQ44PfWBbPYN#}qxa%=J%dlR{O!KyZvk^g5s?sTNycWYPJ^FK(nl3k?z-5t z39#hKrdO7V(@!TU)LAPY&ngnZ1MzLEeEiZznn7e-jLCy8LO zu^7_#z*%I-BjS#Pg-;zKWWqX-+Ly$T!4`vTe5ZOV0j?TJVA*2?*=82^GVlZIuH%9s zXiV&(T(QGHHah=s&7e|6y?g+XxZGmK55`wGV>@1U)Th&=JTgJq>4mI&Av2C z)w+kRoj_dA!;SfTfkgMPO>7Dw6&1*Hi1q?54Yng`JO&q->^CX21^PrU^JU#CJ_qhV zSG>afB%>2fx<~g8p=P8Yzxqc}s@>>{g7}F!;lCXvF#RV)^fyYb_)iKVCz1xEq=fJ| z0a7DMCK*FuP=NM*5h;*D`R4y$6cpW-E&-i{v`x=Jbk_xSn@2T3q!3HoAOB`@5Vg6) z{PW|@9o!e;v1jZ2{=Uw6S6o{g82x6g=k!)cFSC*oemHaVjg?VpEmtUuD2_J^A~$4* z3O7HsbA6wxw{TP5Kk)(Vm?gKo+_}11vbo{Tp_5x79P~#F)ahQXT)tSH5;;14?s)On zel1J>1x>+7;g1Iz2FRpnYz;sD0wG9Q!vuzE9yKi3@4a9Nh1!GGN?hA)!mZEnnHh&i zf?#ZEN2sFbf~kV;>K3UNj1&vFhc^sxgj8FCL4v>EOYL?2uuT`0eDH}R zmtUJMxVrV5H{L53hu3#qaWLUa#5zY?f5ozIn|PkMWNP%n zWB5!B0LZB0kLw$k39=!akkE9Q>F4j+q434jB4VmslQ;$ zKiO#FZ`p|dKS716jpcvR{QJkSNfDVhr2%~eHrW;fU45>>snr*S8Vik-5eN5k*c2Mp zyxvX&_cFbB6lODXznHHT|rsURe2!swomtrqc~w5 zymTM8!w`1{04CBprR!_F{5LB+2_SOuZN{b*!J~1ZiPpP-M;);!ce!rOPDLtgR@Ie1 zPreuqm4!H)hYePcW1WZ0Fyaqe%l}F~Orr)~+;mkS&pOhP5Ebb`cnUt!X_QhP4_4p( z8YKQCDKGIy>?WIFm3-}Br2-N`T&FOi?t)$hjphB9wOhBXU#Hb+zm&We_-O)s(wc`2 z8?VsvU;J>Ju7n}uUb3s1yPx_F*|FlAi=Ge=-kN?1;`~6szP%$3B0|8Sqp%ebM)F8v zADFrbeT0cgE>M0DMV@_Ze*GHM>q}wWMzt|GYC%}r{OXRG3Ij&<+nx9;4jE${Fj_r* z`{z1AW_6Myd)i6e0E-h&m{{CvzH=Xg!&(bLYgRMO_YVd8JU7W+7MuGWNE=4@OvP9+ zxi^vqS@5%+#gf*Z@RVyU9N1sO-(rY$24LGsg1>w>s6ST^@)|D9>cT50maXLUD{Fzf zt~tp{OSTEKg3ZSQyQQ5r51){%=?xlZ54*t1;Ow)zLe3i?8tD8YyY^k%M)e`V*r+vL zPqUf&m)U+zxps+NprxMHF{QSxv}>lE{JZETNk1&F+R~bp{_T$dbXL2UGnB|hgh*p4h$clt#6;NO~>zuyY@C-MD@)JCc5XrYOt`wW7! z_ti2hhZBMJNbn0O-uTxl_b6Hm313^fG@e;RrhIUK9@# z+DHGv_Ow$%S8D%RB}`doJjJy*aOa5mGHVHz0e0>>O_%+^56?IkA5eN+L1BVCp4~m=1eeL zb;#G!#^5G%6Mw}r1KnaKsLvJB%HZL)!3OxT{k$Yo-XrJ?|7{s4!H+S2o?N|^Z z)+?IE9H7h~Vxn5hTis^3wHYuOU84+bWd)cUKuHapq=&}WV#OxHpLab`NpwHm8LmOo zjri+!k;7j_?FP##CpM+pOVx*0wExEex z@`#)K<-ZrGyArK;a%Km`^+We|eT+#MygHOT6lXBmz`8|lyZOwL1+b+?Z$0OhMEp3R z&J=iRERpv~TC=p2-BYLC*?4 zxvPs9V@g=JT0>zky5Poj=fW_M!c)Xxz1<=&_ZcL=LMZJqlnO1P^xwGGW*Z+yTBvbV z-IFe6;(k1@$1;tS>{%pXZ_7w+i?N4A2=TXnGf=YhePg8bH8M|Lk-->+w8Y+FjZ;L=wSGwxfA`gqSn)f(XNuSm>6Y z@|#e-)I(PQ^G@N`%|_DZSb4_pkaEF0!-nqY+t#pyA>{9^*I-zw4SYA1_z2Bs$XGUZbGA;VeMo%CezHK0lO={L%G)dI-+8w?r9iexdoB{?l zbJ}C?huIhWXBVs7oo{!$lOTlvCLZ_KN1N+XJGuG$rh<^eUQIqcI7^pmqhBSaOKNRq zrx~w^?9C?*&rNwP_SPYmo;J-#!G|{`$JZK7DxsM3N^8iR4vvn>E4MU&Oe1DKJvLc~ zCT>KLZ1;t@My zRj_2hI^61T&LIz)S!+AQIV23n1>ng+LUvzv;xu!4;wpqb#EZz;F)BLUzT;8UA1x*6vJ zicB!3Mj03s*kGV{g`fpC?V^s(=JG-k1EMHbkdP4P*1^8p_TqO|;!Zr%GuP$8KLxuf z=pv*H;kzd;P|2`JmBt~h6|GxdU~@weK5O=X&5~w$HpfO}@l-T7@vTCxVOwCkoPQv8 z@aV_)I5HQtfs7^X=C03zYmH4m0S!V@JINm6#(JmZRHBD?T!m^DdiZJrhKpBcur2u1 zf9e4%k$$vcFopK5!CC`;ww(CKL~}mlxK_Pv!cOsFgVkNIghA2Au@)t6;Y3*2gK=5d z?|@1a)-(sQ%uFOmJ7v2iG&l&m^u&^6DJM#XzCrF%r>{2XKyxLD2rgWBD;i(!e4InDQBDg==^z;AzT2z~OmV0!?Z z0S9pX$+E;w3WN;v&NYT=+G8hf=6w0E1$0AOr61}eOvE8W1jX%>&Mjo7&!ulawgzLH zbcb+IF(s^3aj12WSi#pzIpijJJzkP?JzRawnxmNDSUR#7!29vHULCE<3Aa#be}ie~d|!V+ z%l~s9Odo$G&fH!t!+`rUT0T9DulF!Yq&BfQWFZV1L9D($r4H(}Gnf6k3^wa7g5|Ws zj7%d`!3(0bb55yhC6@Q{?H|2os{_F%o=;-h{@Yyyn*V7?{s%Grvpe!H^kl6tF4Zf5 z{Jv1~yZ*iIWL_9C*8pBMQArfJJ0d9Df6Kl#wa}7Xa#Ef_5B7=X}DzbQXVPfCwTO@9+@;A^Ti6il_C>g?A-GFwA0#U;t4;wOm-4oS})h z5&on>NAu67O?YCQr%7XIzY%LS4bha9*e*4bU4{lGCUmO2UQ2U)QOqClLo61Kx~3dI zmV3*(P6F_Tr-oP%x!0kTnnT?Ep5j;_IQ^pTRp=e8dmJtI4YgWd0}+b2=ATkOhgpXe z;jmw+FBLE}UIs4!&HflFr4)vMFOJ19W4f2^W(=2)F%TAL)+=F>IE$=e=@j-*bFLSg z)wf|uFQu+!=N-UzSef62u0-C8Zc7 zo6@F)c+nZA{H|+~7i$DCU0pL{0Ye|fKLuV^w!0Y^tT$isu%i1Iw&N|tX3kwFKJN(M zXS`k9js66o$r)x?TWL}Kxl`wUDUpwFx(w4Yk%49;$sgVvT~n8AgfG~HUcDt1TRo^s zdla@6heJB@JV z!vK;BUMznhzGK6PVtj0)GB=zTv6)Q9Yt@l#fv7>wKovLobMV-+(8)NJmyF8R zcB|_K7=FJGGn^X@JdFaat0uhKjp3>k#^&xE_}6NYNG?kgTp>2Iu?ElUjt4~E-?`Du z?mDCS9wbuS%fU?5BU@Ijx>1HG*N?gIP+<~xE4u=>H`8o((cS5M6@_OK%jSjFHirQK zN9@~NXFx*jS{<|bgSpC|SAnA@I)+GB=2W|JJChLI_mx+-J(mSJ!b)uUom6nH0#2^(L@JBlV#t zLl?j54s`Y3vE^c_3^Hl0TGu*tw_n?@HyO@ZrENxA+^!)OvUX28gDSF*xFtQzM$A+O zCG=n#6~r|3zt=8%GuG} z<#VCZ%2?3Q(Ad#Y7GMJ~{U3>E{5e@z6+rgZLX{Cxk^p-7dip^d29;2N1_mm4QkASo z-L`GWWPCq$uCo;X_BmGIpJFBlhl<8~EG{vOD1o|X$aB9KPhWO_cKiU*$HWEgtf=fn zsO%9bp~D2c@?*K9jVN@_vhR03>M_8h!_~%aN!Cnr?s-!;U3SVfmhRwk11A^8Ns`@KeE}+ zN$H}a1U6E;*j5&~Og!xHdfK5M<~xka)x-0N)K_&e7AjMz`toDzasH+^1bZlC!n()crk9kg@$(Y{wdKvbuUd04N^8}t1iOgsKF zGa%%XWx@WoVaNC1!|&{5ZbkopFre-Lu(LCE5HWZBoE#W@er9W<>R=^oYxBvypN#x3 zq#LC8&q)GFP=5^-bpHj?LW=)-g+3_)Ylps!3^YQ{9~O9&K)xgy zMkCWaApU-MI~e^cV{Je75Qr7eF%&_H)BvfyKL=gIA>;OSq(y z052BFz3E(Prg~09>|_Z@!qj}@;8yxnw+#Ej0?Rk<y}4ghbD569B{9hSFr*^ygZ zr6j7P#gtZh6tMk6?4V$*Jgz+#&ug;yOr>=qdI#9U&^am2qoh4Jy}H2%a|#Fs{E(5r z%!ijh;VuGA6)W)cJZx+;9Bp1LMUzN~x_8lQ#D3+sL{be-Jyeo@@dv7XguJ&S5vrH` z>QxOMWn7N-T!D@1(@4>ZlL^y5>m#0!HKovs12GRav4z!>p(1~xok8+_{| z#Ae4{9#NLh#Vj2&JuIn5$d6t@__`o}umFo(n0QxUtd2GKCyE+erwXY?`cm*h&^9*8 zJ+8x6fRZI-e$CRygofIQN^dWysCxgkyr{(_oBwwSRxZora1(%(aC!5BTtj^+YuevI zx?)H#(xlALUp6QJ!=l9N__$cxBZ5p&7;qD3PsXRFVd<({Kh+mShFWJNpy`N@ab7?9 zv5=klvCJ4bx|-pvOO2-+G)6O?$&)ncA#Urze2rlBfp#htudhx-NeRnJ@u%^_bfw4o z4|{b8SkPV3b>Wera1W(+N@p9H>dc6{cnkh-sgr?e%(YkWvK+0YXVwk0=d`)}*47*B z5JGkEdVix!w7-<%r0JF~`ZMMPe;f0EQHuYHxya`puazyph*ZSb1mJAt^k4549BfS; zK7~T&lRb=W{s&t`DJ$B}s-eH1&&-wEOH1KWsKn0a(ZI+G!v&W4A*cl>qAvUv6pbUR z#(f#EKV8~hk&8oayBz4vaswc(?qw1vn`yC zZQDl2PCB-&Uu@g9ZQHhO+v(W0bNig{-k0;;`+wM@#@J)8r?qOYs#&vUna8ILxN7S{ zp1s41KnR8miQJtJtOr|+qk}wrLt+N*z#5o`TmD1)E&QD(Vh&pjZJ_J*0!8dy_ z>^=@v=J)C`x&gjqAYu`}t^S=DFCtc0MkBU2zf|69?xW`Ck~(6zLD)gSE{7n~6w8j_ zoH&~$ED2k5-yRa0!r8fMRy z;QjBYUaUnpd}mf%iVFPR%Dg9!d>g`01m~>2s))`W|5!kc+_&Y>wD@@C9%>-lE`WB0 zOIf%FVD^cj#2hCkFgi-fgzIfOi+ya)MZK@IZhHT5FVEaSbv-oDDs0W)pA0&^nM0TW zmgJmd7b1R7b0a`UwWJYZXp4AJPteYLH>@M|xZFKwm!t3D3&q~av?i)WvAKHE{RqpD{{%OhYkK?47}+}` zrR2(Iv9bhVa;cDzJ%6ntcSbx7v7J@Y4x&+eWSKZ*eR7_=CVIUSB$^lfYe@g+p|LD{ zPSpQmxx@b$%d!05|H}WzBT4_cq?@~dvy<7s&QWtieJ9)hd4)$SZz}#H2UTi$CkFWW|I)v_-NjuH!VypONC=1`A=rm_jfzQ8Fu~1r8i{q-+S_j$ z#u^t&Xnfi5tZtl@^!fUJhx@~Cg0*vXMK}D{>|$#T*+mj(J_@c{jXBF|rm4-8%Z2o! z2z0o(4%8KljCm^>6HDK!{jI7p+RAPcty_~GZ~R_+=+UzZ0qzOwD=;YeZt*?3%UGdr z`c|BPE;yUbnyARUl&XWSNJ<+uRt%!xPF&K;(l$^JcA_CMH6)FZt{>6ah$|(9$2fc~ z=CD00uHM{qv;{Zk9FR0~u|3|Eiqv9?z2#^GqylT5>6JNZwKqKBzzQpKU2_pmtD;CT zi%Ktau!Y2Tldfu&b0UgmF(SSBID)15*r08eoUe#bT_K-G4VecJL2Pa=6D1K6({zj6 za(2Z{r!FY5W^y{qZ}08+h9f>EKd&PN90f}Sc0ejf%kB4+f#T8Q1=Pj=~#pi$U zp#5rMR%W25>k?<$;$x72pkLibu1N|jX4cWjD3q^Pk3js!uK6h7!dlvw24crL|MZs_ zb%Y%?Fyp0bY0HkG^XyS76Ts*|Giw{31LR~+WU5NejqfPr73Rp!xQ1mLgq@mdWncLy z%8}|nzS4P&`^;zAR-&nm5f;D-%yNQPwq4N7&yULM8bkttkD)hVU>h>t47`{8?n2&4 zjEfL}UEagLUYwdx0sB2QXGeRmL?sZ%J!XM`$@ODc2!y|2#7hys=b$LrGbvvjx`Iqi z&RDDm3YBrlKhl`O@%%&rhLWZ*ABFz2nHu7k~3@e4)kO3%$=?GEFUcCF=6-1n!x^vmu+Ai*amgXH+Rknl6U>#9w;A} zn2xanZSDu`4%%x}+~FG{Wbi1jo@wqBc5(5Xl~d0KW(^Iu(U3>WB@-(&vn_PJt9{1`e9Iic@+{VPc`vP776L*viP{wYB2Iff8hB%E3|o zGMOu)tJX!`qJ}ZPzq7>=`*9TmETN7xwU;^AmFZ-ckZjV5B2T09pYliaqGFY|X#E-8 z20b>y?(r-Fn5*WZ-GsK}4WM>@TTqsxvSYWL6>18q8Q`~JO1{vLND2wg@58OaU!EvT z1|o+f1mVXz2EKAbL!Q=QWQKDZpV|jznuJ}@-)1&cdo z^&~b4Mx{*1gurlH;Vhk5g_cM&6LOHS2 zRkLfO#HabR1JD4Vc2t828dCUG#DL}f5QDSBg?o)IYYi@_xVwR2w_ntlpAW0NWk$F1 z$If?*lP&Ka1oWfl!)1c3fl`g*lMW3JOn#)R1+tfwrs`aiFUgz3;XIJ>{QFxLCkK30 zNS-)#DON3yb!7LBHQJ$)4y%TN82DC2-9tOIqzhZ27@WY^<6}vXCWcR5iN{LN8{0u9 zNXayqD=G|e?O^*ms*4P?G%o@J1tN9_76e}E#66mr89%W_&w4n66~R;X_vWD(oArwj z4CpY`)_mH2FvDuxgT+akffhX0b_slJJ*?Jn3O3~moqu2Fs1oL*>7m=oVek2bnprnW zixkaIFU%+3XhNA@@9hyhFwqsH2bM|`P?G>i<-gy>NflhrN{$9?LZ1ynSE_Mj0rADF zhOz4FnK}wpLmQuV zgO4_Oz9GBu_NN>cPLA=`SP^$gxAnj;WjJnBi%Q1zg`*^cG;Q)#3Gv@c^j6L{arv>- zAW%8WrSAVY1sj$=umcAf#ZgC8UGZGoamK}hR7j6}i8#np8ruUlvgQ$j+AQglFsQQq zOjyHf22pxh9+h#n$21&$h?2uq0>C9P?P=Juw0|;oE~c$H{#RGfa>| zj)Iv&uOnaf@foiBJ}_;zyPHcZt1U~nOcNB{)og8Btv+;f@PIT*xz$x!G?u0Di$lo7 zOugtQ$Wx|C($fyJTZE1JvR~i7LP{ zbdIwqYghQAJi9p}V&$=*2Azev$6K@pyblphgpv8^9bN!?V}{BkC!o#bl&AP!3DAjM zmWFsvn2fKWCfjcAQmE+=c3Y7j@#7|{;;0f~PIodmq*;W9Fiak|gil6$w3%b_Pr6K_ zJEG@&!J%DgBZJDCMn^7mk`JV0&l07Bt`1ymM|;a)MOWz*bh2#d{i?SDe9IcHs7 zjCrnyQ*Y5GzIt}>`bD91o#~5H?4_nckAgotN{2%!?wsSl|LVmJht$uhGa+HiH>;av z8c?mcMYM7;mvWr6noUR{)gE!=i7cZUY7e;HXa221KkRoc2UB>s$Y(k%NzTSEr>W(u z<(4mcc)4rB_&bPzX*1?*ra%VF}P1nwiP5cykJ&W{!OTlz&Td0pOkVp+wc z@k=-Hg=()hNg=Q!Ub%`BONH{ z_=ZFgetj@)NvppAK2>8r!KAgi>#%*7;O-o9MOOfQjV-n@BX6;Xw;I`%HBkk20v`qoVd0)}L6_49y1IhR z_OS}+eto}OPVRn*?UHC{eGyFU7JkPz!+gX4P>?h3QOwGS63fv4D1*no^6PveUeE5% zlehjv_3_^j^C({a2&RSoVlOn71D8WwMu9@Nb@=E_>1R*ve3`#TF(NA0?d9IR_tm=P zOP-x;gS*vtyE1Cm zG0L?2nRUFj#aLr-R1fX*$sXhad)~xdA*=hF3zPZhha<2O$Ps+F07w*3#MTe?)T8|A!P!v+a|ot{|^$q(TX`35O{WI0RbU zCj?hgOv=Z)xV?F`@HKI11IKtT^ocP78cqHU!YS@cHI@{fPD?YXL)?sD~9thOAv4JM|K8OlQhPXgnevF=F7GKD2#sZW*d za}ma31wLm81IZxX(W#A9mBvLZr|PoLnP>S4BhpK8{YV_}C|p<)4#yO{#ISbco92^3 zv&kCE(q9Wi;9%7>>PQ!zSkM%qqqLZW7O`VXvcj;WcJ`2~v?ZTYB@$Q&^CTfvy?1r^ z;Cdi+PTtmQwHX_7Kz?r#1>D zS5lWU(Mw_$B&`ZPmqxpIvK<~fbXq?x20k1~9az-Q!uR78mCgRj*eQ>zh3c$W}>^+w^dIr-u{@s30J=)1zF8?Wn|H`GS<=>Om|DjzC{}Jt?{!fSJe*@$H zg>wFnlT)k#T?LslW zu$^7Uy~$SQ21cE?3Ijl+bLfuH^U5P^$@~*UY#|_`uvAIe(+wD2eF}z_y!pvomuVO; zS^9fbdv)pcm-B@CW|Upm<7s|0+$@@<&*>$a{aW+oJ%f+VMO<#wa)7n|JL5egEgoBv zl$BY(NQjE0#*nv=!kMnp&{2Le#30b)Ql2e!VkPLK*+{jv77H7)xG7&=aPHL7LK9ER z5lfHxBI5O{-3S?GU4X6$yVk>lFn;ApnwZybdC-GAvaznGW-lScIls-P?Km2mF>%B2 zkcrXTk+__hj-3f48U%|jX9*|Ps41U_cd>2QW81Lz9}%`mTDIhE)jYI$q$ma7Y-`>% z8=u+Oftgcj%~TU}3nP8&h7k+}$D-CCgS~wtWvM|UU77r^pUw3YCV80Ou*+bH0!mf0 zxzUq4ed6y>oYFz7+l18PGGzhB^pqSt)si=9M>~0(Bx9*5r~W7sa#w+_1TSj3Jn9mW zMuG9BxN=}4645Cpa#SVKjFst;9UUY@O<|wpnZk$kE+to^4!?0@?Cwr3(>!NjYbu?x z1!U-?0_O?k!NdM^-rIQ8p)%?M+2xkhltt*|l=%z2WFJhme7*2xD~@zk#`dQR$6Lmd zb3LOD4fdt$Cq>?1<%&Y^wTWX=eHQ49Xl_lFUA(YQYHGHhd}@!VpYHHm=(1-O=yfK#kKe|2Xc*9}?BDFN zD7FJM-AjVi)T~OG)hpSWqH>vlb41V#^G2B_EvYlWhDB{Z;Q9-0)ja(O+By`31=biA zG&Fs#5!%_mHi|E4Nm$;vVQ!*>=_F;ZC=1DTPB#CICS5fL2T3XmzyHu?bI;m7D4@#; ztr~;dGYwb?m^VebuULtS4lkC_7>KCS)F@)0OdxZIFZp@FM_pHnJes8YOvwB|++#G( z&dm*OP^cz95Wi15vh`Q+yB>R{8zqEhz5of>Po$9LNE{xS<)lg2*roP*sQ}3r3t<}; zPbDl{lk{pox~2(XY5=qg0z!W-x^PJ`VVtz$git7?)!h>`91&&hESZy1KCJ2nS^yMH z!=Q$eTyRi68rKxdDsdt+%J_&lapa{ds^HV9Ngp^YDvtq&-Xp}60B_w@Ma>_1TTC;^ zpbe!#gH}#fFLkNo#|`jcn?5LeUYto%==XBk6Ik0kc4$6Z+L3x^4=M6OI1=z5u#M%0 z0E`kevJEpJjvvN>+g`?gtnbo$@p4VumliZV3Z%CfXXB&wPS^5C+7of2tyVkMwNWBiTE2 z8CdPu3i{*vR-I(NY5syRR}I1TJOV@DJy-Xmvxn^IInF>Tx2e)eE9jVSz69$6T`M9-&om!T+I znia!ZWJRB28o_srWlAxtz4VVft8)cYloIoVF=pL zugnk@vFLXQ_^7;%hn9x;Vq?lzg7%CQR^c#S)Oc-8d=q_!2ZVH764V z!wDKSgP}BrVV6SfCLZnYe-7f;igDs9t+K*rbMAKsp9L$Kh<6Z;e7;xxced zn=FGY<}CUz31a2G}$Q(`_r~75PzM4l_({Hg&b@d8&jC}B?2<+ed`f#qMEWi z`gm!STV9E4sLaQX+sp5Nu9*;9g12naf5?=P9p@H@f}dxYprH+3ju)uDFt^V{G0APn zS;16Dk{*fm6&BCg#2vo?7cbkkI4R`S9SSEJ=#KBk3rl69SxnCnS#{*$!^T9UUmO#&XXKjHKBqLdt^3yVvu8yn|{ zZ#%1CP)8t-PAz(+_g?xyq;C2<9<5Yy<~C74Iw(y>uUL$+$mp(DRcCWbCKiGCZw@?_ zdomfp+C5xt;j5L@VfhF*xvZdXwA5pcdsG>G<8II-|1dhAgzS&KArcb0BD4ZZ#WfiEY{hkCq5%z9@f|!EwTm;UEjKJsUo696V>h zy##eXYX}GUu%t{Gql8vVZKkNhQeQ4C%n|RmxL4ee5$cgwlU+?V7a?(jI#&3wid+Kz5+x^G!bb#$q>QpR#BZ}Xo5UW^ zD&I`;?(a}Oys7-`I^|AkN?{XLZNa{@27Dv^s4pGowuyhHuXc zuctKG2x0{WCvg_sGN^n9myJ}&FXyGmUQnW7fR$=bj$AHR88-q$D!*8MNB{YvTTEyS zn22f@WMdvg5~o_2wkjItJN@?mDZ9UUlat2zCh(zVE=dGi$rjXF7&}*sxac^%HFD`Y zTM5D3u5x**{bW!68DL1A!s&$2XG@ytB~dX-?BF9U@XZABO`a|LM1X3HWCllgl0+uL z04S*PX$%|^WAq%jkzp~%9HyYIF{Ym?k)j3nMwPZ=hlCg9!G+t>tf0o|J2%t1 ztC+`((dUplgm3`+0JN~}&FRRJ3?l*>Y&TfjS>!ShS`*MwO{WIbAZR#<%M|4c4^dY8 z{Rh;-!qhY=dz5JthbWoovLY~jNaw>%tS4gHVlt5epV8ekXm#==Po$)}mh^u*cE>q7*kvX&gq)(AHoItMYH6^s6f(deNw%}1=7O~bTHSj1rm2|Cq+3M z93djjdomWCTCYu!3Slx2bZVy#CWDozNedIHbqa|otsUl+ut?>a;}OqPfQA05Yim_2 zs@^BjPoFHOYNc6VbNaR5QZfSMh2S*`BGwcHMM(1@w{-4jVqE8Eu0Bi%d!E*^Rj?cR z7qgxkINXZR)K^=fh{pc0DCKtrydVbVILI>@Y0!Jm>x-xM!gu%dehm?cC6ok_msDVA*J#{75%4IZt}X|tIVPReZS#aCvuHkZxc zHVMtUhT(wp09+w9j9eRqz~LtuSNi2rQx_QgQ(}jBt7NqyT&ma61ldD(s9x%@q~PQl zp6N*?=N$BtvjQ_xIT{+vhb1>{pM0Arde0!X-y))A4znDrVx8yrP3B1(7bKPE5jR@5 zwpzwT4cu~_qUG#zYMZ_!2Tkl9zP>M%cy>9Y(@&VoB84#%>amTAH{(hL4cDYt!^{8L z645F>BWO6QaFJ-{C-i|-d%j7#&7)$X7pv#%9J6da#9FB5KyDhkA+~)G0^87!^}AP>XaCSScr;kL;Z%RSPD2CgoJ;gpYT5&6NUK$86$T?jRH=w8nI9Z534O?5fk{kd z`(-t$8W|#$3>xoMfXvV^-A(Q~$8SKDE^!T;J+rQXP71XZ(kCCbP%bAQ1|%$%Ov9_a zyC`QP3uPvFoBqr_+$HenHklqyIr>PU_Fk5$2C+0eYy^~7U&(!B&&P2%7#mBUhM!z> z_B$Ko?{Pf6?)gpYs~N*y%-3!1>o-4;@1Zz9VQHh)j5U1aL-Hyu@1d?X;jtDBNk*vMXPn@ z+u@wxHN*{uHR!*g*4Xo&w;5A+=Pf9w#PeZ^x@UD?iQ&${K2c}UQgLRik-rKM#Y5rdDphdcNTF~cCX&9ViRP}`>L)QA4zNXeG)KXFzSDa6 zd^St;inY6J_i=5mcGTx4_^Ys`M3l%Q==f>{8S1LEHn{y(kbxn5g1ezt4CELqy)~TV6{;VW>O9?5^ ztcoxHRa0jQY7>wwHWcxA-BCwzsP>63Kt&3fy*n#Cha687CQurXaRQnf5wc9o8v7Rw zNwGr2fac;Wr-Ldehn7tF^(-gPJwPt@VR1f;AmKgxN&YPL;j=0^xKM{!wuU|^mh3NE zy35quf}MeL!PU;|{OW_x$TBothLylT-J>_x6p}B_jW1L>k)ps6n%7Rh z96mPkJIM0QFNYUM2H}YF5bs%@Chs6#pEnloQhEl?J-)es!(SoJpEPoMTdgA14-#mC zghayD-DJWtUu`TD8?4mR)w5E`^EHbsz2EjH5aQLYRcF{l7_Q5?CEEvzDo(zjh|BKg z3aJl_n#j&eFHsUw4~lxqnr!6NL*se)6H=A+T1e3xUJGQrd}oSPwSy5+$tt{2t5J5@(lFxl43amsARG74iyNC}uuS zd2$=(r6RdamdGx^eatX@F2D8?U23tDpR+Os?0Gq2&^dF+$9wiWf?=mDWfjo4LfRwL zI#SRV9iSz>XCSgEj!cW&9H-njJopYiYuq|2w<5R2!nZ27DyvU4UDrHpoNQZiGPkp@ z1$h4H46Zn~eqdj$pWrv;*t!rTYTfZ1_bdkZmVVIRC21YeU$iS-*XMNK`#p8Z_DJx| zk3Jssf^XP7v0X?MWFO{rACltn$^~q(M9rMYoVxG$15N;nP)A98k^m3CJx8>6}NrUd@wp-E#$Q0uUDQT5GoiK_R{ z<{`g;8s>UFLpbga#DAf%qbfi`WN1J@6IA~R!YBT}qp%V-j!ybkR{uY0X|x)gmzE0J z&)=eHPjBxJvrZSOmt|)hC+kIMI;qgOnuL3mbNR0g^<%|>9x7>{}>a2qYSZAGPt4it?8 zNcLc!Gy0>$jaU?}ZWxK78hbhzE+etM`67*-*x4DN>1_&{@5t7_c*n(qz>&K{Y?10s zXsw2&nQev#SUSd|D8w7ZD2>E<%g^; zV{yE_O}gq?Q|zL|jdqB^zcx7vo(^})QW?QKacx$yR zhG|XH|8$vDZNIfuxr-sYFR{^csEI*IM#_gd;9*C+SysUFejP0{{z7@P?1+&_o6=7V|EJLQun^XEMS)w(=@eMi5&bbH*a0f;iC~2J74V2DZIlLUHD&>mlug5+v z6xBN~8-ovZylyH&gG#ptYsNlT?-tzOh%V#Y33zlsJ{AIju`CjIgf$@gr8}JugRq^c zAVQ3;&uGaVlVw}SUSWnTkH_6DISN&k2QLMBe9YU=sA+WiX@z)FoSYX`^k@B!j;ZeC zf&**P?HQG6Rk98hZ*ozn6iS-dG}V>jQhb3?4NJB*2F?6N7Nd;EOOo;xR7acylLaLy z9)^lykX39d@8@I~iEVar4jmjjLWhR0d=EB@%I;FZM$rykBNN~jf>#WbH4U{MqhhF6 zU??@fSO~4EbU4MaeQ_UXQcFyO*Rae|VAPLYMJEU`Q_Q_%s2*>$#S^)&7er+&`9L=1 z4q4ao07Z2Vsa%(nP!kJ590YmvrWg+YrgXYs_lv&B5EcoD`%uL79WyYA$0>>qi6ov7 z%`ia~J^_l{p39EY zv>>b}Qs8vxsu&WcXEt8B#FD%L%ZpcVtY!rqVTHe;$p9rbb5O{^rFMB>auLn-^;s+-&P1#h~mf~YLg$8M9 zZ4#87;e-Y6x6QO<{McUzhy(%*6| z)`D~A(TJ$>+0H+mct(jfgL4x%^oC^T#u(bL)`E2tBI#V1kSikAWmOOYrO~#-cc_8! zCe|@1&mN2{*ceeiBldHCdrURk4>V}79_*TVP3aCyV*5n@jiNbOm+~EQ_}1#->_tI@ zqXv+jj2#8xJtW508rzFrYcJxoek@iW6SR@1%a%Bux&;>25%`j3UI`0DaUr7l79`B1 zqqUARhW1^h6=)6?;@v>xrZNM;t}{yY3P@|L}ey@gG( z9r{}WoYN(9TW&dE2dEJIXkyHA4&pU6ki=rx&l2{DLGbVmg4%3Dlfvn!GB>EVaY_%3+Df{fBiqJV>~Xf8A0aqUjgpa} zoF8YXO&^_x*Ej}nw-$-F@(ddB>%RWoPUj?p8U{t0=n>gAI83y<9Ce@Q#3&(soJ{64 z37@Vij1}5fmzAuIUnXX`EYe;!H-yTVTmhAy;y8VZeB#vD{vw9~P#DiFiKQ|kWwGFZ z=jK;JX*A;Jr{#x?n8XUOLS;C%f|zj-7vXtlf_DtP7bpurBeX%Hjwr z4lI-2TdFpzkjgiv!8Vfv`=SP+s=^i3+N~1ELNWUbH|ytVu>EyPN_3(4TM^QE1swRo zoV7Y_g)a>28+hZG0e7g%@2^s>pzR4^fzR-El}ARTmtu!zjZLuX%>#OoU3}|rFjJg} zQ2TmaygxJ#sbHVyiA5KE+yH0LREWr%^C*yR|@gM$nK2P zo}M}PV0v))uJh&33N>#aU376@ZH79u(Yw`EQ2hM3SJs9f99+cO6_pNW$j$L-CtAfe zYfM)ccwD!P%LiBk!eCD?fHCGvgMQ%Q2oT_gmf?OY=A>&PaZQOq4eT=lwbaf}33LCH zFD|)lu{K7$8n9gX#w4~URjZxWm@wlH%oL#G|I~Fb-v^0L0TWu+`B+ZG!yII)w05DU z>GO?n(TN+B=>HdxVDSlIH76pta$_LhbBg;eZ`M7OGcqt||qi zogS72W1IN%=)5JCyOHWoFP7pOFK0L*OAh=i%&VW&4^LF@R;+K)t^S!96?}^+5QBIs zjJNTCh)?)4k^H^g1&jc>gysM`y^8Rm3qsvkr$9AeWwYpa$b22=yAd1t<*{ zaowSEFP+{y?Ob}8&cwfqoy4Pb9IA~VnM3u!trIK$&&0Op#Ql4j>(EW?UNUv#*iH1$ z^j>+W{afcd`{e&`-A{g}{JnIzYib)!T56IT@YEs{4|`sMpW3c8@UCoIJv`XsAw!XC z34|Il$LpW}CIHFC5e*)}00I5{%OL*WZRGzC0?_}-9{#ue?-ug^ zLE|uv-~6xnSs_2_&CN9{9vyc!Xgtn36_g^wI0C4s0s^;8+p?|mm;Odt3`2ZjwtK;l zfd6j)*Fr#53>C6Y8(N5?$H0ma;BCF3HCjUs7rpb2Kf*x3Xcj#O8mvs#&33i+McX zQpBxD8!O{5Y8D&0*QjD=Yhl9%M0)&_vk}bmN_Ud^BPN;H=U^bn&(csl-pkA+GyY0Z zKV7sU_4n;}uR78ouo8O%g*V;79KY?3d>k6%gpcmQsKk&@Vkw9yna_3asGt`0Hmj59 z%0yiF*`jXhByBI9QsD=+>big5{)BGe&+U2gAARGe3ID)xrid~QN_{I>k}@tzL!Md_ z&=7>TWciblF@EMC3t4-WX{?!m!G6$M$1S?NzF*2KHMP3Go4=#ZHkeIv{eEd;s-yD# z_jU^Ba06TZqvV|Yd;Z_sN%$X=!T+&?#p+OQIHS%!LO`Hx0q_Y0MyGYFNoM{W;&@0@ zLM^!X4KhdtsET5G<0+|q0oqVXMW~-7LW9Bg}=E$YtNh1#1D^6Mz(V9?2g~I1( zoz9Cz=8Hw98zVLwC2AQvp@pBeKyidn6Xu0-1SY1((^Hu*-!HxFUPs)yJ+i`^BC>PC zjwd0mygOVK#d2pRC9LxqGc6;Ui>f{YW9Bvb>33bp^NcnZoH~w9(lM5@JiIlfa-6|k ziy31UoMN%fvQfhi8^T+=yrP{QEyb-jK~>$A4SZT-N56NYEbpvO&yUme&pWKs3^94D zH{oXnUTb3T@H+RgzML*lejx`WAyw*?K7B-I(VJx($2!NXYm%3`=F~TbLv3H<{>D?A zJo-FDYdSA-(Y%;4KUP2SpHKAIcv9-ld(UEJE7=TKp|Gryn;72?0LHqAN^fk6%8PCW z{g_-t)G5uCIf0I`*F0ZNl)Z>))MaLMpXgqWgj-y;R+@A+AzDjsTqw2Mo9ULKA3c70 z!7SOkMtZb+MStH>9MnvNV0G;pwSW9HgP+`tg}e{ij0H6Zt5zJ7iw`hEnvye!XbA@!~#%vIkzowCOvq5I5@$3wtc*w2R$7!$*?}vg4;eDyJ_1=ixJuEp3pUS27W?qq(P^8$_lU!mRChT}ctvZz4p!X^ zOSp|JOAi~f?UkwH#9k{0smZ7-#=lK6X3OFEMl7%)WIcHb=#ZN$L=aD`#DZKOG4p4r zwlQ~XDZ`R-RbF&hZZhu3(67kggsM-F4Y_tI^PH8PMJRcs7NS9ogF+?bZB*fcpJ z=LTM4W=N9yepVvTj&Hu~0?*vR1HgtEvf8w%Q;U0^`2@e8{SwgX5d(cQ|1(!|i$km! zvY03MK}j`sff;*-%mN~ST>xU$6Bu?*Hm%l@0dk;j@%>}jsgDcQ)Hn*UfuThz9(ww_ zasV`rSrp_^bp-0sx>i35FzJwA!d6cZ5#5#nr@GcPEjNnFHIrtUYm1^Z$;{d&{hQV9 z6EfFHaIS}46p^5I-D_EcwwzUUuO}mqRh&T7r9sfw`)G^Q%oHxEs~+XoM?8e*{-&!7 z7$m$lg9t9KP9282eke608^Q2E%H-xm|oJ8=*SyEo} z@&;TQ3K)jgspgKHyGiKVMCz>xmC=H5Fy3!=TP)-R3|&1S-B)!6q50wfLHKM@7Bq6E z44CY%G;GY>tC`~yh!qv~YdXw! zSkquvYNs6k1r7>Eza?Vkkxo6XRS$W7EzL&A`o>=$HXgBp{L(i^$}t`NcnAxzbH8Ht z2!;`bhKIh`f1hIFcI5bHI=ueKdzmB9)!z$s-BT4ItyY|NaA_+o=jO%MU5as9 zc2)aLP>N%u>wlaXTK!p)r?+~)L+0eCGb5{8WIk7K52$nufnQ+m8YF+GQc&{^(zh-$ z#wyWV*Zh@d!b(WwXqvfhQX)^aoHTBkc;4ossV3&Ut*k>AI|m+{#kh4B!`3*<)EJVj zwrxK>99v^k4&Y&`Awm>|exo}NvewV%E+@vOc>5>%H#BK9uaE2$vje zWYM5fKuOTtn96B_2~~!xJPIcXF>E_;yO8AwpJ4)V`Hht#wbO3Ung~@c%%=FX4)q+9 z99#>VC2!4l`~0WHs9FI$Nz+abUq# zz`Of97})Su=^rGp2S$)7N3rQCj#0%2YO<R&p>$<#lgXcUj=4H_{oAYiT3 z44*xDn-$wEzRw7#@6aD)EGO$0{!C5Z^7#yl1o;k0PhN=aVUQu~eTQ^Xy{z8Ow6tk83 z4{5xe%(hx)%nD&|e*6sTWH`4W&U!Jae#U4TnICheJmsw{l|CH?UA{a6?2GNgpZLyzU2UlFu1ZVwlALmh_DOs03J^Cjh1im`E3?9&zvNmg(MuMw&0^Lu$(#CJ*q6DjlKsY-RMJ^8yIY|{SQZ*9~CH|u9L z`R78^r=EbbR*_>5?-)I+$6i}G)%mN(`!X72KaV(MNUP7Nv3MS9S|Pe!%N2AeOt5zG zVJ;jI4HZ$W->Ai_4X+`9c(~m=@ek*m`ZQbv3ryI-AD#AH=`x$~WeW~M{Js57(K7(v ze5`};LG|%C_tmd>bkufMWmAo&B+DT9ZV~h(4jg0>^aeAqL`PEUzJJtI8W1M!bQWpv zvN(d}E1@nlYa!L!!A*RN!(Q3F%J?5PvQ0udu?q-T)j3JKV~NL>KRb~w-lWc685uS6 z=S#aR&B8Sc8>cGJ!!--?kwsJTUUm`Jk?7`H z7PrO~xgBrSW2_tTlCq1LH8*!o?pj?qxy8}(=r_;G18POrFh#;buWR0qU24+XUaVZ0 z?(sXcr@-YqvkCmHr{U2oPogHL{r#3r49TeR<{SJX1pcUqyWPrkYz^X8#QW~?F)R5i z>p^!i<;qM8Nf{-fd6!_&V*e_9qP6q(s<--&1Ttj01j0w>bXY7y1W*%Auu&p|XSOH=)V7Bd4fUKh&T1)@cvqhuD-d=?w}O zjI%i(f|thk0Go*!d7D%0^ztBfE*V=(ZIN84f5HU}T9?ulmEYzT5usi=DeuI*d|;M~ zp_=Cx^!4k#=m_qSPBr5EK~E?3J{dWWPH&oCcNepYVqL?nh4D5ynfWip$m*YlZ8r^Z zuFEUL-nW!3qjRCLIWPT0x)FDL7>Yt7@8dA?R2kF@WE>ysMY+)lTsgNM#3VbXVGL}F z1O(>q>2a+_`6r5Xv$NZAnp=Kgnr3)cL(^=8ypEeOf3q8(HGe@7Tt59;yFl||w|mnO zHDxg2G3z8=(6wjj9kbcEY@Z0iOd7Gq5GiPS5% z*sF1J<#daxDV2Z8H>wxOF<;yKzMeTaSOp_|XkS9Sfn6Mpe9UBi1cSTieGG5$O;ZLIIJ60Y>SN4vC?=yE_CWlo(EEE$e4j?z&^FM%kNmRtlbEL^dPPgvs9sbK5fGw*r@ z+!EU@u$T8!nZh?Fdf_qk$VuHk^yVw`h`_#KoS*N%epIIOfQUy_&V}VWDGp3tplMbf z5Se1sJUC$7N0F1-9jdV2mmGK{-}fu|Nv;12jDy0<-kf^AmkDnu6j~TPWOgy1MT68|D z=4=50jVbUKdKaQgD`eWGr3I&^<6uhkjz$YwItY8%Yp9{z4-{6g{73<_b*@XJ4Nm3-3z z?BW3{aY_ccRjb@W1)i5nLg|7BnWS!B`_Uo9CWaE`Ij327QH?i)9A}4Ug4wmxVVa^b z-4+m%-wwOl7cKH7+=x&nrCrbEC)Q$fpg&V83#uEH;C=GNMz`ps@^RxK%T*8%OPnC` z{WO~J%nxYJ`x|N%?&i7?;{_8t^jM&=50HlaOQj8fS}_`moH$c;vI<|cruPFnpT8yU zS%rPOCUSd5Zdb(zwk`hqwTQn)*&n)uYsP*F_(~xEWq}C= zv30kFmZFwJZ@ELVX3?$dXQh|icO7UrL*_5G=I^xXjImz`ZPp>?g#tf(ej~KaIU0algsG!IS09;>?MvqGg#c{i+}qY|{P8W~O%#>|gFd z<1dr$-oxyRGN17yZo1OwLnzwYs0|;IS_nymNB0IlSzPQ%-r`?T=;_XQ^~&#}b|AB} zkNbN5uB?-sUB-T5QLlg%Uk3)uHB;>VIzGe9_J9 zaeISkQm!v(9d(0ML^b9fR^sfHFlH?7Mvddt37OuR{|O0{uv)(&-6<87W4 zyO>s!=cPgP3O&7xxU5DlIPw_o3O>6o6Qb?JWs3qw#p3sBc3g$?Dx zi(6D+DYgV;GrUis-CL%Qe{nvZnwaVXmbhH(|GFh|Q)k=1uvA$I@1DXI7bKlQ@8D6P zS?(*?><>)G49q0wr;NajpxP4W2G)kHl6^=Z>hrNEI4Mwd_$O6$1dXF;Q#hE(-eeW6 zz03GJF%Wl?HO=_ztv5*zRlcU~{+{k%#N59mgm~eK>P!QZ6E?#Cu^2)+K8m@ySvZ*5 z|HDT}BkF@3!l(0%75G=1u2hETXEj!^1Z$!)!lyGXlWD!_vqGE$Z)#cUVBqlORW>0^ zDjyVTxwKHKG|0}j-`;!R-p>}qQfBl(?($7pP<+Y8QE#M8SCDq~k<+>Q^Zf@cT_WdX3~BSe z+|KK|7OL5Hm5(NFP~j>Ct3*$wi0n0!xl=(C61`q&cec@mFlH(sy%+RH<=s)8aAPN`SfJdkAQjdv82G5iRdv8 zh{9wHUZaniSEpslXl^_ODh}mypC?b*9FzLjb~H@3DFSe;D(A-K3t3eOTB(m~I6C;(-lKAvit(70k`%@+O*Ztdz;}|_TS~B?Tpmi=QKC^m_ z2YpEaT3iiz*;T~ap1yiA)a`dKMwu`^UhIUeltNQ1Yjo=q@bI@&3zH?rVUg=IxLy-ni zyxDu%-Fr{H6owTjZU2O5>nDb=q&Jz_TjeSq%!2m40x&U6w~GQ({quPL73IsJS;f`$ zsuhioqCBj(gJ>2hoo)Gou7(WP*pX)f=Y=!=k!&1K?EYY%jJ~X&DnK{^saPQK<1BJ z_A`_{%ZozcB(3w$z^To^6d|XuT@=X~wtW!+{4ID@N{AB~J6AL5vuY>JwvWCNFKsKh zd}@>q@_WV#QZ&UJ0#?X(pXR!oyXOEG3rqzHbCzGLONDb042i$})fM@XF)uSP(DHUc z^&{|$*xe{cs?Gp8=B%RY3L7#$ve$?TWh>MZdxF1zH1v}1z+$Ov#G7?%D)bBCyDe*% zSeKSpETC2V1){II>@UwJi>4uBN+iAx+82E~gb|Cr&8E^i&)A!uv-g?jzH99wU}8+# z$nh>yvb;TwZmS@7LrvuCu_d0-WxFNI&C7%sWuTL%YU!l|I1{|->=dlOeHOCtUO#zkS3ESO8LHV4hTdQL5EdV zuWD33fFPH}HPrW^s$Qn1Xgp&AT6<-He{{4%eIu3rN=iK|9mURdKXfB&Q?qGok%!cs ze53UP{Z!TO-Y@q2;;k2avA3`lm4OoN4@S*k=UA)7H;qZ`d8`XaYFCv?Ba+uGW@r5v z&&{nf(24WSBOhc7!qF^@0cz;XcUynNaj6w2349;s!K{KVqs5yS{ z7VubS`2OzT^5#1~6Tt^RTvt9-J|D2F>y~>2;jeF>g`hx5l%B3H=aLExQihuYngzlnBTYOTHJQMzl>kwqN5JYs)Ej zblA@ntkUS~xi+}y6|(81helS}Q~&VB37qyV|S3Y=><^1wh%msQM?fz z<58MX(=|PSUKCF#)dbhR%D&xgCD?$aR0qen+wpp6 zst}vX18!Be96TD??j1HsHTUx(a&@F?=gT`Q$oJFFyrh^;zgz!(NlAHGn0cJy@us=w zNhC#l5G;H}+>49Nsh12=ZPO2r*2OBQe5kpb&1?*PIBFitK8}FUfb~S-#hKfF0o#&d z#3aPkB$9scYku&kA6{0xHnBV#&Wei5J>5T-XX-gUXEPo+9b7WL=*XESc(3BshL`aj zXp}QIp*40}oWJt*l043e8_5;H5PI5c)U&IEw5dF(4zjX0y_lk9 zAp@!mK>WUqHo)-jop=DoK>&no>kAD=^qIE7qis&_*4~ z6q^EF$D@R~3_xseCG>Ikb6Gfofb$g|75PPyyZN&tiRxqovo_k zO|HA|sgy#B<32gyU9x^&)H$1jvw@qp+1b(eGAb)O%O!&pyX@^nQd^9BQ4{(F8<}|A zhF&)xusQhtoXOOhic=8#Xtt5&slLia3c*a?dIeczyTbC#>FTfiLST57nc3@Y#v_Eg#VUv zT8cKH#f3=1PNj!Oroz_MAR*pow%Y0*6YCYmUy^7`^r|j23Q~^*TW#cU7CHf0eAD_0 zEWEVddxFgQ7=!nEBQ|ibaScslvhuUk^*%b#QUNrEB{3PG@uTxNwW}Bs4$nS9wc(~O zG7Iq>aMsYkcr!9#A;HNsJrwTDYkK8ikdj{M;N$sN6BqJ<8~z>T20{J8Z2rRUuH7~3 z=tgS`AgxbBOMg87UT4Lwge`*Y=01Dvk>)^{Iu+n6fuVX4%}>?3czOGR$0 zpp*wp>bsFFSV`V;r_m+TZns$ZprIi`OUMhe^cLE$2O+pP3nP!YB$ry}2THx2QJs3< za1;>d-AggCarrQ>&Z!d@;mW+!q6eXhb&`GbzUDSxpl8AJ#Cm#tuc)_xh(2NV=5XMs zrf_ozRYO$NkC=pKFX5OH8v1>0i9Z$ec`~Mf+_jQ68spn(CJwclDhEEkH2Qw;${J$clv__nUjn5jA0wCLEnu1j;v!0vB>Ri6m9`;R{JMS%^)4FC zU0Z44+u$I$w=Bj|iu4DT5h~sS`C*zbmX?@-crY}E+hy>}2~C0Nn(EKk@5^qO4@l@! z6O0lr%tzGC`D^)8xU3FnMZVm0kX1sBWhaQyzVoXFWwr%Ny?=2M{5s#5i7fTu3gEkG zc{(Pr$v=;`Y#&`y*J}#M9ux>0?xu!`$9cUKm#Bdd_&S#LPTS?ZPV6zN6>W6JTS~-LfjL{mB=b(KMk3 z2HjBSlJeyUVqDd=Mt!=hpYsvby2GL&3~zm;0{^nZJq+4vb?5HH4wufvr}IX42sHeK zm@x?HN$8TsTavXs)tLDFJtY9b)y~Tl@7z4^I8oUQq4JckH@~CVQ;FoK(+e0XAM>1O z(ei}h?)JQp>)d=6ng-BZF1Z5hsAKW@mXq+hU?r8I(*%`tnIIOXw7V6ZK(T9RFJJe@ zZS!aC+p)Gf2Ujc=a6hx4!A1Th%YH!Lb^xpI!Eu` zmJO{9rw){B1Ql18d%F%da+Tbu1()?o(zT7StYqK6_w`e+fjXq5L^y(0 z09QA6H4oFj59c2wR~{~>jUoDzDdKz}5#onYPJRwa`SUO)Pd4)?(ENBaFVLJr6Kvz= zhTtXqbx09C1z~~iZt;g^9_2nCZ{};-b4dQJbv8HsWHXPVg^@(*!@xycp#R?a|L!+` zY5w))JWV`Gls(=}shH0#r*;~>_+-P5Qc978+QUd>J%`fyn{*TsiG-dWMiJXNgwBaT zJ=wgYFt+1ACW)XwtNx)Q9tA2LPoB&DkL16P)ERWQlY4%Y`-5aM9mZ{eKPUgI!~J3Z zkMd5A_p&v?V-o-6TUa8BndiX?ooviev(DKw=*bBVOW|=zps9=Yl|-R5@yJe*BPzN}a0mUsLn{4LfjB_oxpv(mwq# zSY*%E{iB)sNvWfzg-B!R!|+x(Q|b@>{-~cFvdDHA{F2sFGA5QGiIWy#3?P2JIpPKg6ncI^)dvqe`_|N=8*=4<|!MJu@}isLc4AW#{m2if&A5T5g&~ ziuMQeS*U5sL6J698wOd)K@oK@1{peP5&Esut<#VH^u)gp`9H4)`uE!2$>RTctN+^u z=ASkePDZA-X8)rp%D;p*~P?*a_=*Kwc<^>QSH|^<0>o37lt^+Mj1;4YvJ(JR-Y+?%Nu}JAYj5 z_Qc5%Ao#F?q32i?ZaN2OSNhWL;2oDEw_({7ZbgUjna!Fqn3NzLM@-EWFPZVmc>(fZ z0&bF-Ch#p9C{YJT9Rcr3+Y_uR^At1^BxZ#eo>$PLJF3=;t_$2|t+_6gg5(j{TmjYU zK12c&lE?Eh+2u2&6Gf*IdKS&6?rYbSEKBN!rv{YCm|Rt=UlPcW9j`0o6{66#y5t9C zruFA2iKd=H%jHf%ypOkxLnO8#H}#Zt{8p!oi6)7#NqoF({t6|J^?1e*oxqng9Q2Cc zg%5Vu!em)}Yuj?kaP!D?b?(C*w!1;>R=j90+RTkyEXz+9CufZ$C^umX^+4|JYaO<5 zmIM3#dv`DGM;@F6;(t!WngZSYzHx?9&$xEF70D1BvfVj<%+b#)vz)2iLCrTeYzUcL z(OBnNoG6Le%M+@2oo)&jdOg=iCszzv59e zDRCeaX8l1hC=8LbBt|k5?CXgep=3r9BXx1uR8!p%Z|0+4Xro=xi0G!e{c4U~1j6!) zH6adq0}#l{%*1U(Cb%4AJ}VLWKBPi0MoKFaQH6x?^hQ!6em@993xdtS%_dmevzeNl z(o?YlOI=jl(`L9^ z0O+H9k$_@`6L13eTT8ci-V0ljDMD|0ifUw|Q-Hep$xYj0hTO@0%IS^TD4b4n6EKDG z??uM;MEx`s98KYN(K0>c!C3HZdZ{+_53DO%9k5W%pr6yJusQAv_;IA}925Y%;+!tY z%2k!YQmLLOr{rF~!s<3-WEUs)`ix_mSU|cNRBIWxOox_Yb7Z=~Q45ZNe*u|m^|)d* zog=i>`=bTe!|;8F+#H>EjIMcgWcG2ORD`w0WD;YZAy5#s{65~qfI6o$+Ty&-hyMyJ z3Ra~t>R!p=5ZpxA;QkDAoPi4sYOP6>LT+}{xp}tk+<0k^CKCFdNYG(Es>p0gqD)jP zWOeX5G;9(m@?GOG7g;e74i_|SmE?`B2i;sLYwRWKLy0RLW!Hx`=!LH3&k=FuCsM=9M4|GqzA)anEHfxkB z?2iK-u(DC_T1};KaUT@3nP~LEcENT^UgPvp!QC@Dw&PVAhaEYrPey{nkcn(ro|r7XUz z%#(=$7D8uP_uU-oPHhd>>^adbCSQetgSG`e$U|7mr!`|bU0aHl_cmL)na-5x1#OsVE#m*+k84Y^+UMeSAa zbrVZHU=mFwXEaGHtXQq`2ZtjfS!B2H{5A<3(nb-6ARVV8kEmOkx6D2x7~-6hl;*-*}2Xz;J#a8Wn;_B5=m zl3dY;%krf?i-Ok^Pal-}4F`{F@TYPTwTEhxpZK5WCpfD^UmM_iYPe}wpE!Djai6_{ z*pGO=WB47#Xjb7!n2Ma)s^yeR*1rTxp`Mt4sfA+`HwZf%!7ZqGosPkw69`Ix5Ku6G z@Pa;pjzV&dn{M=QDx89t?p?d9gna*}jBly*#1!6}5K<*xDPJ{wv4& zM$17DFd~L*Te3A%yD;Dp9UGWTjRxAvMu!j^Tbc}2v~q^59d4bz zvu#!IJCy(BcWTc`;v$9tH;J%oiSJ_i7s;2`JXZF+qd4C)vY!hyCtl)sJIC{ebI*0> z@x>;EzyBv>AI-~{D6l6{ST=em*U( z(r$nuXY-#CCi^8Z2#v#UXOt`dbYN1z5jzNF2 z411?w)whZrfA20;nl&C1Gi+gk<`JSm+{|*2o<< zqM#@z_D`Cn|0H^9$|Tah)0M_X4c37|KQ*PmoT@%xHc3L1ZY6(p(sNXHa&49Frzto& zR`c~ClHpE~4Z=uKa5S(-?M8EJ$zt0&fJk~p$M#fGN1-y$7!37hld`Uw>Urri(DxLa;=#rK0g4J)pXMC zxzraOVw1+kNWpi#P=6(qxf`zSdUC?D$i`8ZI@F>k6k zz21?d+dw7b&i*>Kv5L(LH-?J%@WnqT7j#qZ9B>|Zl+=> z^U-pV@1y_ptHo4hl^cPRWewbLQ#g6XYQ@EkiP z;(=SU!yhjHp%1&MsU`FV1Z_#K1&(|5n(7IHbx&gG28HNT)*~-BQi372@|->2Aw5It z0CBpUcMA*QvsPy)#lr!lIdCi@1k4V2m!NH)%Px(vu-r(Q)HYc!p zJ^$|)j^E#q#QOgcb^pd74^JUi7fUmMiNP_o*lvx*q%_odv49Dsv$NV;6J z9GOXKomA{2Pb{w}&+yHtH?IkJJu~}Z?{Uk++2mB8zyvh*xhHKE``99>y#TdD z&(MH^^JHf;g(Tbb^&8P*;_i*2&fS$7${3WJtV7K&&(MBV2~)2KB3%cWg#1!VE~k#C z!;A;?p$s{ihyojEZz+$I1)L}&G~ml=udD9qh>Tu(ylv)?YcJT3ihapi!zgPtWb*CP zlLLJSRCj-^w?@;RU9aL2zDZY1`I3d<&OMuW=c3$o0#STpv_p3b9Wtbql>w^bBi~u4 z3D8KyF?YE?=HcKk!xcp@Cigvzy=lnFgc^9c%(^F22BWYNAYRSho@~*~S)4%AhEttv zvq>7X!!EWKG?mOd9&n>vvH1p4VzE?HCuxT-u+F&mnsfDI^}*-d00-KAauEaXqg3k@ zy#)MGX!X;&3&0s}F3q40ZmVM$(H3CLfpdL?hB6nVqMxX)q=1b}o_PG%r~hZ4gUfSp zOH4qlEOW4OMUc)_m)fMR_rl^pCfXc{$fQbI*E&mV77}kRF z&{<06AJyJ!e863o-V>FA1a9Eemx6>^F$~9ppt()ZbPGfg_NdRXBWoZnDy2;#ODgf! zgl?iOcF7Meo|{AF>KDwTgYrJLb$L2%%BEtO>T$C?|9bAB&}s;gI?lY#^tttY&hfr# zKhC+&b-rpg_?~uVK%S@mQleU#_xCsvIPK*<`E0fHE1&!J7!xD#IB|SSPW6-PyuqGn3^M^Rz%WT{e?OI^svARX&SAdU77V(C~ zM$H{Kg59op{<|8ry9ecfP%=kFm(-!W&?U0@<%z*+!*<e0XesMxRFu9QnGqun6R_%T+B%&9Dtk?*d$Q zb~>84jEAPi@&F@3wAa^Lzc(AJz5gsfZ7J53;@D<;Klpl?sK&u@gie`~vTsbOE~Cd4 z%kr56mI|#b(Jk&;p6plVwmNB0H@0SmgdmjIn5Ne@)}7Vty(yb2t3ev@22AE^s!KaN zyQ>j+F3w=wnx7w@FVCRe+`vUH)3gW%_72fxzqX!S&!dchdkRiHbXW1FMrIIBwjsai8`CB2r4mAbwp%rrO>3B$Zw;9=%fXI9B{d(UzVap7u z6piC-FQ)>}VOEuPpuqznpY`hN4dGa_1Xz9rVg(;H$5Te^F0dDv*gz9JS<|>>U0J^# z6)(4ICh+N_Q`Ft0hF|3fSHs*?a=XC;e`sJaU9&d>X4l?1W=|fr!5ShD|nv$GK;j46@BV6+{oRbWfqOBRb!ir88XD*SbC(LF}I1h#6@dvK%Toe%@ zhDyG$93H8Eu&gCYddP58iF3oQH*zLbNI;rN@E{T9%A8!=v#JLxKyUe}e}BJpB{~uN zqgxRgo0*-@-iaHPV8bTOH(rS(huwK1Xg0u+e!`(Irzu@Bld&s5&bWgVc@m7;JgELd zimVs`>vQ}B_1(2#rv#N9O`fJpVfPc7V2nv34PC);Dzbb;p!6pqHzvy?2pD&1NE)?A zt(t-ucqy@wn9`^MN5apa7K|L=9>ISC>xoc#>{@e}m#YAAa1*8-RUMKwbm|;5p>T`Z zNf*ph@tnF{gmDa3uwwN(g=`Rh)4!&)^oOy@VJaK4lMT&5#YbXkl`q?<*XtsqD z9PRK6bqb)fJw0g-^a@nu`^?71k|m3RPRjt;pIkCo1{*pdqbVs-Yl>4E>3fZx3Sv44grW=*qdSoiZ9?X0wWyO4`yDHh2E!9I!ZFi zVL8|VtW38}BOJHW(Ax#KL_KQzarbuE{(%TA)AY)@tY4%A%P%SqIU~8~-Lp3qY;U-} z`h_Gel7;K1h}7$_5ZZT0&%$Lxxr-<89V&&TCsu}LL#!xpQ1O31jaa{U34~^le*Y%L za?7$>Jk^k^pS^_M&cDs}NgXlR>16AHkSK-4TRaJSh#h&p!-!vQY%f+bmn6x`4fwTp z$727L^y`~!exvmE^W&#@uY!NxJi`g!i#(++!)?iJ(1)2Wk;RN zFK&O4eTkP$Xn~4bB|q8y(btx$R#D`O@epi4ofcETrx!IM(kWNEe42Qh(8*KqfP(c0 zouBl6>Fc_zM+V;F3znbo{x#%!?mH3`_ANJ?y7ppxS@glg#S9^MXu|FM&ynpz3o&Qh z2ujAHLF3($pH}0jXQsa#?t--TnF1P73b?4`KeJ9^qK-USHE)4!IYgMn-7z|=ALF5SNGkrtPG@Y~niUQV2?g$vzJN3nZ{7;HZHzWAeQ;5P|@Tl3YHpyznGG4-f4=XflwSJY+58-+wf?~Fg@1p1wkzuu-RF3j2JX37SQUc? zQ4v%`V8z9ZVZVqS8h|@@RpD?n0W<=hk=3Cf8R?d^9YK&e9ZybFY%jdnA)PeHvtBe- zhMLD+SSteHBq*q)d6x{)s1UrsO!byyLS$58WK;sqip$Mk{l)Y(_6hEIBsIjCr5t>( z7CdKUrJTrW%qZ#1z^n*Lb8#VdfzPw~OIL76aC+Rhr<~;4Tl!sw?Rj6hXj4XWa#6Tp z@)kJ~qOV)^Rh*-?aG>ic2*NlC2M7&LUzc9RT6WM%Cpe78`iAowe!>(T0jo&ivn8-7 zs{Qa@cGy$rE-3AY0V(l8wjI^uB8Lchj@?L}fYal^>T9z;8juH@?rG&g-t+R2dVDBe zq!K%{e-rT5jX19`(bP23LUN4+_zh2KD~EAYzhpEO3MUG8@}uBHH@4J zd`>_(K4q&>*k82(dDuC)X6JuPrBBubOg7qZ{?x!r@{%0);*`h*^F|%o?&1wX?Wr4b z1~&cy#PUuES{C#xJ84!z<1tp9sfrR(i%Tu^jnXy;4`Xk;AQCdFC@?V%|; zySdC7qS|uQRcH}EFZH%mMB~7gi}a0utE}ZE_}8PQH8f;H%PN41Cb9R%w5Oi5el^fd z$n{3SqLCnrF##x?4sa^r!O$7NX!}&}V;0ZGQ&K&i%6$3C_dR%I7%gdQ;KT6YZiQrW zk%q<74oVBV>@}CvJ4Wj!d^?#Zwq(b$E1ze4$99DuNg?6t9H}k_|D7KWD7i0-g*EO7 z;5{hSIYE4DMOK3H%|f5Edx+S0VI0Yw!tsaRS2&Il2)ea^8R5TG72BrJue|f_{2UHa z@w;^c|K3da#$TB0P3;MPlF7RuQeXT$ zS<<|C0OF(k)>fr&wOB=gP8!Qm>F41u;3esv7_0l%QHt(~+n; zf!G6%hp;Gfa9L9=AceiZs~tK+Tf*Wof=4!u{nIO90jH@iS0l+#%8=~%ASzFv7zqSB^?!@N7)kp0t&tCGLmzXSRMRyxCmCYUD2!B`? zhs$4%KO~m=VFk3Buv9osha{v+mAEq=ik3RdK@;WWTV_g&-$U4IM{1IhGX{pAu%Z&H zFfwCpUsX%RKg);B@7OUzZ{Hn{q6Vv!3#8fAg!P$IEx<0vAx;GU%}0{VIsmFBPq_mb zpe^BChDK>sc-WLKl<6 zwbW|e&d&dv9Wu0goueyu>(JyPx1mz0v4E?cJjFuKF71Q1)AL8jHO$!fYT3(;U3Re* zPPOe%*O+@JYt1bW`!W_1!mN&=w3G9ru1XsmwfS~BJ))PhD(+_J_^N6j)sx5VwbWK| zwRyC?W<`pOCY)b#AS?rluxuuGf-AJ=D!M36l{ua?@SJ5>e!IBr3CXIxWw5xUZ@Xrw z_R@%?{>d%Ld4p}nEsiA@v*nc6Ah!MUs?GA7e5Q5lPpp0@`%5xY$C;{%rz24$;vR#* zBP=a{)K#CwIY%p} zXVdxTQ^HS@O&~eIftU+Qt^~(DGxrdi3k}DdT^I7Iy5SMOp$QuD8s;+93YQ!OY{eB24%xY7ml@|M7I(Nb@K_-?F;2?et|CKkuZK_>+>Lvg!>JE~wN`BI|_h6$qi!P)+K-1Hh(1;a`os z55)4Q{oJiA(lQM#;w#Ta%T0jDNXIPM_bgESMCDEg6rM33anEr}=|Fn6)|jBP6Y}u{ zv9@%7*#RI9;fv;Yii5CI+KrRdr0DKh=L>)eO4q$1zmcSmglsV`*N(x=&Wx`*v!!hn6X-l0 zP_m;X??O(skcj+oS$cIdKhfT%ABAzz3w^la-Ucw?yBPEC+=Pe_vU8nd-HV5YX6X8r zZih&j^eLU=%*;VzhUyoLF;#8QsEfmByk+Y~caBqSvQaaWf2a{JKB9B>V&r?l^rXaC z8)6AdR@Qy_BxQrE2Fk?ewD!SwLuMj@&d_n5RZFf7=>O>hzVE*seW3U?_p|R^CfoY`?|#x9)-*yjv#lo&zP=uI`M?J zbzC<^3x7GfXA4{FZ72{PE*-mNHyy59Q;kYG@BB~NhTd6pm2Oj=_ zizmD?MKVRkT^KmXuhsk?eRQllPo2Ubk=uCKiZ&u3Xjj~<(!M94c)Tez@9M1Gfs5JV z->@II)CDJOXTtPrQudNjE}Eltbjq>6KiwAwqvAKd^|g!exgLG3;wP+#mZYr`cy3#39e653d=jrR-ulW|h#ddHu(m9mFoW~2yE zz5?dB%6vF}+`-&-W8vy^OCxm3_{02royjvmwjlp+eQDzFVEUiyO#gLv%QdDSI#3W* z?3!lL8clTaNo-DVJw@ynq?q!%6hTQi35&^>P85G$TqNt78%9_sSJt2RThO|JzM$iL zg|wjxdMC2|Icc5rX*qPL(coL!u>-xxz-rFiC!6hD1IR%|HSRsV3>Kq~&vJ=s3M5y8SG%YBQ|{^l#LGlg!D?E>2yR*eV%9m$_J6VGQ~AIh&P$_aFbh zULr0Z$QE!QpkP=aAeR4ny<#3Fwyw@rZf4?Ewq`;mCVv}xaz+3ni+}a=k~P+yaWt^L z@w67!DqVf7D%7XtXX5xBW;Co|HvQ8WR1k?r2cZD%U;2$bsM%u8{JUJ5Z0k= zZJARv^vFkmWx15CB=rb=D4${+#DVqy5$C%bf`!T0+epLJLnh1jwCdb*zuCL}eEFvE z{rO1%gxg>1!W(I!owu*mJZ0@6FM(?C+d*CeceZRW_4id*D9p5nzMY&{mWqrJomjIZ z97ZNnZ3_%Hx8dn;H>p8m7F#^2;T%yZ3H;a&N7tm=Lvs&lgJLW{V1@h&6Vy~!+Ffbb zv(n3+v)_D$}dqd!2>Y2B)#<+o}LH#%ogGi2-?xRIH)1!SD)u-L65B&bsJTC=LiaF+YOCif2dUX6uAA|#+vNR z>U+KQekVGon)Yi<93(d!(yw1h3&X0N(PxN2{%vn}cnV?rYw z$N^}_o!XUB!mckL`yO1rnUaI4wrOeQ(+&k?2mi47hzxSD`N#-byqd1IhEoh!PGq>t z_MRy{5B0eKY>;Ao3z$RUU7U+i?iX^&r739F)itdrTpAi-NN0=?^m%?{A9Ly2pVv>Lqs6moTP?T2-AHqFD-o_ znVr|7OAS#AEH}h8SRPQ@NGG47dO}l=t07__+iK8nHw^(AHx&Wb<%jPc$$jl6_p(b$ z)!pi(0fQodCHfM)KMEMUR&UID>}m^(!{C^U7sBDOA)$VThRCI0_+2=( zV8mMq0R(#z;C|7$m>$>`tX+T|xGt(+Y48@ZYu#z;0pCgYgmMVbFb!$?%yhZqP_nhn zy4<#3P1oQ#2b51NU1mGnHP$cf0j-YOgAA}A$QoL6JVLcmExs(kU{4z;PBHJD%_=0F z>+sQV`mzijSIT7xn%PiDKHOujX;n|M&qr1T@rOxTdxtZ!&u&3HHFLYD5$RLQ=heur zb>+AFokUVQeJy-#LP*^)spt{mb@Mqe=A~-4p0b+Bt|pZ+@CY+%x}9f}izU5;4&QFE zO1bhg&A4uC1)Zb67kuowWY4xbo&J=%yoXlFB)&$d*-}kjBu|w!^zbD1YPc0-#XTJr z)pm2RDy%J3jlqSMq|o%xGS$bPwn4AqitC6&e?pqWcjWPt{3I{>CBy;hg0Umh#c;hU3RhCUX=8aR>rmd` z7Orw(5tcM{|-^J?ZAA9KP|)X6n9$-kvr#j5YDecTM6n z&07(nD^qb8hpF0B^z^pQ*%5ePYkv&FabrlI61ntiVp!!C8y^}|<2xgAd#FY=8b*y( zuQOuvy2`Ii^`VBNJB&R!0{hABYX55ooCAJSSevl4RPqEGb)iy_0H}v@vFwFzD%>#I>)3PsouQ+_Kkbqy*kKdHdfkN7NBcq%V{x^fSxgXpg7$bF& zj!6AQbDY(1u#1_A#1UO9AxiZaCVN2F0wGXdY*g@x$ByvUA?ePdide0dmr#}udE%K| z3*k}Vv2Ew2u1FXBaVA6aerI36R&rzEZeDDCl5!t0J=ug6kuNZzH>3i_VN`%BsaVB3 zQYw|Xub_SGf{)F{$ZX5`Jc!X!;eybjP+o$I{Z^Hsj@D=E{MnnL+TbC@HEU2DjG{3-LDGIbq()U87x4eS;JXnSh;lRlJ z>EL3D>wHt-+wTjQF$fGyDO$>d+(fq@bPpLBS~xA~R=3JPbS{tzN(u~m#Po!?H;IYv zE;?8%^vle|%#oux(Lj!YzBKv+Fd}*Ur-dCBoX*t{KeNM*n~ZPYJ4NNKkI^MFbz9!v z4(Bvm*Kc!-$%VFEewYJKz-CQN{`2}KX4*CeJEs+Q(!kI%hN1!1P6iOq?ovz}X0IOi z)YfWpwW@pK08^69#wSyCZkX9?uZD?C^@rw^Y?gLS_xmFKkooyx$*^5#cPqntNTtSG zlP>XLMj2!VF^0k#ole7`-c~*~+_T5ls?x4)ah(j8vo_ zwb%S8qoaZqY0-$ZI+ViIA_1~~rAH7K_+yFS{0rT@eQtTAdz#8E5VpwnW!zJ_^{Utv zlW5Iar3V5t&H4D6A=>?mq;G92;1cg9a2sf;gY9pJDVKn$DYdQlvfXq}zz8#LyPGq@ z+`YUMD;^-6w&r-82JL7mA8&M~Pj@aK!m{0+^v<|t%APYf7`}jGEhdYLqsHW-Le9TL z_hZZ1gbrz7$f9^fAzVIP30^KIz!!#+DRLL+qMszvI_BpOSmjtl$hh;&UeM{ER@INV zcI}VbiVTPoN|iSna@=7XkP&-4#06C};8ajbxJ4Gcq8(vWv4*&X8bM^T$mBk75Q92j z1v&%a;OSKc8EIrodmIiw$lOES2hzGDcjjB`kEDfJe{r}yE6`eZL zEB`9u>Cl0IsQ+t}`-cx}{6jqcANucqIB>Qmga_&<+80E2Q|VHHQ$YlAt{6`Qu`HA3 z03s0-sSlwbvgi&_R8s={6<~M^pGvBNjKOa>tWenzS8s zR>L7R5aZ=mSU{f?ib4Grx$AeFvtO5N|D>9#)ChH#Fny2maHWHOf2G=#<9Myot#+4u zWVa6d^Vseq_0=#AYS(-m$Lp;*8nC_6jXIjEM`omUmtH@QDs3|G)i4j*#_?#UYVZvJ z?YjT-?!4Q{BNun;dKBWLEw2C-VeAz`%?A>p;)PL}TAZn5j~HK>v1W&anteARlE+~+ zj>c(F;?qO3pXBb|#OZdQnm<4xWmn~;DR5SDMxt0UK_F^&eD|KZ=O;tO3vy4@4h^;2 zUL~-z`-P1aOe?|ZC1BgVsL)2^J-&vIFI%q@40w0{jjEfeVl)i9(~bt2z#2Vm)p`V_ z1;6$Ae7=YXk#=Qkd24Y23t&GvRxaOoad~NbJ+6pxqzJ>FY#Td7@`N5xp!n(c!=RE& z&<<@^a$_Ys8jqz4|5Nk#FY$~|FPC0`*a5HH!|Gssa9=~66&xG9)|=pOOJ2KE5|YrR zw!w6K2aC=J$t?L-;}5hn6mHd%hC;p8P|Dgh6D>hGnXPgi;6r+eA=?f72y9(Cf_ho{ zH6#)uD&R=73^$$NE;5piWX2bzR67fQ)`b=85o0eOLGI4c-Tb@-KNi2pz=Ke@SDcPn za$AxXib84`!Sf;Z3B@TSo`Dz7GM5Kf(@PR>Ghzi=BBxK8wRp>YQoXm+iL>H*Jo9M3 z6w&E?BC8AFTFT&Tv8zf+m9<&S&%dIaZ)Aoqkak_$r-2{$d~0g2oLETx9Y`eOAf14QXEQw3tJne;fdzl@wV#TFXSLXM2428F-Q}t+n2g%vPRMUzYPvzQ9f# zu(liiJem9P*?0%V@RwA7F53r~|I!Ty)<*AsMX3J{_4&}{6pT%Tpw>)^|DJ)>gpS~1rNEh z0$D?uO8mG?H;2BwM5a*26^7YO$XjUm40XmBsb63MoR;bJh63J;OngS5sSI+o2HA;W zdZV#8pDpC9Oez&L8loZO)MClRz!_!WD&QRtQxnazhT%Vj6Wl4G11nUk8*vSeVab@N#oJ}`KyJv+8Mo@T1-pqZ1t|?cnaVOd;1(h9 z!$DrN=jcGsVYE-0-n?oCJ^4x)F}E;UaD-LZUIzcD?W^ficqJWM%QLy6QikrM1aKZC zi{?;oKwq^Vsr|&`i{jIphA8S6G4)$KGvpULjH%9u(Dq247;R#l&I0{IhcC|oBF*Al zvLo7Xte=C{aIt*otJD}BUq)|_pdR>{zBMT< z(^1RpZv*l*m*OV^8>9&asGBo8h*_4q*)-eCv*|Pq=XNGrZE)^(SF7^{QE_~4VDB(o zVcPA_!G+2CAtLbl+`=Q~9iW`4ZRLku!uB?;tWqVjB0lEOf}2RD7dJ=BExy=<9wkb- z9&7{XFA%n#JsHYN8t5d~=T~5DcW4$B%3M+nNvC2`0!#@sckqlzo5;hhGi(D9=*A4` z5ynobawSPRtWn&CDLEs3Xf`(8^zDP=NdF~F^s&={l7(aw&EG}KWpMjtmz7j_VLO;@ zM2NVLDxZ@GIv7*gzl1 zjq78tv*8#WSY`}Su0&C;2F$Ze(q>F(@Wm^Gw!)(j;dk9Ad{STaxn)IV9FZhm*n+U} zi;4y*3v%A`_c7a__DJ8D1b@dl0Std3F||4Wtvi)fCcBRh!X9$1x!_VzUh>*S5s!oq z;qd{J_r79EL2wIeiGAqFstWtkfIJpjVh%zFo*=55B9Zq~y0=^iqHWfQl@O!Ak;(o*m!pZqe9 z%U2oDOhR)BvW8&F70L;2TpkzIutIvNQaTjjs5V#8mV4!NQ}zN=i`i@WI1z0eN-iCS z;vL-Wxc^Vc_qK<5RPh(}*8dLT{~GzE{w2o$2kMFaEl&q zP{V=>&3kW7tWaK-Exy{~`v4J0U#OZBk{a9{&)&QG18L@6=bsZ1zC_d{{pKZ-Ey>I> z;8H0t4bwyQqgu4hmO`3|4K{R*5>qnQ&gOfdy?z`XD%e5+pTDzUt3`k^u~SaL&XMe= z9*h#kT(*Q9jO#w2Hd|Mr-%DV8i_1{J1MU~XJ3!WUplhXDYBpJH><0OU`**nIvPIof z|N8@I=wA)sf45SAvx||f?Z5uB$kz1qL3Ky_{%RPdP5iN-D2!p5scq}buuC00C@jom zhfGKm3|f?Z0iQ|K$Z~!`8{nmAS1r+fp6r#YDOS8V*;K&Gs7Lc&f^$RC66O|)28oh`NHy&vq zJh+hAw8+ybTB0@VhWN^0iiTnLsCWbS_y`^gs!LX!Lw{yE``!UVzrV24tP8o;I6-65 z1MUiHw^{bB15tmrVT*7-#sj6cs~z`wk52YQJ*TG{SE;KTm#Hf#a~|<(|ImHH17nNM z`Ub{+J3dMD!)mzC8b(2tZtokKW5pAwHa?NFiso~# z1*iaNh4lQ4TS)|@G)H4dZV@l*Vd;Rw;-;odDhW2&lJ%m@jz+Panv7LQm~2Js6rOW3 z0_&2cW^b^MYW3)@o;neZ<{B4c#m48dAl$GCc=$>ErDe|?y@z`$uq3xd(%aAsX)D%l z>y*SQ%My`yDP*zof|3@_w#cjaW_YW4BdA;#Glg1RQcJGY*CJ9`H{@|D+*e~*457kd z73p<%fB^PV!Ybw@)Dr%(ZJbX}xmCStCYv#K3O32ej{$9IzM^I{6FJ8!(=azt7RWf4 z7ib0UOPqN40X!wOnFOoddd8`!_IN~9O)#HRTyjfc#&MCZ zZAMzOVB=;qwt8gV?{Y2?b=iSZG~RF~uyx18K)IDFLl})G1v@$(s{O4@RJ%OTJyF+Cpcx4jmy|F3euCnMK!P2WTDu5j z{{gD$=M*pH!GGzL%P)V2*ROm>!$Y=z|D`!_yY6e7SU$~a5q8?hZGgaYqaiLnkK%?0 zs#oI%;zOxF@g*@(V4p!$7dS1rOr6GVs6uYCTt2h)eB4?(&w8{#o)s#%gN@BBosRUe z)@P@8_Zm89pr~)b>e{tbPC~&_MR--iB{=)y;INU5#)@Gix-YpgP<-c2Ms{9zuCX|3 z!p(?VaXww&(w&uBHzoT%!A2=3HAP>SDxcljrego7rY|%hxy3XlODWffO_%g|l+7Y_ zqV(xbu)s4lV=l7M;f>vJl{`6qBm>#ZeMA}kXb97Z)?R97EkoI?x6Lp0yu1Z>PS?2{ z0QQ(8D)|lc9CO3B~e(pQM&5(1y&y=e>C^X$`)_&XuaI!IgDTVqt31wX#n+@!a_A0ZQkA zCJ2@M_4Gb5MfCrm5UPggeyh)8 zO9?`B0J#rkoCx(R0I!ko_2?iO@|oRf1;3r+i)w-2&j?=;NVIdPFsB)`|IC0zk6r9c zRrkfxWsiJ(#8QndNJj@{@WP2Ackr|r1VxV{7S&rSU(^)-M8gV>@UzOLXu9K<{6e{T zXJ6b92r$!|lwjhmgqkdswY&}c)KW4A)-ac%sU;2^fvq7gfUW4Bw$b!i@duy1CAxSn z(pyh$^Z=&O-q<{bZUP+$U}=*#M9uVc>CQVgDs4swy5&8RAHZ~$)hrTF4W zPsSa~qYv_0mJnF89RnnJTH`3}w4?~epFl=D(35$ zWa07ON$`OMBOHgCmfO(9RFc<)?$x)N}Jd2A(<*Ll7+4jrRt9w zwGxExUXd9VB#I|DwfxvJ;HZ8Q{37^wDhaZ%O!oO(HpcqfLH%#a#!~;Jl7F5>EX_=8 z{()l2NqPz>La3qJR;_v+wlK>GsHl;uRA8%j`A|yH@k5r%55S9{*Cp%uw6t`qc1!*T za2OeqtQj7sAp#Q~=5Fs&aCR9v>5V+s&RdNvo&H~6FJOjvaj--2sYYBvMq;55%z8^o z|BJDA4vzfow#DO#ZQHh;Oq_{r+qP{R9ox2TOgwQiv7Ow!zjN+A@BN;0tA2lUb#+zO z(^b89eV)D7UVE+h{mcNc6&GtpOqDn_?VAQ)Vob$hlFwW%xh>D#wml{t&Ofmm_d_+; zKDxzdr}`n2Rw`DtyIjrG)eD0vut$}dJAZ0AohZ+ZQdWXn_Z@dI_y=7t3q8x#pDI-K z2VVc&EGq445Rq-j0=U=Zx`oBaBjsefY;%)Co>J3v4l8V(T8H?49_@;K6q#r~Wwppc z4XW0(4k}cP=5ex>-Xt3oATZ~bBWKv)aw|I|Lx=9C1s~&b77idz({&q3T(Y(KbWO?+ zmcZ6?WeUsGk6>km*~234YC+2e6Zxdl~<_g2J|IE`GH%n<%PRv-50; zH{tnVts*S5*_RxFT9eM0z-pksIb^drUq4>QSww=u;UFCv2AhOuXE*V4z?MM`|ABOC4P;OfhS(M{1|c%QZ=!%rQTDFx`+}?Kdx$&FU?Y<$x;j7z=(;Lyz+?EE>ov!8vvMtSzG!nMie zsBa9t8as#2nH}n8xzN%W%U$#MHNXmDUVr@GX{?(=yI=4vks|V)!-W5jHsU|h_&+kY zS_8^kd3jlYqOoiI`ZqBVY!(UfnAGny!FowZWY_@YR0z!nG7m{{)4OS$q&YDyw6vC$ zm4!$h>*|!2LbMbxS+VM6&DIrL*X4DeMO!@#EzMVfr)e4Tagn~AQHIU8?e61TuhcKD zr!F4(kEebk(Wdk-?4oXM(rJwanS>Jc%<>R(siF+>+5*CqJLecP_we33iTFTXr6W^G z7M?LPC-qFHK;E!fxCP)`8rkxZyFk{EV;G-|kwf4b$c1k0atD?85+|4V%YATWMG|?K zLyLrws36p%Qz6{}>7b>)$pe>mR+=IWuGrX{3ZPZXF3plvuv5Huax86}KX*lbPVr}L z{C#lDjdDeHr~?l|)Vp_}T|%$qF&q#U;ClHEPVuS+Jg~NjC1RP=17=aQKGOcJ6B3mp z8?4*-fAD~}sX*=E6!}^u8)+m2j<&FSW%pYr_d|p_{28DZ#Cz0@NF=gC-o$MY?8Ca8 zr5Y8DSR^*urS~rhpX^05r30Ik#2>*dIOGxRm0#0YX@YQ%Mg5b6dXlS!4{7O_kdaW8PFSdj1=ryI-=5$fiieGK{LZ+SX(1b=MNL!q#lN zv98?fqqTUH8r8C7v(cx#BQ5P9W>- zmW93;eH6T`vuJ~rqtIBg%A6>q>gnWb3X!r0wh_q;211+Om&?nvYzL1hhtjB zK_7G3!n7PL>d!kj){HQE zE8(%J%dWLh1_k%gVXTZt zEdT09XSKAx27Ncaq|(vzL3gm83q>6CAw<$fTnMU05*xAe&rDfCiu`u^1)CD<>sx0i z*hr^N_TeN89G(nunZoLBf^81#pmM}>JgD@Nn1l*lN#a=B=9pN%tmvYFjFIoKe_(GF z-26x{(KXdfsQL7Uv6UtDuYwV`;8V3w>oT_I<`Ccz3QqK9tYT5ZQzbop{=I=!pMOCb zCU68`n?^DT%^&m>A%+-~#lvF!7`L7a{z<3JqIlk1$<||_J}vW1U9Y&eX<}l8##6i( zZcTT@2`9(Mecptm@{3A_Y(X`w9K0EwtPq~O!16bq{7c0f7#(3wn-^)h zxV&M~iiF!{-6A@>o;$RzQ5A50kxXYj!tcgme=Qjrbje~;5X2xryU;vH|6bE(8z^<7 zQ>BG7_c*JG8~K7Oe68i#0~C$v?-t@~@r3t2inUnLT(c=URpA9kA8uq9PKU(Ps(LVH zqgcqW>Gm?6oV#AldDPKVRcEyQIdTT`Qa1j~vS{<;SwyTdr&3*t?J)y=M7q*CzucZ&B0M=joT zBbj@*SY;o2^_h*>R0e({!QHF0=)0hOj^B^d*m>SnRrwq>MolNSgl^~r8GR#mDWGYEIJA8B<|{{j?-7p zVnV$zancW3&JVDtVpIlI|5djKq0(w$KxEFzEiiL=h5Jw~4Le23@s(mYyXWL9SX6Ot zmb)sZaly_P%BeX_9 zw&{yBef8tFm+%=--m*J|o~+Xg3N+$IH)t)=fqD+|fEk4AAZ&!wcN5=mi~Vvo^i`}> z#_3ahR}Ju)(Px7kev#JGcSwPXJ2id9%Qd2A#Uc@t8~egZ8;iC{e! z%=CGJOD1}j!HW_sgbi_8suYnn4#Ou}%9u)dXd3huFIb!ytlX>Denx@pCS-Nj$`VO&j@(z!kKSP0hE4;YIP#w9ta=3DO$7f*x zc9M4&NK%IrVmZAe=r@skWD`AEWH=g+r|*13Ss$+{c_R!b?>?UaGXlw*8qDmY#xlR= z<0XFbs2t?8i^G~m?b|!Hal^ZjRjt<@a? z%({Gn14b4-a|#uY^=@iiKH+k?~~wTj5K1A&hU z2^9-HTC)7zpoWK|$JXaBL6C z#qSNYtY>65T@Zs&-0cHeu|RX(Pxz6vTITdzJdYippF zC-EB+n4}#lM7`2Ry~SO>FxhKboIAF#Z{1wqxaCb{#yEFhLuX;Rx(Lz%T`Xo1+a2M}7D+@wol2)OJs$TwtRNJ={( zD@#zTUEE}#Fz#&(EoD|SV#bayvr&E0vzmb%H?o~46|FAcx?r4$N z&67W3mdip-T1RIxwSm_&(%U|+WvtGBj*}t69XVd&ebn>KOuL(7Y8cV?THd-(+9>G7*Nt%T zcH;`p={`SOjaf7hNd(=37Lz3-51;58JffzIPgGs_7xIOsB5p2t&@v1mKS$2D$*GQ6 zM(IR*j4{nri7NMK9xlDy-hJW6sW|ZiDRaFiayj%;(%51DN!ZCCCXz+0Vm#};70nOx zJ#yA0P3p^1DED;jGdPbQWo0WATN=&2(QybbVdhd=Vq*liDk`c7iZ?*AKEYC#SY&2g z&Q(Ci)MJ{mEat$ZdSwTjf6h~roanYh2?9j$CF@4hjj_f35kTKuGHvIs9}Re@iKMxS-OI*`0S z6s)fOtz}O$T?PLFVSeOjSO26$@u`e<>k(OSP!&YstH3ANh>)mzmKGNOwOawq-MPXe zy4xbeUAl6tamnx))-`Gi2uV5>9n(73yS)Ukma4*7fI8PaEwa)dWHs6QA6>$}7?(L8 ztN8M}?{Tf!Zu22J5?2@95&rQ|F7=FK-hihT-vDp!5JCcWrVogEnp;CHenAZ)+E+K5 z$Cffk5sNwD_?4+ymgcHR(5xgt20Z8M`2*;MzOM#>yhk{r3x=EyM226wb&!+j`W<%* zSc&|`8!>dn9D@!pYow~(DsY_naSx7(Z4i>cu#hA5=;IuI88}7f%)bRkuY2B;+9Uep zpXcvFWkJ!mQai63BgNXG26$5kyhZ2&*3Q_tk)Ii4M>@p~_~q_cE!|^A;_MHB;7s#9 zKzMzK{lIxotjc};k67^Xsl-gS!^*m*m6kn|sbdun`O?dUkJ{0cmI0-_2y=lTAfn*Y zKg*A-2sJq)CCJgY0LF-VQvl&6HIXZyxo2#!O&6fOhbHXC?%1cMc6y^*dOS{f$=137Ds1m01qs`>iUQ49JijsaQ( zksqV9@&?il$|4Ua%4!O15>Zy&%gBY&wgqB>XA3!EldQ%1CRSM(pp#k~-pkcCg4LAT zXE=puHbgsw)!xtc@P4r~Z}nTF=D2~j(6D%gTBw$(`Fc=OOQ0kiW$_RDd=hcO0t97h zb86S5r=>(@VGy1&#S$Kg_H@7G^;8Ue)X5Y+IWUi`o;mpvoV)`fcVk4FpcT|;EG!;? zHG^zrVVZOm>1KFaHlaogcWj(v!S)O(Aa|Vo?S|P z5|6b{qkH(USa*Z7-y_Uvty_Z1|B{rTS^qmEMLEYUSk03_Fg&!O3BMo{b^*`3SHvl0 zhnLTe^_vVIdcSHe)SQE}r~2dq)VZJ!aSKR?RS<(9lzkYo&dQ?mubnWmgMM37Nudwo z3Vz@R{=m2gENUE3V4NbIzAA$H1z0pagz94-PTJyX{b$yndsdKptmlKQKaaHj@3=ED zc7L?p@%ui|RegVYutK$64q4pe9+5sv34QUpo)u{1ci?)_7gXQd{PL>b0l(LI#rJmN zGuO+%GO`xneFOOr4EU(Wg}_%bhzUf;d@TU+V*2#}!2OLwg~%D;1FAu=Un>OgjPb3S z7l(riiCwgghC=Lm5hWGf5NdGp#01xQ59`HJcLXbUR3&n%P(+W2q$h2Qd z*6+-QXJ*&Kvk9ht0f0*rO_|FMBALen{j7T1l%=Q>gf#kma zQlg#I9+HB+z*5BMxdesMND`_W;q5|FaEURFk|~&{@qY32N$G$2B=&Po{=!)x5b!#n zxLzblkq{yj05#O7(GRuT39(06FJlalyv<#K4m}+vs>9@q-&31@1(QBv82{}Zkns~K ze{eHC_RDX0#^A*JQTwF`a=IkE6Ze@j#-8Q`tTT?k9`^ZhA~3eCZJ-Jr{~7Cx;H4A3 zcZ+Zj{mzFZbVvQ6U~n>$U2ZotGsERZ@}VKrgGh0xM;Jzt29%TX6_&CWzg+YYMozrM z`nutuS)_0dCM8UVaKRj804J4i%z2BA_8A4OJRQ$N(P9Mfn-gF;4#q788C@9XR0O3< zsoS4wIoyt046d+LnSCJOy@B@Uz*#GGd#+Ln1ek5Dv>(ZtD@tgZlPnZZJGBLr^JK+!$$?A_fA3LOrkoDRH&l7 zcMcD$Hsjko3`-{bn)jPL6E9Ds{WskMrivsUu5apD z?grQO@W7i5+%X&E&p|RBaEZ(sGLR@~(y^BI@lDMot^Ll?!`90KT!JXUhYS`ZgX3jnu@Ja^seA*M5R@f`=`ynQV4rc$uT1mvE?@tz)TN<=&H1%Z?5yjxcpO+6y_R z6EPuPKM5uxKpmZfT(WKjRRNHs@ib)F5WAP7QCADvmCSD#hPz$V10wiD&{NXyEwx5S z6NE`3z!IS^$s7m}PCwQutVQ#~w+V z=+~->DI*bR2j0^@dMr9`p>q^Ny~NrAVxrJtX2DUveic5vM%#N*XO|?YAWwNI$Q)_) zvE|L(L1jP@F%gOGtnlXtIv2&1i8q<)Xfz8O3G^Ea~e*HJsQgBxWL(yuLY+jqUK zRE~`-zklrGog(X}$9@ZVUw!8*=l`6mzYLtsg`AvBYz(cxmAhr^j0~(rzXdiOEeu_p zE$sf2(w(BPAvO5DlaN&uQ$4@p-b?fRs}d7&2UQ4Fh?1Hzu*YVjcndqJLw0#q@fR4u zJCJ}>_7-|QbvOfylj+e^_L`5Ep9gqd>XI3-O?Wp z-gt*P29f$Tx(mtS`0d05nHH=gm~Po_^OxxUwV294BDKT>PHVlC5bndncxGR!n(OOm znsNt@Q&N{TLrmsoKFw0&_M9$&+C24`sIXGWgQaz=kY;S{?w`z^Q0JXXBKFLj0w0U6P*+jPKyZHX9F#b0D1$&(- zrm8PJd?+SrVf^JlfTM^qGDK&-p2Kdfg?f>^%>1n8bu&byH(huaocL>l@f%c*QkX2i znl}VZ4R1en4S&Bcqw?$=Zi7ohqB$Jw9x`aM#>pHc0x z0$!q7iFu zZ`tryM70qBI6JWWTF9EjgG@>6SRzsd}3h+4D8d~@CR07P$LJ}MFsYi-*O%XVvD@yT|rJ+Mk zDllJ7$n0V&A!0flbOf)HE6P_afPWZmbhpliqJuw=-h+r;WGk|ntkWN(8tKlYpq5Ow z(@%s>IN8nHRaYb*^d;M(D$zGCv5C|uqmsDjwy4g=Lz>*OhO3z=)VD}C<65;`89Ye} zSCxrv#ILzIpEx1KdLPlM&%Cctf@FqTKvNPXC&`*H9=l=D3r!GLM?UV zOxa(8ZsB`&+76S-_xuj?G#wXBfDY@Z_tMpXJS7^mp z@YX&u0jYw2A+Z+bD#6sgVK5ZgdPSJV3>{K^4~%HV?rn~4D)*2H!67Y>0aOmzup`{D zzDp3c9yEbGCY$U<8biJ_gB*`jluz1ShUd!QUIQJ$*1;MXCMApJ^m*Fiv88RZ zFopLViw}{$Tyhh_{MLGIE2~sZ)t0VvoW%=8qKZ>h=adTe3QM$&$PO2lfqH@brt!9j ziePM8$!CgE9iz6B<6_wyTQj?qYa;eC^{x_0wuwV~W+^fZmFco-o%wsKSnjXFEx02V zF5C2t)T6Gw$Kf^_c;Ei3G~uC8SM-xyycmXyC2hAVi-IfXqhu$$-C=*|X?R0~hu z8`J6TdgflslhrmDZq1f?GXF7*ALeMmOEpRDg(s*H`4>_NAr`2uqF;k;JQ+8>A|_6ZNsNLECC%NNEb1Y1dP zbIEmNpK)#XagtL4R6BC{C5T(+=yA-(Z|Ap}U-AfZM#gwVpus3(gPn}Q$CExObJ5AC z)ff9Yk?wZ}dZ-^)?cbb9Fw#EjqQ8jxF4G3=L?Ra zg_)0QDMV1y^A^>HRI$x?Op@t;oj&H@1xt4SZ9(kifQ zb59B*`M99Td7@aZ3UWvj1rD0sE)d=BsBuW*KwkCds7ay(7*01_+L}b~7)VHI>F_!{ zyxg-&nCO?v#KOUec0{OOKy+sjWA;8rTE|Lv6I9H?CI?H(mUm8VXGwU$49LGpz&{nQp2}dinE1@lZ1iox6{ghN&v^GZv9J${7WaXj)<0S4g_uiJ&JCZ zr8-hsu`U%N;+9N^@&Q0^kVPB3)wY(rr}p7{p0qFHb3NUUHJb672+wRZs`gd1UjKPX z4o6zljKKA+Kkj?H>Ew63o%QjyBk&1!P22;MkD>sM0=z_s-G{mTixJCT9@_|*(p^bz zJ8?ZZ&;pzV+7#6Mn`_U-)k8Pjg?a;|Oe^us^PoPY$Va~yi8|?+&=y$f+lABT<*pZr zP}D{~Pq1Qyni+@|aP;ixO~mbEW9#c0OU#YbDZIaw=_&$K%Ep2f%hO^&P67hApZe`x zv8b`Mz@?M_7-)b!lkQKk)JXXUuT|B8kJlvqRmRpxtQDgvrHMXC1B$M@Y%Me!BSx3P z#2Eawl$HleZhhTS6Txm>lN_+I`>eV$&v9fOg)%zVn3O5mI*lAl>QcHuW6!Kixmq`X zBCZ*Ck6OYtDiK!N47>jxI&O2a9x7M|i^IagRr-fmrmikEQGgw%J7bO|)*$2FW95O4 zeBs>KR)izRG1gRVL;F*sr8A}aRHO0gc$$j&ds8CIO1=Gwq1%_~E)CWNn9pCtBE}+`Jelk4{>S)M)`Ll=!~gnn1yq^EX(+y*ik@3Ou0qU`IgYi3*doM+5&dU!cho$pZ zn%lhKeZkS72P?Cf68<#kll_6OAO26bIbueZx**j6o;I0cS^XiL`y+>{cD}gd%lux} z)3N>MaE24WBZ}s0ApfdM;5J_Ny}rfUyxfkC``Awo2#sgLnGPewK};dORuT?@I6(5~ z?kE)Qh$L&fwJXzK){iYx!l5$Tt|^D~MkGZPA}(o6f7w~O2G6Vvzdo*a;iXzk$B66$ zwF#;wM7A+(;uFG4+UAY(2`*3XXx|V$K8AYu#ECJYSl@S=uZW$ksfC$~qrrbQj4??z-)uz0QL}>k^?fPnJTPw% zGz)~?B4}u0CzOf@l^um}HZzbaIwPmb<)< zi_3@E9lc)Qe2_`*Z^HH;1CXOceL=CHpHS{HySy3T%<^NrWQ}G0i4e1xm_K3(+~oi$ zoHl9wzb?Z4j#90DtURtjtgvi7uw8DzHYmtPb;?%8vb9n@bszT=1qr)V_>R%s!92_` zfnHQPANx z<#hIjIMm#*(v*!OXtF+w8kLu`o?VZ5k7{`vw{Yc^qYclpUGIM_PBN1+c{#Vxv&E*@ zxg=W2W~JuV{IuRYw3>LSI1)a!thID@R=bU+cU@DbR^_SXY`MC7HOsCN z!dO4OKV7(E_Z8T#8MA1H`99?Z!r0)qKW_#|29X3#Jb+5+>qUidbeP1NJ@)(qi2S-X zao|f0_tl(O+$R|Qwd$H{_ig|~I1fbp_$NkI!0E;Y z6JrnU{1Ra6^on{9gUUB0mwzP3S%B#h0fjo>JvV~#+X0P~JV=IG=yHG$O+p5O3NUgG zEQ}z6BTp^Fie)Sg<){Z&I8NwPR(=mO4joTLHkJ>|Tnk23E(Bo`FSbPc05lF2-+)X? z6vV3*m~IBHTy*^E!<0nA(tCOJW2G4DsH7)BxLV8kICn5lu6@U*R`w)o9;Ro$i8=Q^V%uH8n3q=+Yf;SFRZu z!+F&PKcH#8cG?aSK_Tl@K9P#8o+jry@gdexz&d(Q=47<7nw@e@FFfIRNL9^)1i@;A z28+$Z#rjv-wj#heI|<&J_DiJ*s}xd-f!{J8jfqOHE`TiHHZVIA8CjkNQ_u;Ery^^t zl1I75&u^`1_q)crO+JT4rx|z2ToSC>)Or@-D zy3S>jW*sNIZR-EBsfyaJ+Jq4BQE4?SePtD2+jY8*%FsSLZ9MY>+wk?}}}AFAw)vr{ml)8LUG-y9>^t!{~|sgpxYc0Gnkg`&~R z-pilJZjr@y5$>B=VMdZ73svct%##v%wdX~9fz6i3Q-zOKJ9wso+h?VME7}SjL=!NUG{J?M&i!>ma`eoEa@IX`5G>B1(7;%}M*%-# zfhJ(W{y;>MRz!Ic8=S}VaBKqh;~7KdnGEHxcL$kA-6E~=!hrN*zw9N+_=odt<$_H_8dbo;0=42wcAETPCVGUr~v(`Uai zb{=D!Qc!dOEU6v)2eHSZq%5iqK?B(JlCq%T6av$Cb4Rko6onlG&?CqaX7Y_C_cOC3 zYZ;_oI(}=>_07}Oep&Ws7x7-R)cc8zfe!SYxJYP``pi$FDS)4Fvw5HH=FiU6xfVqIM!hJ;Rx8c0cB7~aPtNH(Nmm5Vh{ibAoU#J6 zImRCr?(iyu_4W_6AWo3*vxTPUw@vPwy@E0`(>1Qi=%>5eSIrp^`` zK*Y?fK_6F1W>-7UsB)RPC4>>Ps9)f+^MqM}8AUm@tZ->j%&h1M8s*s!LX5&WxQcAh z8mciQej@RPm?660%>{_D+7er>%zX_{s|$Z+;G7_sfNfBgY(zLB4Ey}J9F>zX#K0f6 z?dVNIeEh?EIShmP6>M+d|0wMM85Sa4diw1hrg|ITJ}JDg@o8y>(rF9mXk5M z2@D|NA)-7>wD&wF;S_$KS=eE84`BGw3g0?6wGxu8ys4rwI?9U=*^VF22t3%mbGeOh z`!O-OpF7#Vceu~F`${bW0nYVU9ecmk31V{tF%iv&5hWofC>I~cqAt@u6|R+|HLMMX zVxuSlMFOK_EQ86#E8&KwxIr8S9tj_goWtLv4f@!&h8;Ov41{J~496vp9vX=(LK#j! zAwi*21RAV-LD>9Cw3bV_9X(X3)Kr0-UaB*7Y>t82EQ%!)(&(XuAYtTsYy-dz+w=$ir)VJpe!_$ z6SGpX^i(af3{o=VlFPC);|J8#(=_8#vdxDe|Cok+ANhYwbE*FO`Su2m1~w+&9<_9~ z-|tTU_ACGN`~CNW5WYYBn^B#SwZ(t4%3aPp z;o)|L6Rk569KGxFLUPx@!6OOa+5OjQLK5w&nAmwxkC5rZ|m&HT8G%GVZxB_@ME z>>{rnXUqyiJrT(8GMj_ap#yN_!9-lO5e8mR3cJiK3NE{_UM&=*vIU`YkiL$1%kf+1 z4=jk@7EEj`u(jy$HnzE33ZVW_J4bj}K;vT?T91YlO(|Y0FU4r+VdbmQ97%(J5 zkK*Bed8+C}FcZ@HIgdCMioV%A<*4pw_n}l*{Cr4}a(lq|injK#O?$tyvyE`S%(1`H z_wwRvk#13ElkZvij2MFGOj`fhy?nC^8`Zyo%yVcUAfEr8x&J#A{|moUBAV_^f$hpaUuyQeY3da^ zS9iRgf87YBwfe}>BO+T&Fl%rfpZh#+AM?Dq-k$Bq`vG6G_b4z%Kbd&v>qFjow*mBl z-OylnqOpLg}or7_VNwRg2za3VBK6FUfFX{|TD z`Wt0Vm2H$vdlRWYQJqDmM?JUbVqL*ZQY|5&sY*?!&%P8qhA~5+Af<{MaGo(dl&C5t zE%t!J0 zh6jqANt4ABdPxSTrVV}fLsRQal*)l&_*rFq(Ez}ClEH6LHv{J#v?+H-BZ2)Wy{K@9 z+ovXHq~DiDvm>O~r$LJo!cOuwL+Oa--6;UFE2q@g3N8Qkw5E>ytz^(&($!O47+i~$ zKM+tkAd-RbmP{s_rh+ugTD;lriL~`Xwkad#;_aM?nQ7L_muEFI}U_4$phjvYgleK~`Fo`;GiC07&Hq1F<%p;9Q;tv5b?*QnR%8DYJH3P>Svmv47Y>*LPZJy8_{9H`g6kQpyZU{oJ`m%&p~D=K#KpfoJ@ zn-3cqmHsdtN!f?~w+(t+I`*7GQA#EQC^lUA9(i6=i1PqSAc|ha91I%X&nXzjYaM{8$s&wEx@aVkQ6M{E2 zfzId#&r(XwUNtPcq4Ngze^+XaJA1EK-%&C9j>^9(secqe{}z>hR5CFNveMsVA)m#S zk)_%SidkY-XmMWlVnQ(mNJ>)ooszQ#vaK;!rPmGKXV7am^_F!Lz>;~{VrIO$;!#30XRhE1QqO_~#+Ux;B_D{Nk=grn z8Y0oR^4RqtcYM)7a%@B(XdbZCOqnX#fD{BQTeLvRHd(irHKq=4*jq34`6@VAQR8WG z^%)@5CXnD_T#f%@-l${>y$tfb>2LPmc{~5A82|16mH)R?&r#KKLs7xpN-D`=&Cm^R zvMA6#Ahr<3X>Q7|-qfTY)}32HkAz$_mibYV!I)u>bmjK`qwBe(>za^0Kt*HnFbSdO z1>+ryKCNxmm^)*$XfiDOF2|{-v3KKB?&!(S_Y=Ht@|ir^hLd978xuI&N{k>?(*f8H z=ClxVJK_%_z1TH0eUwm2J+2To7FK4o+n_na)&#VLn1m;!+CX+~WC+qg1?PA~KdOlC zW)C@pw75_xoe=w7i|r9KGIvQ$+3K?L{7TGHwrQM{dCp=Z*D}3kX7E-@sZnup!BImw z*T#a=+WcTwL78exTgBn|iNE3#EsOorO z*kt)gDzHiPt07fmisA2LWN?AymkdqTgr?=loT7z@d`wnlr6oN}@o|&JX!yPzC*Y8d zu6kWlTzE1)ckyBn+0Y^HMN+GA$wUO_LN6W>mxCo!0?oiQvT`z$jbSEu&{UHRU0E8# z%B^wOc@S!yhMT49Y)ww(Xta^8pmPCe@eI5C*ed96)AX9<>))nKx0(sci8gwob_1}4 z0DIL&vsJ1_s%<@y%U*-eX z5rN&(zef-5G~?@r79oZGW1d!WaTqQn0F6RIOa9tJ=0(kdd{d1{<*tHT#cCvl*i>YY zH+L7jq8xZNcTUBqj(S)ztTU!TM!RQ}In*n&Gn<>(60G7}4%WQL!o>hbJqNDSGwl#H z`4k+twp0cj%PsS+NKaxslAEu9!#U3xT1|_KB6`h=PI0SW`P9GTa7caD1}vKEglV8# zjKZR`pluCW19c2fM&ZG)c3T3Um;ir3y(tSCJ7Agl6|b524dy5El{^EQBG?E61H0XY z`bqg!;zhGhyMFl&(o=JWEJ8n~z)xI}A@C0d2hQGvw7nGv)?POU@(kS1m=%`|+^ika zXl8zjS?xqW$WlO?Ewa;vF~XbybHBor$f<%I&*t$F5fynwZlTGj|IjZtVfGa7l&tK} zW>I<69w(cZLu)QIVG|M2xzW@S+70NinQzk&Y0+3WT*cC)rx~04O-^<{JohU_&HL5XdUKW!uFy|i$FB|EMu0eUyW;gsf`XfIc!Z0V zeK&*hPL}f_cX=@iv>K%S5kL;cl_$v?n(Q9f_cChk8Lq$glT|=e+T*8O4H2n<=NGmn z+2*h+v;kBvF>}&0RDS>)B{1!_*XuE8A$Y=G8w^qGMtfudDBsD5>T5SB;Qo}fSkkiV ze^K^M(UthkwrD!&*tTsu>Dacdj_q`~V%r_twr$(Ct&_dKeeXE?fA&4&yASJWJ*}~- zel=@W)tusynfC_YqH4ll>4Eg`Xjs5F7Tj>tTLz<0N3)X<1px_d2yUY>X~y>>93*$) z5PuNMQLf9Bu?AAGO~a_|J2akO1M*@VYN^VxvP0F$2>;Zb9;d5Yfd8P%oFCCoZE$ z4#N$^J8rxYjUE_6{T%Y>MmWfHgScpuGv59#4u6fpTF%~KB^Ae`t1TD_^Ud#DhL+Dm zbY^VAM#MrAmFj{3-BpVSWph2b_Y6gCnCAombVa|1S@DU)2r9W<> zT5L8BB^er3zxKt1v(y&OYk!^aoQisqU zH(g@_o)D~BufUXcPt!Ydom)e|aW{XiMnes2z&rE?og>7|G+tp7&^;q?Qz5S5^yd$i z8lWr4g5nctBHtigX%0%XzIAB8U|T6&JsC4&^hZBw^*aIcuNO47de?|pGXJ4t}BB`L^d8tD`H`i zqrP8?#J@8T#;{^B!KO6J=@OWKhAerih(phML`(Rg7N1XWf1TN>=Z3Do{l_!d~DND&)O)D>ta20}@Lt77qSnVsA7>)uZAaT9bsB>u&aUQl+7GiY2|dAEg@%Al3i316y;&IhQL^8fw_nwS>f60M_-m+!5)S_6EPM7Y)(Nq^8gL7(3 zOiot`6Wy6%vw~a_H?1hLVzIT^i1;HedHgW9-P#)}Y6vF%C=P70X0Tk^z9Te@kPILI z_(gk!k+0%CG)%!WnBjjw*kAKs_lf#=5HXC00s-}oM-Q1aXYLj)(1d!_a7 z*Gg4Fe6F$*ujVjI|79Z5+Pr`us%zW@ln++2l+0hsngv<{mJ%?OfSo_3HJXOCys{Ug z00*YR-(fv<=&%Q!j%b-_ppA$JsTm^_L4x`$k{VpfLI(FMCap%LFAyq;#ns5bR7V+x zO!o;c5y~DyBPqdVQX)8G^G&jWkBy2|oWTw>)?5u}SAsI$RjT#)lTV&Rf8;>u*qXnb z8F%Xb=7#$m)83z%`E;49)t3fHInhtc#kx4wSLLms!*~Z$V?bTyUGiS&m>1P(952(H zuHdv=;o*{;5#X-uAyon`hP}d#U{uDlV?W?_5UjJvf%11hKwe&(&9_~{W)*y1nR5f_ z!N(R74nNK`y8>B!0Bt_Vr!;nc3W>~RiKtGSBkNlsR#-t^&;$W#)f9tTlZz>n*+Fjz z3zXZ;jf(sTM(oDzJt4FJS*8c&;PLTW(IQDFs_5QPy+7yhi1syPCarvqrHFcf&yTy)^O<1EBx;Ir`5W{TIM>{8w&PB>ro4;YD<5LF^TjTb0!zAP|QijA+1Vg>{Afv^% zmrkc4o6rvBI;Q8rj4*=AZacy*n8B{&G3VJc)so4$XUoie0)vr;qzPZVbb<#Fc=j+8CGBWe$n|3K& z_@%?{l|TzKSlUEO{U{{%Fz_pVDxs7i9H#bnbCw7@4DR=}r_qV!Zo~CvD4ZI*+j3kO zW6_=|S`)(*gM0Z;;}nj`73OigF4p6_NPZQ-Od~e$c_);;4-7sR>+2u$6m$Gf%T{aq zle>e3(*Rt(TPD}03n5)!Ca8Pu!V}m6v0o1;5<1h$*|7z|^(3$Y&;KHKTT}hV056wuF0Xo@mK-52~r=6^SI1NC%c~CC?n>yX6wPTgiWYVz!Sx^atLby9YNn1Rk{g?|pJaxD4|9cUf|V1_I*w zzxK)hRh9%zOl=*$?XUjly5z8?jPMy%vEN)f%T*|WO|bp5NWv@B(K3D6LMl!-6dQg0 zXNE&O>Oyf%K@`ngCvbGPR>HRg5!1IV$_}m@3dWB7x3t&KFyOJn9pxRXCAzFr&%37wXG;z^xaO$ekR=LJG ztIHpY8F5xBP{mtQidqNRoz= z@){+N3(VO5bD+VrmS^YjG@+JO{EOIW)9=F4v_$Ed8rZtHvjpiEp{r^c4F6Ic#ChlC zJX^DtSK+v(YdCW)^EFcs=XP7S>Y!4=xgmv>{S$~@h=xW-G4FF9?I@zYN$e5oF9g$# zb!eVU#J+NjLyX;yb)%SY)xJdvGhsnE*JEkuOVo^k5PyS=o#vq!KD46UTW_%R=Y&0G zFj6bV{`Y6)YoKgqnir2&+sl+i6foAn-**Zd1{_;Zb7Ki=u394C5J{l^H@XN`_6XTKY%X1AgQM6KycJ+= zYO=&t#5oSKB^pYhNdzPgH~aEGW2=ec1O#s-KG z71}LOg@4UEFtp3GY1PBemXpNs6UK-ax*)#$J^pC_me;Z$Je(OqLoh|ZrW*mAMBFn< zHttjwC&fkVfMnQeen8`Rvy^$pNRFVaiEN4Pih*Y3@jo!T0nsClN)pdrr9AYLcZxZ| zJ5Wlj+4q~($hbtuY zVQ7hl>4-+@6g1i`1a)rvtp-;b0>^`Dloy(#{z~ytgv=j4q^Kl}wD>K_Y!l~ zp(_&7sh`vfO(1*MO!B%<6E_bx1)&s+Ae`O)a|X=J9y~XDa@UB`m)`tSG4AUhoM=5& znWoHlA-(z@3n0=l{E)R-p8sB9XkV zZ#D8wietfHL?J5X0%&fGg@MH~(rNS2`GHS4xTo7L$>TPme+Is~!|79=^}QbPF>m%J zFMkGzSndiPO|E~hrhCeo@&Ea{M(ieIgRWMf)E}qeTxT8Q#g-!Lu*x$v8W^M^>?-g= zwMJ$dThI|~M06rG$Sv@C@tWR>_YgaG&!BAbkGggVQa#KdtDB)lMLNVLN|51C@F^y8 zCRvMB^{GO@j=cHfmy}_pCGbP%xb{pNN>? z?7tBz$1^zVaP|uaatYaIN+#xEN4jBzwZ|YI_)p(4CUAz1ZEbDk>J~Y|63SZaak~#0 zoYKruYsWHoOlC1(MhTnsdUOwQfz5p6-D0}4;DO$B;7#M{3lSE^jnTT;ns`>!G%i*F?@pR1JO{QTuD0U+~SlZxcc8~>IB{)@8p`P&+nDxNj`*gh|u?yrv$phpQcW)Us)bi`kT%qLj(fi{dWRZ%Es2!=3mI~UxiW0$-v3vUl?#g{p6eF zMEUAqo5-L0Ar(s{VlR9g=j7+lt!gP!UN2ICMokAZ5(Agd>})#gkA2w|5+<%-CuEP# zqgcM}u@3(QIC^Gx<2dbLj?cFSws_f3e%f4jeR?4M^M3cx1f+Qr6ydQ>n)kz1s##2w zk}UyQc+Z5G-d-1}{WzjkLXgS-2P7auWSJ%pSnD|Uivj5u!xk0 z_^-N9r9o;(rFDt~q1PvE#iJZ_f>J3gcP$)SOqhE~pD2|$=GvpL^d!r z6u=sp-CrMoF7;)}Zd7XO4XihC4ji?>V&(t^?@3Q&t9Mx=qex6C9d%{FE6dvU6%d94 zIE;hJ1J)cCqjv?F``7I*6bc#X)JW2b4f$L^>j{*$R`%5VHFi*+Q$2;nyieduE}qdS{L8y8F08yLs?w}{>8>$3236T-VMh@B zq-nujsb_1aUv_7g#)*rf9h%sFj*^mIcImRV*k~Vmw;%;YH(&ylYpy!&UjUVqqtfG` zox3esju?`unJJA_zKXRJP)rA3nXc$m^{S&-p|v|-0x9LHJm;XIww7C#R$?00l&Yyj z=e}gKUOpsImwW?N)+E(awoF@HyP^EhL+GlNB#k?R<2>95hz!h9sF@U20DHSB3~WMa zk90+858r@-+vWwkawJ)8ougd(i#1m3GLN{iSTylYz$brAsP%=&m$mQQrH$g%3-^VR zE%B`Vi&m8f3T~&myTEK28BDWCVzfWir1I?03;pX))|kY5ClO^+bae z*7E?g=3g7EiisYOrE+lA)2?Ln6q2*HLNpZEWMB|O-JI_oaHZB%CvYB(%=tU= zE*OY%QY58fW#RG5=gm0NR#iMB=EuNF@)%oZJ}nmm=tsJ?eGjia{e{yuU0l3{d^D@)kVDt=1PE)&tf_hHC%0MB znL|CRCPC}SeuVTdf>-QV70`0(EHizc21s^sU>y%hW0t!0&y<7}Wi-wGy>m%(-jsDj zP?mF|>p_K>liZ6ZP(w5(|9Ga%>tLgb$|doDDfkdW>Z z`)>V2XC?NJT26mL^@ zf+IKr27TfM!UbZ@?zRddC7#6ss1sw%CXJ4FWC+t3lHZupzM77m^=9 z&(a?-LxIq}*nvv)y?27lZ{j zifdl9hyJudyP2LpU$-kXctshbJDKS{WfulP5Dk~xU4Le4c#h^(YjJit4#R8_khheS z|8(>2ibaHES4+J|DBM7I#QF5u-*EdN{n=Kt@4Zt?@Tv{JZA{`4 zU#kYOv{#A&gGPwT+$Ud}AXlK3K7hYzo$(fBSFjrP{QQ zeaKg--L&jh$9N}`pu{Bs>?eDFPaWY4|9|foN%}i;3%;@4{dc+iw>m}{3rELqH21G! z`8@;w-zsJ1H(N3%|1B@#ioLOjib)j`EiJqPQVSbPSPVHCj6t5J&(NcWzBrzCiDt{4 zdlPAUKldz%6x5II1H_+jv)(xVL+a;P+-1hv_pM>gMRr%04@k;DTokASSKKhU1Qms| zrWh3a!b(J3n0>-tipg{a?UaKsP7?+|@A+1WPDiQIW1Sf@qDU~M_P65_s}7(gjTn0X zucyEm)o;f8UyshMy&>^SC3I|C6jR*R_GFwGranWZe*I>K+0k}pBuET&M~ z;Odo*ZcT?ZpduHyrf8E%IBFtv;JQ!N_m>!sV6ly$_1D{(&nO~w)G~Y`7sD3#hQk%^ zp}ucDF_$!6DAz*PM8yE(&~;%|=+h(Rn-=1Wykas_-@d&z#=S}rDf`4w(rVlcF&lF! z=1)M3YVz7orwk^BXhslJ8jR);sh^knJW(Qmm(QdSgIAIdlN4Te5KJisifjr?eB{FjAX1a0AB>d?qY4Wx>BZ8&}5K0fA+d{l8 z?^s&l8#j7pR&ijD?0b%;lL9l$P_mi2^*_OL+b}4kuLR$GAf85sOo02?Y#90}CCDiS zZ%rbCw>=H~CBO=C_JVV=xgDe%b4FaEFtuS7Q1##y686r%F6I)s-~2(}PWK|Z8M+Gu zl$y~5@#0Ka%$M<&Cv%L`a8X^@tY&T7<0|(6dNT=EsRe0%kp1Qyq!^43VAKYnr*A5~ zsI%lK1ewqO;0TpLrT9v}!@vJK{QoVa_+N4FYT#h?Y8rS1S&-G+m$FNMP?(8N`MZP zels(*?kK{{^g9DOzkuZXJ2;SrOQsp9T$hwRB1(phw1c7`!Q!by?Q#YsSM#I12RhU{$Q+{xj83axHcftEc$mNJ8_T7A-BQc*k(sZ+~NsO~xAA zxnbb%dam_fZlHvW7fKXrB~F&jS<4FD2FqY?VG?ix*r~MDXCE^WQ|W|WM;gsIA4lQP zJ2hAK@CF*3*VqPr2eeg6GzWFlICi8S>nO>5HvWzyZTE)hlkdC_>pBej*>o0EOHR|) z$?};&I4+_?wvL*g#PJ9)!bc#9BJu1(*RdNEn>#Oxta(VWeM40ola<0aOe2kSS~{^P zDJBd}0L-P#O-CzX*%+$#v;(x%<*SPgAje=F{Zh-@ucd2DA(yC|N_|ocs*|-!H%wEw z@Q!>siv2W;C^^j^59OAX03&}&D*W4EjCvfi(ygcL#~t8XGa#|NPO+*M@Y-)ctFA@I z-p7npT1#5zOLo>7q?aZpCZ=iecn3QYklP;gF0bq@>oyBq94f6C=;Csw3PkZ|5q=(c zfs`aw?II0e(h=|7o&T+hq&m$; zBrE09Twxd9BJ2P+QPN}*OdZ-JZV7%av@OM7v!!NL8R;%WFq*?{9T3{ct@2EKgc8h) zMxoM$SaF#p<`65BwIDfmXG6+OiK0e)`I=!A3E`+K@61f}0e z!2a*FOaDrOe>U`q%K!QN`&=&0C~)CaL3R4VY(NDt{Xz(Xpqru5=r#uQN1L$Je1*dkdqQ*=lofQaN%lO!<5z9ZlHgxt|`THd>2 zsWfU$9=p;yLyJyM^t zS2w9w?Bpto`@H^xJpZDKR1@~^30Il6oFGfk5%g6w*C+VM)+%R@gfIwNprOV5{F^M2 zO?n3DEzpT+EoSV-%OdvZvNF+pDd-ZVZ&d8 zKeIyrrfPN=EcFRCPEDCVflX#3-)Ik_HCkL(ejmY8vzcf-MTA{oHk!R2*36`O68$7J zf}zJC+bbQk--9Xm!u#lgLvx8TXx2J258E5^*IZ(FXMpq$2LUUvhWQPs((z1+2{Op% z?J}9k5^N=z;7ja~zi8a_-exIqWUBJwohe#4QJ`|FF*$C{lM18z^#hX6!5B8KAkLUX ziP=oti-gpV(BsLD{0(3*dw}4JxK23Y7M{BeFPucw!sHpY&l%Ws4pSm`+~V7;bZ%Dx zeI)MK=4vC&5#;2MT7fS?^ch9?2;%<8Jlu-IB&N~gg8t;6S-#C@!NU{`p7M8@2iGc& zg|JPg%@gCoCQ&s6JvDU&`X2S<57f(k8nJ1wvBu{8r?;q3_kpZZ${?|( z+^)UvR33sjSd)aT!UPkA;ylO6{aE3MQa{g%Mcf$1KONcjO@&g5zPHWtzM1rYC{_K> zgQNcs<{&X{OA=cEWw5JGqpr0O>x*Tfak2PE9?FuWtz^DDNI}rwAaT0(bdo-<+SJ6A z&}S%boGMWIS0L}=S>|-#kRX;e^sUsotry(MjE|3_9duvfc|nwF#NHuM-w7ZU!5ei8 z6Mkf>2)WunY2eU@C-Uj-A zG(z0Tz2YoBk>zCz_9-)4a>T46$(~kF+Y{#sA9MWH%5z#zNoz)sdXq7ZR_+`RZ%0(q zC7&GyS_|BGHNFl8Xa%@>iWh%Gr?=J5<(!OEjauj5jyrA-QXBjn0OAhJJ9+v=!LK`` z@g(`^*84Q4jcDL`OA&ZV60djgwG`|bcD*i50O}Q{9_noRg|~?dj%VtKOnyRs$Uzqg z191aWoR^rDX#@iSq0n z?9Sg$WSRPqSeI<}&n1T3!6%Wj@5iw5`*`Btni~G=&;J+4`7g#OQTa>u`{4ZZ(c@s$ zK0y;ySOGD-UTjREKbru{QaS>HjN<2)R%Nn-TZiQ(Twe4p@-saNa3~p{?^V9Nixz@a zykPv~<@lu6-Ng9i$Lrk(xi2Tri3q=RW`BJYOPC;S0Yly%77c727Yj-d1vF!Fuk{Xh z)lMbA69y7*5ufET>P*gXQrxsW+ zz)*MbHZv*eJPEXYE<6g6_M7N%#%mR{#awV3i^PafNv(zyI)&bH?F}2s8_rR(6%!V4SOWlup`TKAb@ee>!9JKPM=&8g#BeYRH9FpFybxBXQI2|g}FGJfJ+ zY-*2hB?o{TVL;Wt_ek;AP5PBqfDR4@Z->_182W z{P@Mc27j6jE*9xG{R$>6_;i=y{qf(c`5w9fa*`rEzX6t!KJ(p1H|>J1pC-2zqWENF zmm=Z5B4u{cY2XYl(PfrInB*~WGWik3@1oRhiMOS|D;acnf-Bs(QCm#wR;@Vf!hOPJ zgjhDCfDj$HcyVLJ=AaTbQ{@vIv14LWWF$=i-BDoC11}V;2V8A`S>_x)vIq44-VB-v z*w-d}$G+Ql?En8j!~ZkCpQ$|cA0|+rrY>tiCeWxkRGPoarxlGU2?7%k#F693RHT24 z-?JsiXlT2PTqZqNb&sSc>$d;O4V@|b6VKSWQb~bUaWn1Cf0+K%`Q&Wc<>mQ>*iEGB zbZ;aYOotBZ{vH3y<0A*L0QVM|#rf*LIsGx(O*-7)r@yyBIzJnBFSKBUSl1e|8lxU* zzFL+YDVVkIuzFWeJ8AbgN&w(4-7zbiaMn{5!JQXu)SELk*CNL+Fro|2v|YO)1l15t zs(0^&EB6DPMyaqvY>=KL>)tEpsn;N5Q#yJj<9}ImL((SqErWN3Q=;tBO~ExTCs9hB z2E$7eN#5wX4<3m^5pdjm#5o>s#eS_Q^P)tm$@SawTqF*1dj_i#)3};JslbLKHXl_N z)Fxzf>FN)EK&Rz&*|6&%Hs-^f{V|+_vL1S;-1K-l$5xiC@}%uDuwHYhmsV?YcOUlk zOYkG5v2+`+UWqpn0aaaqrD3lYdh0*!L`3FAsNKu=Q!vJu?Yc8n|CoYyDo_`r0mPoo z8>XCo$W4>l(==h?2~PoRR*kEe)&IH{1sM41mO#-36`02m#nTX{r*r`Q5rZ2-sE|nA zhnn5T#s#v`52T5|?GNS`%HgS2;R(*|^egNPDzzH_z^W)-Q98~$#YAe)cEZ%vge965AS_am#DK#pjPRr-!^za8>`kksCAUj(Xr*1NW5~e zpypt_eJpD&4_bl_y?G%>^L}=>xAaV>KR6;^aBytqpiHe%!j;&MzI_>Sx7O%F%D*8s zSN}cS^<{iiK)=Ji`FpO#^zY!_|D)qeRNAtgmH)m;qC|mq^j(|hL`7uBz+ULUj37gj zksdbnU+LSVo35riSX_4z{UX=%n&}7s0{WuZYoSfwAP`8aKN9P@%e=~1`~1ASL-z%# zw>DO&ixr}c9%4InGc*_y42bdEk)ZdG7-mTu0bD@_vGAr*NcFoMW;@r?@LUhRI zCUJgHb`O?M3!w)|CPu~ej%fddw20lod?Ufp8Dmt0PbnA0J%KE^2~AIcnKP()025V> zG>noSM3$5Btmc$GZoyP^v1@Poz0FD(6YSTH@aD0}BXva?LphAiSz9f&Y(aDAzBnUh z?d2m``~{z;{}kZJ>a^wYI?ry(V9hIoh;|EFc0*-#*`$T0DRQ1;WsqInG;YPS+I4{g zJGpKk%%Sdc5xBa$Q^_I~(F97eqDO7AN3EN0u)PNBAb+n+ zWBTxQx^;O9o0`=g+Zrt_{lP!sgWZHW?8bLYS$;1a@&7w9rD9|Ge;Gb?sEjFoF9-6v z#!2)t{DMHZ2@0W*fCx;62d#;jouz`R5Y(t{BT=$N4yr^^o$ON8d{PQ=!O zX17^CrdM~7D-;ZrC!||<+FEOxI_WI3CA<35va%4v>gc zEX-@h8esj=a4szW7x{0g$hwoWRQG$yK{@3mqd-jYiVofJE!Wok1* znV7Gm&Ssq#hFuvj1sRyHg(6PFA5U*Q8Rx>-blOs=lb`qa{zFy&n4xY;sd$fE+<3EI z##W$P9M{B3c3Si9gw^jlPU-JqD~Cye;wr=XkV7BSv#6}DrsXWFJ3eUNrc%7{=^sP> zrp)BWKA9<}^R9g!0q7yWlh;gr_TEOD|#BmGq<@IV;ueg+D2}cjpp+dPf&Q(36sFU&K8}hA85U61faW&{ zlB`9HUl-WWCG|<1XANN3JVAkRYvr5U4q6;!G*MTdSUt*Mi=z_y3B1A9j-@aK{lNvx zK%p23>M&=KTCgR!Ee8c?DAO2_R?B zkaqr6^BSP!8dHXxj%N1l+V$_%vzHjqvu7p@%Nl6;>y*S}M!B=pz=aqUV#`;h%M0rU zHfcog>kv3UZAEB*g7Er@t6CF8kHDmKTjO@rejA^ULqn!`LwrEwOVmHx^;g|5PHm#B zZ+jjWgjJ!043F+&#_;D*mz%Q60=L9Ove|$gU&~As5^uz@2-BfQ!bW)Khn}G+Wyjw- z19qI#oB(RSNydn0t~;tAmK!P-d{b-@@E5|cdgOS#!>%#Rj6ynkMvaW@37E>@hJP^8 z2zk8VXx|>#R^JCcWdBCy{0nPmYFOxN55#^-rlqobe0#L6)bi?E?SPymF*a5oDDeSd zO0gx?#KMoOd&G(2O@*W)HgX6y_aa6iMCl^~`{@UR`nMQE`>n_{_aY5nA}vqU8mt8H z`oa=g0SyiLd~BxAj2~l$zRSDHxvDs;I4>+M$W`HbJ|g&P+$!U7-PHX4RAcR0szJ*( ze-417=bO2q{492SWrqDK+L3#ChUHtz*@MP)e^%@>_&#Yk^1|tv@j4%3T)diEX zATx4K*hcO`sY$jk#jN5WD<=C3nvuVsRh||qDHnc~;Kf59zr0;c7VkVSUPD%NnnJC_ zl3F^#f_rDu8l}l8qcAz0FFa)EAt32IUy_JLIhU_J^l~FRH&6-ivSpG2PRqzDdMWft>Zc(c)#tb%wgmWN%>IOPm zZi-noqS!^Ftb81pRcQi`X#UhWK70hy4tGW1mz|+vI8c*h@ zfFGJtW3r>qV>1Z0r|L>7I3un^gcep$AAWfZHRvB|E*kktY$qQP_$YG60C@X~tTQjB3%@`uz!qxtxF+LE!+=nrS^07hn` zEgAp!h|r03h7B!$#OZW#ACD+M;-5J!W+{h|6I;5cNnE(Y863%1(oH}_FTW})8zYb$7czP zg~Szk1+_NTm6SJ0MS_|oSz%e(S~P-&SFp;!k?uFayytV$8HPwuyELSXOs^27XvK-D zOx-Dl!P|28DK6iX>p#Yb%3`A&CG0X2S43FjN%IB}q(!hC$fG}yl1y9W&W&I@KTg6@ zK^kpH8=yFuP+vI^+59|3%Zqnb5lTDAykf z9S#X`3N(X^SpdMyWQGOQRjhiwlj!0W-yD<3aEj^&X%=?`6lCy~?`&WSWt z?U~EKFcCG_RJ(Qp7j=$I%H8t)Z@6VjA#>1f@EYiS8MRHZphp zMA_5`znM=pzUpBPO)pXGYpQ6gkine{6u_o!P@Q+NKJ}k!_X7u|qfpAyIJb$_#3@wJ z<1SE2Edkfk9C!0t%}8Yio09^F`YGzpaJHGk*-ffsn85@)%4@`;Fv^8q(-Wk7r=Q8p zT&hD`5(f?M{gfzGbbwh8(}G#|#fDuk7v1W)5H9wkorE0ZZjL0Q1=NRGY>zwgfm81DdoaVwNH;or{{eSyybt)m<=zXoA^RALYG-2t zouH|L*BLvmm9cdMmn+KGopyR@4*=&0&4g|FLoreZOhRmh=)R0bg~ zT2(8V_q7~42-zvb)+y959OAv!V$u(O3)%Es0M@CRFmG{5sovIq4%8Ahjk#*5w{+)+ zMWQoJI_r$HxL5km1#6(e@{lK3Udc~n0@g`g$s?VrnQJ$!oPnb?IHh-1qA`Rz$)Ai< z6w$-MJW-gKNvOhL+XMbE7&mFt`x1KY>k4(!KbbpZ`>`K@1J<(#vVbjx@Z@(6Q}MF# zMnbr-f55(cTa^q4+#)=s+ThMaV~E`B8V=|W_fZWDwiso8tNMTNse)RNBGi=gVwgg% zbOg8>mbRN%7^Um-7oj4=6`$|(K7!+t^90a{$18Z>}<#!bm%ZEFQ{X(yBZMc>lCz0f1I2w9Sq zuGh<9<=AO&g6BZte6hn>Qmvv;Rt)*cJfTr2=~EnGD8P$v3R|&1RCl&7)b+`=QGapi zPbLg_pxm`+HZurtFZ;wZ=`Vk*do~$wB zxoW&=j0OTbQ=Q%S8XJ%~qoa3Ea|au5o}_(P;=!y-AjFrERh%8la!z6Fn@lR?^E~H12D?8#ht=1F;7@o4$Q8GDj;sSC%Jfn01xgL&%F2 zwG1|5ikb^qHv&9hT8w83+yv&BQXOQyMVJSBL(Ky~p)gU3#%|blG?IR9rP^zUbs7rOA0X52Ao=GRt@C&zlyjNLv-} z9?*x{y(`509qhCV*B47f2hLrGl^<@SuRGR!KwHei?!CM10Tq*YDIoBNyRuO*>3FU? zHjipIE#B~y3FSfOsMfj~F9PNr*H?0oHyYB^G(YyNh{SxcE(Y-`x5jFMKb~HO*m+R% zrq|ic4fzJ#USpTm;X7K+E%xsT_3VHKe?*uc4-FsILUH;kL>_okY(w`VU*8+l>o>Jm ziU#?2^`>arnsl#)*R&nf_%>A+qwl%o{l(u)M?DK1^mf260_oteV3#E_>6Y4!_hhVD zM8AI6MM2V*^_M^sQ0dmHu11fy^kOqXqzpr?K$`}BKWG`=Es(9&S@K@)ZjA{lj3ea7_MBP zk(|hBFRjHVMN!sNUkrB;(cTP)T97M$0Dtc&UXSec<+q?y>5=)}S~{Z@ua;1xt@=T5 zI7{`Z=z_X*no8s>mY;>BvEXK%b`a6(DTS6t&b!vf_z#HM{Uoy_5fiB(zpkF{})ruka$iX*~pq1ZxD?q68dIo zIZSVls9kFGsTwvr4{T_LidcWtt$u{kJlW7moRaH6+A5hW&;;2O#$oKyEN8kx`LmG)Wfq4ykh+q{I3|RfVpkR&QH_x;t41Uw z`P+tft^E2B$domKT@|nNW`EHwyj>&}K;eDpe z1bNOh=fvIfk`&B61+S8ND<(KC%>y&?>opCnY*r5M+!UrWKxv0_QvTlJc>X#AaI^xo zaRXL}t5Ej_Z$y*|w*$6D+A?Lw-CO-$itm^{2Ct82-<0IW)0KMNvJHgBrdsIR0v~=H z?n6^}l{D``Me90`^o|q!olsF?UX3YSq^6Vu>Ijm>>PaZI8G@<^NGw{Cx&%|PwYrfw zR!gX_%AR=L3BFsf8LxI|K^J}deh0ZdV?$3r--FEX`#INxsOG6_=!v)DI>0q|BxT)z z-G6kzA01M?rba+G_mwNMQD1mbVbNTWmBi*{s_v_Ft9m2Avg!^78(QFu&n6mbRJ2bA zv!b;%yo{g*9l2)>tsZJOOp}U~8VUH`}$ z8p_}t*XIOehezolNa-a2x0BS})Y9}&*TPgua{Ewn-=wVrmJUeU39EKx+%w%=ixQWK zDLpwaNJs65#6o7Ln7~~X+p_o2BR1g~VCfxLzxA{HlWAI6^H;`juI=&r1jQrUv_q0Z z1Ja-tjdktrrP>GOC*#p?*xfQU5MqjMsBe!9lh(u8)w$e@Z|>aUHI5o;MGw*|Myiz3 z-f0;pHg~Q#%*Kx8MxH%AluVXjG2C$)WL-K63@Q`#y9_k_+}eR(x4~dp7oV-ek0H>I zgy8p#i4GN{>#v=pFYUQT(g&b$OeTy-X_#FDgNF8XyfGY6R!>inYn8IR2RDa&O!(6< znXs{W!bkP|s_YI*Yx%4stI`=ZO45IK6rBs`g7sP40ic}GZ58s?Mc$&i`kq_tfci>N zIHrC0H+Qpam1bNa=(`SRKjixBTtm&e`j9porEci!zdlg1RI0Jw#b(_Tb@RQK1Zxr_ z%7SUeH6=TrXt3J@js`4iDD0=IoHhK~I7^W8^Rcp~Yaf>2wVe|Hh1bUpX9ATD#moByY57-f2Ef1TP^lBi&p5_s7WGG9|0T}dlfxOx zXvScJO1Cnq`c`~{Dp;{;l<-KkCDE+pmexJkd}zCgE{eF=)K``-qC~IT6GcRog_)!X z?fK^F8UDz$(zFUrwuR$qro5>qqn>+Z%<5>;_*3pZ8QM|yv9CAtrAx;($>4l^_$_-L z*&?(77!-=zvnCVW&kUcZMb6;2!83si518Y%R*A3JZ8Is|kUCMu`!vxDgaWjs7^0j( ziTaS4HhQ)ldR=r)_7vYFUr%THE}cPF{0H45FJ5MQW^+W>P+eEX2kLp3zzFe*-pFVA zdDZRybv?H|>`9f$AKVjFWJ=wegO7hOOIYCtd?Vj{EYLT*^gl35|HQ`R=ti+ADm{jyQE7K@kdjuqJhWVSks>b^ zxha88-h3s;%3_5b1TqFCPTxVjvuB5U>v=HyZ$?JSk+&I%)M7KE*wOg<)1-Iy)8-K! z^XpIt|0ibmk9RtMmlUd7#Ap3Q!q9N4atQy)TmrhrFhfx1DAN`^vq@Q_SRl|V z#lU<~n67$mT)NvHh`%als+G-)x1`Y%4Bp*6Un5Ri9h=_Db zA-AdP!f>f0m@~>7X#uBM?diI@)Egjuz@jXKvm zJo+==juc9_<;CqeRaU9_Mz@;3e=E4=6TK+c`|uu#pIqhSyNm`G(X)&)B`8q0RBv#> z`gGlw(Q=1Xmf55VHj%C#^1lpc>LY8kfA@|rlC1EA<1#`iuyNO z(=;irt{_&K=i4)^x%;U(Xv<)+o=dczC5H3W~+e|f~{*ucxj@{Yi-cw^MqYr3fN zF5D+~!wd$#al?UfMnz(@K#wn`_5na@rRr8XqN@&M&FGEC@`+OEv}sI1hw>Up0qAWf zL#e4~&oM;TVfjRE+10B_gFlLEP9?Q-dARr3xi6nQqnw>k-S;~b z;!0s2VS4}W8b&pGuK=7im+t(`nz@FnT#VD|!)eQNp-W6)@>aA+j~K*H{$G`y2|QHY z|Hmy+CR@#jWY4~)lr1qBJB_RfHJFfP<}pK5(#ZZGSqcpyS&}01LnTWk5fzmXMGHkJ zTP6L^B+uj;lmB_W<~4=${+v0>z31M!-_O@o-O9GyW)j_mjx}!0@br_LE-7SIuPP84 z;5=O(U*g_um0tyG|61N@d9lEuOeiRd+#NY^{nd5;-CVlw&Ap7J?qwM^?E29wvS}2d zbzar4Fz&RSR(-|s!Z6+za&Z zY#D<5q_JUktIzvL0)yq_kLWG6DO{ri=?c!y!f(Dk%G{8)k`Gym%j#!OgXVDD3;$&v@qy#ISJfp=Vm>pls@9-mapVQChAHHd-x+OGx)(*Yr zC1qDUTZ6mM(b_hi!TuFF2k#8uI2;kD70AQ&di$L*4P*Y-@p`jdm%_c3f)XhYD^6M8&#Y$ZpzQMcR|6nsH>b=*R_Von!$BTRj7yGCXokoAQ z&ANvx0-Epw`QIEPgI(^cS2f(Y85yV@ygI{ewyv5Frng)e}KCZF7JbR(&W618_dcEh(#+^zZFY;o<815<5sOHQdeax9_!PyM&;{P zkBa5xymca0#)c#tke@3KNEM8a_mT&1gm;p&&JlMGH(cL(b)BckgMQ^9&vRwj!~3@l zY?L5}=Jzr080OGKb|y`ee(+`flQg|!lo6>=H)X4`$Gz~hLmu2a%kYW_Uu8x09Pa0J zKZ`E$BKJ=2GPj_3l*TEcZ*uYRr<*J^#5pILTT;k_cgto1ZL-%slyc16J~OH-(RgDA z%;EjEnoUkZ&acS{Q8`{i6T5^nywgqQI5bDIymoa7CSZG|WWVk>GM9)zy*bNih|QIm z%0+(Nnc*a_xo;$=!HQYaapLms>J1ToyjtFByY`C2H1wT#178#4+|{H0BBqtCdd$L% z_3Hc60j@{t9~MjM@LBalR&6@>B;9?r<7J~F+WXyYu*y3?px*=8MAK@EA+jRX8{CG?GI-< z54?Dc9CAh>QTAvyOEm0^+x;r2BWX|{3$Y7)L5l*qVE*y0`7J>l2wCmW zL1?|a`pJ-l{fb_N;R(Z9UMiSj6pQjOvQ^%DvhIJF!+Th7jO2~1f1N+(-TyCFYQZYw z4)>7caf^Ki_KJ^Zx2JUb z&$3zJy!*+rCV4%jqwyuNY3j1ZEiltS0xTzd+=itTb;IPYpaf?8Y+RSdVdpacB(bVQ zC(JupLfFp8y43%PMj2}T|VS@%LVp>hv4Y!RPMF?pp8U_$xCJ)S zQx!69>bphNTIb9yn*_yfj{N%bY)t{L1cs8<8|!f$;UQ*}IN=2<6lA;x^(`8t?;+ST zh)z4qeYYgZkIy{$4x28O-pugO&gauRh3;lti9)9Pvw+^)0!h~%m&8Q!AKX%urEMnl z?yEz?g#ODn$UM`+Q#$Q!6|zsq_`dLO5YK-6bJM6ya>}H+vnW^h?o$z;V&wvuM$dR& zeEq;uUUh$XR`TWeC$$c&Jjau2it3#%J-y}Qm>nW*s?En?R&6w@sDXMEr#8~$=b(gk zwDC3)NtAP;M2BW_lL^5ShpK$D%@|BnD{=!Tq)o(5@z3i7Z){} zGr}Exom_qDO{kAVkZ*MbLNHE666Kina#D{&>Jy%~w7yX$oj;cYCd^p9zy z8*+wgSEcj$4{WxKmCF(5o7U4jqwEvO&dm1H#7z}%VXAbW&W24v-tS6N3}qrm1OnE)fUkoE8yMMn9S$?IswS88tQWm4#Oid#ckgr6 zRtHm!mfNl-`d>O*1~d7%;~n+{Rph6BBy^95zqI{K((E!iFQ+h*C3EsbxNo_aRm5gj zKYug($r*Q#W9`p%Bf{bi6;IY0v`pB^^qu)gbg9QHQ7 zWBj(a1YSu)~2RK8Pi#C>{DMlrqFb9e_RehEHyI{n?e3vL_}L>kYJC z_ly$$)zFi*SFyNrnOt(B*7E$??s67EO%DgoZL2XNk8iVx~X_)o++4oaK1M|ou73vA0K^503j@uuVmLcHH4ya-kOIDfM%5%(E z+Xpt~#7y2!KB&)PoyCA+$~DXqxPxxALy!g-O?<9+9KTk4Pgq4AIdUkl`1<1#j^cJg zgU3`0hkHj_jxV>`Y~%LAZl^3o0}`Sm@iw7kwff{M%VwtN)|~!p{AsfA6vB5UolF~d zHWS%*uBDt<9y!9v2Xe|au&1j&iR1HXCdyCjxSgG*L{wmTD4(NQ=mFjpa~xooc6kju z`~+d{j7$h-;HAB04H!Zscu^hZffL#9!p$)9>sRI|Yovm)g@F>ZnosF2EgkU3ln0bR zTA}|+E(tt)!SG)-bEJi_0m{l+(cAz^pi}`9=~n?y&;2eG;d9{M6nj>BHGn(KA2n|O zt}$=FPq!j`p&kQ8>cirSzkU0c08%8{^Qyqi-w2LoO8)^E7;;I1;HQ6B$u0nNaX2CY zSmfi)F`m94zL8>#zu;8|{aBui@RzRKBlP1&mfFxEC@%cjl?NBs`cr^nm){>;$g?rhKr$AO&6qV_Wbn^}5tfFBry^e1`%du2~o zs$~dN;S_#%iwwA_QvmMjh%Qo?0?rR~6liyN5Xmej8(*V9ym*T`xAhHih-v$7U}8=dfXi2i*aAB!xM(Xekg*ix@r|ymDw*{*s0?dlVys2e)z62u1 z+k3esbJE=-P5S$&KdFp+2H7_2e=}OKDrf( z9-207?6$@f4m4B+9E*e((Y89!q?zH|mz_vM>kp*HGXldO0Hg#!EtFhRuOm$u8e~a9 z5(roy7m$Kh+zjW6@zw{&20u?1f2uP&boD}$#Zy)4o&T;vyBoqFiF2t;*g=|1=)PxB z8eM3Mp=l_obbc?I^xyLz?4Y1YDWPa+nm;O<$Cn;@ane616`J9OO2r=rZr{I_Kizyc zP#^^WCdIEp*()rRT+*YZK>V@^Zs=ht32x>Kwe zab)@ZEffz;VM4{XA6e421^h~`ji5r%)B{wZu#hD}f3$y@L0JV9f3g{-RK!A?vBUA}${YF(vO4)@`6f1 z-A|}e#LN{)(eXloDnX4Vs7eH|<@{r#LodP@Nz--$Dg_Par%DCpu2>2jUnqy~|J?eZ zBG4FVsz_A+ibdwv>mLp>P!(t}E>$JGaK$R~;fb{O3($y1ssQQo|5M;^JqC?7qe|hg zu0ZOqeFcp?qVn&Qu7FQJ4hcFi&|nR!*j)MF#b}QO^lN%5)4p*D^H+B){n8%VPUzi! zDihoGcP71a6!ab`l^hK&*dYrVYzJ0)#}xVrp!e;lI!+x+bfCN0KXwUAPU9@#l7@0& QuEJmfE|#`Dqx|px0L@K;Y5)KL diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3ae1e2f..19a6bde 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 744e882..23d15a9 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,81 +15,115 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MSYS* | MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +132,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 107acd3..db3a6ac 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,32 +59,34 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal From 4befdba5bff355593eecfe8408c497e245b73097 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 27 Jan 2026 15:31:30 +0900 Subject: [PATCH 03/43] docs: add Tidy First principles to CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kent Beck의 Tidy First 원칙을 추가하여 AI가 코드 수정 시 구조적 변경과 행위적 변경을 분리하도록 지침 제공 Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index ecd7e93..62a8b35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,84 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Development Principles + +### Tidy First (Kent Beck) + +코드 수정 및 기능 추가 시 반드시 Kent Beck의 **Tidy First** 원칙을 적용합니다. + +> References: +> - https://tidyfirst.substack.com/p/augmented-coding-beyond-the-vibes +> - https://tidyfirst.substack.com/p/taming-the-genie-like-kent-beck + +#### 핵심 원칙: 구조적 변경과 행위적 변경의 분리 + +모든 코드 변경은 두 가지 유형으로 분류됩니다: + +| 유형 | 설명 | 예시 | +|------|------|------| +| **구조적 변경 (Structural)** | 동작 변경 없이 코드 구조만 개선 | 리네이밍, 메서드 추출, 파일 재구성, 중복 제거 | +| **행위적 변경 (Behavioral)** | 실제 기능 추가 또는 수정 | 새 API 추가, 버그 수정, 로직 변경 | + +**절대 규칙**: 구조적 변경과 행위적 변경을 **하나의 커밋에 혼합하지 않습니다**. + +#### 작업 순서 + +1. **Tidy First**: 기능 추가 전, 해당 영역의 코드를 먼저 정리 + - 변수/함수명 명확하게 변경 + - 복잡한 메서드 분리 (작고 특화된 클래스, 단일 책임) + - 중복 코드 제거 + - 테스트 실행하여 동작 보존 확인 + - **별도 커밋으로 분리** (예: `refactor: ...`) + +2. **Behavioral Change**: 정리된 코드 위에 기능 구현 + - TDD 사이클 적용: Red → Green → Refactor + - 가장 단순한 실패 테스트 먼저 작성 + - 테스트 통과를 위한 최소한의 코드 구현 + - **별도 커밋으로 분리** (예: `feat: ...`, `fix: ...`) + +#### AI(Claude) 코딩 지침 + +Kent Beck 스타일로 코드를 작성합니다: + +1. **페르소나 적용**: "code like Kent Beck" + - 모듈형 단위 테스트 작성 (모놀리식 테스트 스크립트 지양) + - 명확한 변수/함수 명명 + - TDD 스타일 개발 습관 + +2. **아키텍처 명시** + - 적절한 디자인 패턴 선택 및 적용 + - 작고 특화된 클래스로 행위 분리 + - 단일 책임 원칙 준수 + +3. **변경 분리 필수** + - 구조적 변경 요청 시: 동작 변경 없이 리팩토링만 수행 + - 기능 추가 요청 시: 필요한 경우 먼저 tidy 커밋을 분리하여 제안 + +#### 커밋 전략 + +```bash +# 좋은 예 - 분리된 커밋 +git commit -m "refactor: extract validation logic to separate method" +git commit -m "feat: add phone number format validation" + +# 나쁜 예 - 혼합된 커밋 (금지) +git commit -m "feat: add validation and refactor code" +``` + +#### 체크리스트 + +**기능 추가 또는 코드 수정 전:** +- [ ] 수정할 영역에 정리가 필요한 코드가 있는가? +- [ ] 있다면, 구조적 변경을 먼저 별도 커밋으로 완료했는가? +- [ ] 구조적 변경 후 테스트가 통과하는가? + +**기능 구현 시:** +- [ ] 테스트를 먼저 작성했는가? (TDD) +- [ ] 최소한의 코드로 테스트를 통과시켰는가? +- [ ] 행위적 변경만 포함된 커밋인가? +- [ ] 클래스/메서드가 단일 책임을 가지는가? + ## Development Commands ### Build and Test From 207d27245973255f16893d8b101c63cf2c5a6b15 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 27 Jan 2026 16:01:29 +0900 Subject: [PATCH 04/43] refactor: migrate from java.time to kotlin.time for date/time handling Replace java.time.Instant and java.time.LocalDateTime with kotlin.time.Instant across all DTOs and utility classes. This simplifies the codebase by using Kotlin's native time API and removes the need for complex lenient parsing in JsonSupport. Also simplify test dependencies to use kotlin("test"). Co-Authored-By: Claude Opus 4.5 --- build.gradle.kts | 3 +- .../dto/request/MessageListBaseRequest.kt | 8 ++-- .../message/dto/request/MessageListRequest.kt | 10 ++--- .../MultipleDetailMessageSendingRequest.kt | 4 +- .../message/dto/request/SendRequestConfig.kt | 2 +- .../request/kakao/KakaoTemplateDateQuery.kt | 2 +- .../solapi/sdk/message/lib/Authenticator.kt | 5 +-- .../com/solapi/sdk/message/lib/JsonSupport.kt | 42 ++----------------- 8 files changed, 20 insertions(+), 56 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 12e28cd..613a712 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -89,8 +89,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") implementation("com.squareup.retrofit2:converter-kotlinx-serialization:3.0.0") - testImplementation("org.junit.jupiter:junit-jupiter-api:5.14.2") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.14.2") + testImplementation(kotlin("test")) dokkaHtmlPlugin("org.jetbrains.dokka:kotlin-as-java-plugin:2.1.0") } diff --git a/src/main/java/com/solapi/sdk/message/dto/request/MessageListBaseRequest.kt b/src/main/java/com/solapi/sdk/message/dto/request/MessageListBaseRequest.kt index a0d56fd..6061606 100644 --- a/src/main/java/com/solapi/sdk/message/dto/request/MessageListBaseRequest.kt +++ b/src/main/java/com/solapi/sdk/message/dto/request/MessageListBaseRequest.kt @@ -1,9 +1,9 @@ package com.solapi.sdk.message.dto.request +import com.solapi.sdk.message.model.CommonMessageProperty +import kotlin.time.Instant import kotlinx.serialization.Contextual -import java.time.LocalDateTime import kotlinx.serialization.Serializable -import com.solapi.sdk.message.model.CommonMessageProperty @Serializable data class MessageListBaseRequest( @@ -20,8 +20,8 @@ data class MessageListBaseRequest( var value: String? = null, @Contextual - var startDate: LocalDateTime? = null, + var startDate: Instant? = null, @Contextual - var endDate: LocalDateTime? = null + var endDate: Instant? = null ) : CommonMessageProperty diff --git a/src/main/java/com/solapi/sdk/message/dto/request/MessageListRequest.kt b/src/main/java/com/solapi/sdk/message/dto/request/MessageListRequest.kt index 4f973fa..009e41d 100644 --- a/src/main/java/com/solapi/sdk/message/dto/request/MessageListRequest.kt +++ b/src/main/java/com/solapi/sdk/message/dto/request/MessageListRequest.kt @@ -1,10 +1,10 @@ package com.solapi.sdk.message.dto.request -import kotlinx.serialization.Contextual -import java.time.LocalDateTime -import kotlinx.serialization.Serializable import com.solapi.sdk.message.model.CommonMessageProperty import com.solapi.sdk.message.model.MessageStatusType +import kotlin.time.Instant +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable @Serializable data class MessageListRequest( @@ -58,13 +58,13 @@ data class MessageListRequest( * 조회 할 시작 날짜 */ @Contextual - var startDate: LocalDateTime? = null, + var startDate: Instant? = null, /** * 조회 할 종료 날짜 */ @Contextual - var endDate: LocalDateTime? = null, + var endDate: Instant? = null, /** * 발송 상태 diff --git a/src/main/java/com/solapi/sdk/message/dto/request/MultipleDetailMessageSendingRequest.kt b/src/main/java/com/solapi/sdk/message/dto/request/MultipleDetailMessageSendingRequest.kt index c10cff3..87a736f 100644 --- a/src/main/java/com/solapi/sdk/message/dto/request/MultipleDetailMessageSendingRequest.kt +++ b/src/main/java/com/solapi/sdk/message/dto/request/MultipleDetailMessageSendingRequest.kt @@ -1,9 +1,9 @@ package com.solapi.sdk.message.dto.request +import com.solapi.sdk.message.model.Message +import kotlin.time.Instant import kotlinx.serialization.Contextual -import java.time.Instant import kotlinx.serialization.Serializable -import com.solapi.sdk.message.model.Message @Serializable data class MultipleDetailMessageSendingRequest( diff --git a/src/main/java/com/solapi/sdk/message/dto/request/SendRequestConfig.kt b/src/main/java/com/solapi/sdk/message/dto/request/SendRequestConfig.kt index 7e60011..7c8662d 100644 --- a/src/main/java/com/solapi/sdk/message/dto/request/SendRequestConfig.kt +++ b/src/main/java/com/solapi/sdk/message/dto/request/SendRequestConfig.kt @@ -1,8 +1,8 @@ package com.solapi.sdk.message.dto.request +import kotlin.time.Instant import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable -import java.time.Instant @Serializable data class SendRequestConfig( diff --git a/src/main/java/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQuery.kt b/src/main/java/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQuery.kt index 61aa8e3..13153b2 100644 --- a/src/main/java/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQuery.kt +++ b/src/main/java/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQuery.kt @@ -1,6 +1,6 @@ package com.solapi.sdk.message.dto.request.kakao -import java.time.Instant +import kotlin.time.Instant data class KakaoTemplateDateQuery( val date: Instant, diff --git a/src/main/java/com/solapi/sdk/message/lib/Authenticator.kt b/src/main/java/com/solapi/sdk/message/lib/Authenticator.kt index 1172441..a5f84d4 100644 --- a/src/main/java/com/solapi/sdk/message/lib/Authenticator.kt +++ b/src/main/java/com/solapi/sdk/message/lib/Authenticator.kt @@ -1,10 +1,9 @@ package com.solapi.sdk.message.lib import com.solapi.sdk.message.exception.SolapiApiKeyException +import kotlin.time.Clock import org.apache.commons.codec.binary.Hex import java.nio.charset.StandardCharsets -import java.time.ZoneId -import java.time.ZonedDateTime import java.util.* import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec @@ -25,7 +24,7 @@ internal class Authenticator( } val salt = UUID.randomUUID().toString().replace(Regex("-"), "") - val date = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toString().split(Regex("\\[")).toTypedArray()[0] + val date = Clock.System.now().toString() val encryptionInstance = Mac.getInstance(ENCRYPTION_ALGORITHM) val secretKey = SecretKeySpec(apiSecretKey.toByteArray(StandardCharsets.UTF_8), ENCRYPTION_ALGORITHM) diff --git a/src/main/java/com/solapi/sdk/message/lib/JsonSupport.kt b/src/main/java/com/solapi/sdk/message/lib/JsonSupport.kt index 1ef56b6..0ca85a2 100644 --- a/src/main/java/com/solapi/sdk/message/lib/JsonSupport.kt +++ b/src/main/java/com/solapi/sdk/message/lib/JsonSupport.kt @@ -1,11 +1,6 @@ package com.solapi.sdk.message.lib -import java.time.Instant -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter +import kotlin.time.Instant import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor @@ -17,53 +12,24 @@ import kotlinx.serialization.modules.SerializersModule /** * 중앙 집중식 Json 및 직렬화 설정. - * LocalDateTime 은 ISO_LOCAL_DATE_TIME 문자열로 직렬화/역직렬화합니다. - * Instant 는 ISO_INSTANT(Z) 문자열로 직렬화/역직렬화합니다. + * Instant 는 ISO-8601(Z) 문자열로 직렬화/역직렬화합니다. */ object JsonSupport { private object InstantIsoSerializer : KSerializer { - private val formatter: DateTimeFormatter = DateTimeFormatter.ISO_INSTANT - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: Instant) { - encoder.encodeString(formatter.format(value)) + encoder.encodeString(value.toString()) } override fun deserialize(decoder: Decoder): Instant { val text = decoder.decodeString() - // Lenient parsing: accept ISO_INSTANT, ISO_OFFSET_DATE_TIME, ISO_ZONED_DATE_TIME, ISO_LOCAL_DATE_TIME(UTC) - return runCatching { Instant.parse(text) } - .recoverCatching { OffsetDateTime.parse(text, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toInstant() } - .recoverCatching { ZonedDateTime.parse(text, DateTimeFormatter.ISO_ZONED_DATE_TIME).toInstant() } - .recoverCatching { LocalDateTime.parse(text, DateTimeFormatter.ISO_LOCAL_DATE_TIME).toInstant(ZoneOffset.UTC) } - .getOrElse { throw it } - } - } - private object LocalDateTimeIsoSerializer : KSerializer { - private val formatter: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME - - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: LocalDateTime) { - encoder.encodeString(value.format(formatter)) - } - - override fun deserialize(decoder: Decoder): LocalDateTime { - val text = decoder.decodeString() - // Lenient parsing: accept ISO_LOCAL_DATE_TIME, ISO_OFFSET_DATE_TIME, ISO_ZONED_DATE_TIME, ISO_INSTANT - return runCatching { LocalDateTime.parse(text, formatter) } - .recoverCatching { OffsetDateTime.parse(text, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toLocalDateTime() } - .recoverCatching { ZonedDateTime.parse(text, DateTimeFormatter.ISO_ZONED_DATE_TIME).toLocalDateTime() } - .recoverCatching { LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC) } - .getOrElse { throw it } + return Instant.parse(text) } } val serializersModule: SerializersModule = SerializersModule { - contextual(LocalDateTime::class, LocalDateTimeIsoSerializer) contextual(Instant::class, InstantIsoSerializer) } From 618f129b877bbcf86793c144bdb1aa2aa7ab1fe1 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 27 Jan 2026 16:02:02 +0900 Subject: [PATCH 05/43] test: add unit tests for kotlin.time migration Add comprehensive test coverage for the java.time to kotlin.time migration: - DtoSerializationTest: Tests for SendRequestConfig, MessageListRequest, MessageListBaseRequest, and MultipleDetailMessageSendingRequest serialization with kotlin.time.Instant - AuthenticatorTest: Tests for HMAC-SHA256 auth info generation including UTC timestamp format, salt, and signature validation - JsonSupportTest: Tests for Instant serialization/deserialization including null handling, epoch, and nanosecond precision Co-Authored-By: Claude Opus 4.5 --- .../dto/request/DtoSerializationTest.kt | 160 ++++++++++++++++++ .../sdk/message/lib/AuthenticatorTest.kt | 136 +++++++++++++++ .../solapi/sdk/message/lib/JsonSupportTest.kt | 117 +++++++++++++ 3 files changed, 413 insertions(+) create mode 100644 src/test/kotlin/com/solapi/sdk/message/dto/request/DtoSerializationTest.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/lib/AuthenticatorTest.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/lib/JsonSupportTest.kt diff --git a/src/test/kotlin/com/solapi/sdk/message/dto/request/DtoSerializationTest.kt b/src/test/kotlin/com/solapi/sdk/message/dto/request/DtoSerializationTest.kt new file mode 100644 index 0000000..a676105 --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/dto/request/DtoSerializationTest.kt @@ -0,0 +1,160 @@ +package com.solapi.sdk.message.dto.request + +import com.solapi.sdk.message.lib.JsonSupport +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant +import kotlinx.serialization.encodeToString + +class DtoSerializationTest { + + @Test + fun `SendRequestConfig serializes scheduledDate as ISO-8601`() { + // Given + val config = SendRequestConfig( + appId = "test-app", + scheduledDate = Instant.parse("2024-06-15T14:30:00Z") + ) + + // When + val json = JsonSupport.json.encodeToString(config) + + // Then + assertTrue(json.contains("\"scheduledDate\":\"2024-06-15T14:30:00Z\"")) + } + + @Test + fun `SendRequestConfig deserializes scheduledDate from ISO-8601`() { + // Given + val json = """{"appId":"test-app","allowDuplicates":false,"showMessageList":false,"scheduledDate":"2024-06-15T14:30:00Z"}""" + + // When + val config = JsonSupport.json.decodeFromString(json) + + // Then + assertEquals("test-app", config.appId) + assertEquals(Instant.parse("2024-06-15T14:30:00Z"), config.scheduledDate) + } + + @Test + fun `SendRequestConfig handles null scheduledDate`() { + // Given + val config = SendRequestConfig(appId = "test-app") + + // When + val json = JsonSupport.json.encodeToString(config) + val restored = JsonSupport.json.decodeFromString(json) + + // Then + assertNull(restored.scheduledDate) + } + + @Test + fun `MessageListRequest serializes date range as Instant`() { + // Given + val request = MessageListRequest( + to = "01012345678", + startDate = Instant.parse("2024-01-01T00:00:00Z"), + endDate = Instant.parse("2024-01-31T23:59:59Z") + ) + + // When + val json = JsonSupport.json.encodeToString(request) + + // Then + assertTrue(json.contains("\"startDate\":\"2024-01-01T00:00:00Z\"")) + assertTrue(json.contains("\"endDate\":\"2024-01-31T23:59:59Z\"")) + } + + @Test + fun `MessageListRequest deserializes date range from Instant`() { + // Given + val json = """{"to":"01012345678","startDate":"2024-01-01T00:00:00Z","endDate":"2024-01-31T23:59:59Z"}""" + + // When + val request = JsonSupport.json.decodeFromString(json) + + // Then + assertEquals(Instant.parse("2024-01-01T00:00:00Z"), request.startDate) + assertEquals(Instant.parse("2024-01-31T23:59:59Z"), request.endDate) + } + + @Test + fun `MessageListBaseRequest serializes date range as Instant`() { + // Given + val request = MessageListBaseRequest( + to = "01012345678", + startDate = Instant.parse("2024-02-01T00:00:00Z"), + endDate = Instant.parse("2024-02-28T23:59:59Z") + ) + + // When + val json = JsonSupport.json.encodeToString(request) + + // Then + assertTrue(json.contains("\"startDate\":\"2024-02-01T00:00:00Z\"")) + assertTrue(json.contains("\"endDate\":\"2024-02-28T23:59:59Z\"")) + } + + @Test + fun `MessageListBaseRequest deserializes date range from Instant`() { + // Given + val json = """{"to":"01012345678","startDate":"2024-02-01T00:00:00Z","endDate":"2024-02-28T23:59:59Z"}""" + + // When + val request = JsonSupport.json.decodeFromString(json) + + // Then + assertEquals(Instant.parse("2024-02-01T00:00:00Z"), request.startDate) + assertEquals(Instant.parse("2024-02-28T23:59:59Z"), request.endDate) + } + + @Test + fun `MultipleDetailMessageSendingRequest serializes scheduledDate`() { + // Given + val request = MultipleDetailMessageSendingRequest( + messages = emptyList(), + scheduledDate = Instant.parse("2024-03-15T09:00:00Z") + ) + + // When + val json = JsonSupport.json.encodeToString(request) + + // Then + assertTrue(json.contains("\"scheduledDate\":\"2024-03-15T09:00:00Z\"")) + } + + @Test + fun `MultipleDetailMessageSendingRequest round-trip preserves scheduledDate`() { + // Given + val original = MultipleDetailMessageSendingRequest( + messages = emptyList(), + scheduledDate = Instant.parse("2024-03-15T09:00:00Z"), + showMessageList = true + ) + + // When + val json = JsonSupport.json.encodeToString(original) + val restored = JsonSupport.json.decodeFromString(json) + + // Then + assertEquals(original.scheduledDate, restored.scheduledDate) + assertEquals(original.showMessageList, restored.showMessageList) + } + + @Test + fun `Instant with nanoseconds precision is preserved`() { + // Given + val preciseInstant = Instant.parse("2024-06-15T14:30:00.123456789Z") + val config = SendRequestConfig(scheduledDate = preciseInstant) + + // When + val json = JsonSupport.json.encodeToString(config) + val restored = JsonSupport.json.decodeFromString(json) + + // Then + assertEquals(preciseInstant, restored.scheduledDate) + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/lib/AuthenticatorTest.kt b/src/test/kotlin/com/solapi/sdk/message/lib/AuthenticatorTest.kt new file mode 100644 index 0000000..848c99e --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/lib/AuthenticatorTest.kt @@ -0,0 +1,136 @@ +package com.solapi.sdk.message.lib + +import com.solapi.sdk.message.exception.SolapiApiKeyException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class AuthenticatorTest { + + @Test + fun `generateAuthInfo returns HMAC-SHA256 format`() { + // Given + val authenticator = Authenticator("test-api-key", "test-api-secret") + + // When + val authInfo = authenticator.generateAuthInfo() + + // Then + assertTrue(authInfo.startsWith("HMAC-SHA256 ")) + assertTrue(authInfo.contains("Apikey=test-api-key")) + assertTrue(authInfo.contains("Date=")) + assertTrue(authInfo.contains("salt=")) + assertTrue(authInfo.contains("signature=")) + } + + @Test + fun `generateAuthInfo includes UTC timestamp`() { + // Given + val authenticator = Authenticator("test-api-key", "test-api-secret") + + // When + val authInfo = authenticator.generateAuthInfo() + + // Then - UTC ISO-8601 형식의 타임스탬프가 포함되어야 함 + val dateMatch = Regex("Date=([^,]+)").find(authInfo) + assertNotNull(dateMatch) + + val dateValue = dateMatch.groupValues[1] + // kotlin.time.Instant.toString() 형식: "2024-01-15T10:30:45.123456789Z" + assertTrue(dateValue.endsWith("Z"), "Timestamp should be in UTC format (ends with Z)") + assertTrue(dateValue.contains("T"), "Timestamp should contain 'T' separator") + } + + @Test + fun `generateAuthInfo includes 32-character salt`() { + // Given + val authenticator = Authenticator("test-api-key", "test-api-secret") + + // When + val authInfo = authenticator.generateAuthInfo() + + // Then + val saltMatch = Regex("salt=([a-f0-9]+)").find(authInfo) + assertNotNull(saltMatch) + assertEquals(32, saltMatch.groupValues[1].length, "Salt should be 32 hex characters (UUID without dashes)") + } + + @Test + fun `generateAuthInfo includes 64-character signature`() { + // Given + val authenticator = Authenticator("test-api-key", "test-api-secret") + + // When + val authInfo = authenticator.generateAuthInfo() + + // Then - HMAC-SHA256 produces 64 hex characters + val signatureMatch = Regex("signature=([a-f0-9]+)").find(authInfo) + assertNotNull(signatureMatch) + assertEquals(64, signatureMatch.groupValues[1].length, "Signature should be 64 hex characters (SHA-256)") + } + + @Test + fun `generateAuthInfo throws exception for empty apiKey`() { + // Given + val authenticator = Authenticator("", "test-api-secret") + + // When & Then + assertFailsWith { + authenticator.generateAuthInfo() + } + } + + @Test + fun `generateAuthInfo throws exception for empty apiSecretKey`() { + // Given + val authenticator = Authenticator("test-api-key", "") + + // When & Then + assertFailsWith { + authenticator.generateAuthInfo() + } + } + + @Test + fun `generateAuthInfo throws exception for both empty keys`() { + // Given + val authenticator = Authenticator("", "") + + // When & Then + assertFailsWith { + authenticator.generateAuthInfo() + } + } + + @Test + fun `generateAuthInfo produces different signatures for different calls`() { + // Given + val authenticator = Authenticator("test-api-key", "test-api-secret") + + // When + val authInfo1 = authenticator.generateAuthInfo() + val authInfo2 = authenticator.generateAuthInfo() + + // Then - salt가 다르므로 signature도 달라야 함 + val signature1 = Regex("signature=([a-f0-9]+)").find(authInfo1)?.groupValues?.get(1) + val signature2 = Regex("signature=([a-f0-9]+)").find(authInfo2)?.groupValues?.get(1) + + assertNotEquals(signature1, signature2, "Each call should produce different signature due to unique salt") + } + + @Test + fun `generateAuthInfo produces consistent format`() { + // Given + val authenticator = Authenticator("my-key", "my-secret") + + // When + val authInfo = authenticator.generateAuthInfo() + + // Then - 정확한 형식 검증 + val pattern = Regex("^HMAC-SHA256 Apikey=my-key, Date=[^,]+, salt=[a-f0-9]{32}, signature=[a-f0-9]{64}$") + assertTrue(pattern.matches(authInfo), "Auth info format should match expected pattern") + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/lib/JsonSupportTest.kt b/src/test/kotlin/com/solapi/sdk/message/lib/JsonSupportTest.kt new file mode 100644 index 0000000..886f8fe --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/lib/JsonSupportTest.kt @@ -0,0 +1,117 @@ +package com.solapi.sdk.message.lib + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Instant +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString + +class JsonSupportTest { + + @Serializable + private data class InstantHolder( + @Contextual + val timestamp: Instant + ) + + @Serializable + private data class NullableInstantHolder( + @Contextual + val timestamp: Instant? = null + ) + + @Test + fun `Instant serializes to ISO-8601 format`() { + // Given + val instant = Instant.parse("2024-01-15T10:30:45.123456789Z") + val holder = InstantHolder(instant) + + // When + val json = JsonSupport.json.encodeToString(holder) + + // Then + assertEquals("""{"timestamp":"2024-01-15T10:30:45.123456789Z"}""", json) + } + + @Test + fun `Instant deserializes from ISO-8601 format`() { + // Given + val json = """{"timestamp":"2024-01-15T10:30:45.123456789Z"}""" + + // When + val holder = JsonSupport.json.decodeFromString(json) + + // Then + val expected = Instant.parse("2024-01-15T10:30:45.123456789Z") + assertEquals(expected, holder.timestamp) + } + + @Test + fun `Instant round-trip serialization preserves value`() { + // Given + val original = InstantHolder(Instant.parse("2024-12-31T23:59:59.999999999Z")) + + // When + val json = JsonSupport.json.encodeToString(original) + val restored = JsonSupport.json.decodeFromString(json) + + // Then + assertEquals(original, restored) + } + + @Test + fun `null Instant serializes correctly`() { + // Given + val holder = NullableInstantHolder(timestamp = null) + + // When + val json = JsonSupport.json.encodeToString(holder) + + // Then - explicitNulls = false 이므로 null 필드는 생략됨 + assertEquals("{}", json) + } + + @Test + fun `missing Instant field deserializes to null`() { + // Given + val json = "{}" + + // When + val holder = JsonSupport.json.decodeFromString(json) + + // Then + assertNull(holder.timestamp) + } + + @Test + fun `Instant with zero nanoseconds serializes correctly`() { + // Given + val instant = Instant.parse("2024-01-15T10:30:45Z") + val holder = InstantHolder(instant) + + // When + val json = JsonSupport.json.encodeToString(holder) + + // Then + assertNotNull(json) + val restored = JsonSupport.json.decodeFromString(json) + assertEquals(instant, restored.timestamp) + } + + @Test + fun `epoch Instant serializes correctly`() { + // Given + val epoch = Instant.fromEpochSeconds(0) + val holder = InstantHolder(epoch) + + // When + val json = JsonSupport.json.encodeToString(holder) + val restored = JsonSupport.json.decodeFromString(json) + + // Then + assertEquals(epoch, restored.timestamp) + } +} From 9a1498fc71f0a25ee7cca1c4551c4da8c7e45739 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 27 Jan 2026 21:32:36 +0900 Subject: [PATCH 06/43] docs: add AGENTS.md knowledge base for SDK navigation and patterns --- AGENTS.md | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fd17cbe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,187 @@ +# SOLAPI Kotlin SDK - Knowledge Base + +**Generated:** 2026-01-27 | **Commit:** 618f129 | **Branch:** main + +## CRITICAL: Development Principles + +**MUST follow `CLAUDE.md` development principles:** + +| Principle | Rule | +|-----------|------| +| **Tidy First** | NEVER mix structural and behavioral changes in a single commit | +| **Commit Separation** | `refactor:` (structural) vs `feat:`/`fix:` (behavioral) in separate commits | +| **TDD** | Write tests first (Red → Green → Refactor) | +| **Single Responsibility** | Classes/methods have single responsibility only | +| **Tidy Code First** | Clean up target area code before adding features | + +```bash +# Correct commit order +git commit -m "refactor: extract validation logic to separate method" +git commit -m "feat: add phone number format validation" + +# Forbidden (mixed commit) +git commit -m "feat: add validation and refactor code" # ❌ FORBIDDEN +``` + +--- + +## OVERVIEW + +Kotlin/Java SDK for SOLAPI messaging platform. Supports SMS, LMS, MMS, Kakao Alimtalk/Brand Message, Naver Smart Notification, RCS, Fax, and Voice messaging. + +## STRUCTURE + +``` +src/main/java/com/solapi/sdk/ +├── SolapiClient.kt # Entry point (use this) +├── NurigoApp.kt # DEPRECATED - do not use +└── message/ + ├── service/ # API operations (send, query, templates) + ├── model/ # Domain models (Message, options) + │ └── kakao/ # 19 files - Kakao templates, buttons, options + ├── dto/ # Request/Response DTOs + ├── exception/ # Exception hierarchy (8 types) + └── lib/ # Internal utilities (auth, helpers) +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +|------|----------|-------| +| **Initialize SDK** | `SolapiClient.kt` | `createInstance(apiKey, secretKey)` | +| **Send messages** | `service/DefaultMessageService.kt` | `send(message)` or `send(messages)` | +| **Query messages** | `service/DefaultMessageService.kt` | `getMessageList(params)` | +| **Upload files** | `service/DefaultMessageService.kt` | `uploadFile(file, type)` for MMS/Fax | +| **Kakao Alimtalk** | `service/DefaultMessageService.kt` | 11 template methods | +| **Create Message** | `model/Message.kt` | Data class with all message options | +| **Kakao options** | `model/kakao/KakaoOption.kt` | Alimtalk/FriendTalk config | +| **Handle errors** | `exception/` | Catch specific `Solapi*Exception` types | +| **HTTP layer** | `service/MessageHttpService.kt` | Retrofit interface (internal) | +| **Auth** | `lib/Authenticator.kt` | HMAC-SHA256 (internal, auto-injected) | + +## CODE PATTERNS + +### Serialization +```kotlin +@Serializable +data class Message( + var to: String? = null, + var from: String? = null, + // All fields nullable with defaults for flexibility +) +``` +- **ALWAYS** use `@Serializable` annotation +- **ALWAYS** use `kotlinx.serialization` (not Jackson/Gson) +- **ALWAYS** provide nullable fields with defaults + +### Service Methods +```kotlin +@JvmOverloads // Java interop +@Throws(SolapiMessageNotReceivedException::class, ...) +fun send(messages: List, config: SendRequestConfig? = null): MultipleDetailMessageSentResponse +``` +- **ALWAYS** annotate with `@JvmOverloads` for optional params +- **ALWAYS** declare `@Throws` for checked exceptions + +### Exception Handling +```kotlin +// Internal: Map error codes to exceptions +when (errorResponse.errorCode) { + "ValidationError" -> throw SolapiBadRequestException(msg) + "InvalidApiKey" -> throw SolapiInvalidApiKeyException(msg) + else -> throw SolapiUnknownException(msg) +} +``` +- Exceptions are `sealed interface` based (SolapiException) +- 8 specific exception types + +### Phone Number Normalization +```kotlin +init { + from = from?.replace("-", "") + to = to?.replace("-", "") +} +``` +- Dashes auto-stripped from phone numbers in `Message.init` + +### Test Conventions +```kotlin +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.assertFailsWith + +class AuthenticatorTest { + @Test + fun `generateAuthInfo returns HMAC-SHA256 format`() { + // Given + val authenticator = Authenticator("api-key", "secret") + // When + val result = authenticator.generateAuthInfo() + // Then + assertTrue(result.startsWith("HMAC-SHA256 ")) + } +} +``` +- **ALWAYS** use `kotlin.test` (NOT JUnit directly) +- **ALWAYS** use Given-When-Then comment structure +- **ALWAYS** use backtick method names for readability + +## ANTI-PATTERNS + +| Forbidden | Required | +|-----------|----------| +| `NurigoApp.initialize()` | `SolapiClient.createInstance()` | +| Direct `DefaultMessageService()` | Use factory via `SolapiClient` | +| Catch generic `Exception` | Catch specific `Solapi*Exception` | +| `net.nurigo.sdk` imports | `com.solapi.sdk` package only | +| Jackson/Gson serialization | `kotlinx.serialization` only | +| Mixed structural+behavioral commits | Separate commits per Tidy First | + +## INTERNAL CLASSES (Do Not Use Directly) + +- `Authenticator` - HMAC auth (auto-injected via interceptor) +- `ErrorResponse` - Internal error DTO +- `MessageHttpService` - Retrofit interface +- `JsonSupport` - Serialization config +- `MapHelper`, `Criterion` - Internal utilities + +## EXCEPTION TYPES + +| Exception | When Thrown | +|-----------|-------------| +| `SolapiApiKeyException` | Empty/missing API key | +| `SolapiInvalidApiKeyException` | Invalid credentials | +| `SolapiBadRequestException` | Validation error, bad input | +| `SolapiEmptyResponseException` | Server returned empty body | +| `SolapiFileUploadException` | File upload failed | +| `SolapiMessageNotReceivedException` | All messages failed (has `failedMessageList`) | +| `SolapiUnknownException` | Unclassified server error | + +## KAKAO INTEGRATION + +19 model files in `model/kakao/`: +- `KakaoOption` - Main config (pfId, templateId, variables) +- `KakaoAlimtalkTemplate*` - Template CRUD models +- `KakaoBrandMessageTemplate` - Brand message with carousels +- `KakaoButton`, `KakaoButtonType` - Button configurations + +Template workflow: `getKakaoAlimtalkTemplateCategories()` → `createKakaoAlimtalkTemplate()` → `requestKakaoAlimtalkTemplateInspection()` + +## BUILD & TEST + +```bash +./gradlew clean build test # Full build +./gradlew test # Tests only +./gradlew shadowJar # Fat JAR with relocated deps +``` + +**Shadow JAR**: Dependencies relocated to `com.solapi.shadow.*` to prevent conflicts. + +## NOTES + +- **Java 8 target**: Code must work on JVM 1.8 +- **Source location**: Kotlin in `src/main/java/` (unconventional but intentional) +- **Tests**: `src/test/kotlin/`, `kotlin.test` (Kotlin native), Given-When-Then style +- **Version**: Auto-generated at `build/generated/source/kotlin/com/solapi/sdk/Version.kt` +- **Docs**: Dokka output to `./docs/`, run `./gradlew dokkaGeneratePublicationHtml` From f3c2528e1a6775f05875dd0b8e6fbb1d4d857b1a Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 27 Jan 2026 22:59:47 +0900 Subject: [PATCH 07/43] feat(kakao): add BMS_FREE message type and BMS storage types --- src/main/java/com/solapi/sdk/message/model/MessageType.kt | 7 ++++++- src/main/java/com/solapi/sdk/message/model/StorageType.kt | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/solapi/sdk/message/model/MessageType.kt b/src/main/java/com/solapi/sdk/message/model/MessageType.kt index 78e5a14..0392c70 100644 --- a/src/main/java/com/solapi/sdk/message/model/MessageType.kt +++ b/src/main/java/com/solapi/sdk/message/model/MessageType.kt @@ -84,5 +84,10 @@ enum class MessageType { /** * 카카오 브랜드 메시지 와이드 리스트 타입 */ - BMS_WIDE_ITEM_LIST; + BMS_WIDE_ITEM_LIST, + + /** + * 카카오 브랜드 메시지 자유형 타입 + */ + BMS_FREE; } \ No newline at end of file diff --git a/src/main/java/com/solapi/sdk/message/model/StorageType.kt b/src/main/java/com/solapi/sdk/message/model/StorageType.kt index 15224bd..23c2187 100644 --- a/src/main/java/com/solapi/sdk/message/model/StorageType.kt +++ b/src/main/java/com/solapi/sdk/message/model/StorageType.kt @@ -5,5 +5,11 @@ enum class StorageType { MMS, DOCUMENT, RCS, - FAX + FAX, + BMS, + BMS_WIDE, + BMS_WIDE_MAIN_ITEM_LIST, + BMS_WIDE_SUB_ITEM_LIST, + BMS_CAROUSEL_FEED_LIST, + BMS_CAROUSEL_COMMERCE_LIST } \ No newline at end of file From 0ed40eb6d3514f80f632c9f6396623b1989e2a13 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 27 Jan 2026 23:01:01 +0900 Subject: [PATCH 08/43] feat(kakao): add BMS Free enum types (ChatBubbleType, ButtonType) --- .../message/model/kakao/bms/BmsButtonType.kt | 49 +++++++++++++++++++ .../model/kakao/bms/BmsChatBubbleType.kt | 49 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsButtonType.kt create mode 100644 src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsChatBubbleType.kt diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsButtonType.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsButtonType.kt new file mode 100644 index 0000000..5a19bd4 --- /dev/null +++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsButtonType.kt @@ -0,0 +1,49 @@ +package com.solapi.sdk.message.model.kakao.bms + +import kotlinx.serialization.Serializable + +/** + * BMS Free 버튼 타입 + */ +@Serializable +enum class BmsButtonType { + /** + * 웹링크 + */ + WL, + + /** + * 앱링크 + */ + AL, + + /** + * 채널 추가 + */ + AC, + + /** + * 봇 키워드 + */ + BK, + + /** + * 메시지 전달 + */ + MD, + + /** + * 상담 요청 + */ + BC, + + /** + * 봇 전환 + */ + BT, + + /** + * 비즈니스폼 + */ + BF +} diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsChatBubbleType.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsChatBubbleType.kt new file mode 100644 index 0000000..0ea09d9 --- /dev/null +++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsChatBubbleType.kt @@ -0,0 +1,49 @@ +package com.solapi.sdk.message.model.kakao.bms + +import kotlinx.serialization.Serializable + +/** + * BMS Free 채팅 버블 타입 + */ +@Serializable +enum class BmsChatBubbleType { + /** + * 텍스트 타입 + */ + TEXT, + + /** + * 이미지 타입 + */ + IMAGE, + + /** + * 와이드 타입 + */ + WIDE, + + /** + * 와이드 리스트 타입 + */ + WIDE_ITEM_LIST, + + /** + * 커머스 타입 + */ + COMMERCE, + + /** + * 캐러셀 피드 타입 + */ + CAROUSEL_FEED, + + /** + * 캐러셀 커머스 타입 + */ + CAROUSEL_COMMERCE, + + /** + * 프리미엄 비디오 타입 + */ + PREMIUM_VIDEO +} From 97c2c4a30d95ccdd0740542591ad542cc567d73f Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 27 Jan 2026 23:01:54 +0900 Subject: [PATCH 09/43] feat(kakao): add BMS Free data classes (Button, Commerce, Coupon, Video) --- .../sdk/message/model/kakao/bms/BmsButton.kt | 15 +++++++++++ .../message/model/kakao/bms/BmsCommerce.kt | 12 +++++++++ .../sdk/message/model/kakao/bms/BmsCoupon.kt | 26 +++++++++++++++++++ .../sdk/message/model/kakao/bms/BmsVideo.kt | 10 +++++++ 4 files changed, 63 insertions(+) create mode 100644 src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsButton.kt create mode 100644 src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCommerce.kt create mode 100644 src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCoupon.kt create mode 100644 src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsVideo.kt diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsButton.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsButton.kt new file mode 100644 index 0000000..44ffe6b --- /dev/null +++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsButton.kt @@ -0,0 +1,15 @@ +package com.solapi.sdk.message.model.kakao.bms + +import kotlinx.serialization.Serializable + +@Serializable +data class BmsButton( + var linkType: BmsButtonType? = null, + var name: String? = null, + var linkMobile: String? = null, + var linkPc: String? = null, + var linkAndroid: String? = null, + var linkIos: String? = null, + var chatExtra: String? = null, + var targetOut: Boolean? = null +) diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCommerce.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCommerce.kt new file mode 100644 index 0000000..70811c2 --- /dev/null +++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCommerce.kt @@ -0,0 +1,12 @@ +package com.solapi.sdk.message.model.kakao.bms + +import kotlinx.serialization.Serializable + +@Serializable +data class BmsCommerce( + var title: String? = null, + var regularPrice: Long? = null, + var discountPrice: Long? = null, + var discountRate: Int? = null, + var discountFixed: Long? = null +) diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCoupon.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCoupon.kt new file mode 100644 index 0000000..ed1b78d --- /dev/null +++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCoupon.kt @@ -0,0 +1,26 @@ +package com.solapi.sdk.message.model.kakao.bms + +import kotlinx.serialization.Serializable + +/** + * BMS Free 쿠폰 정보 + * + * @property title 쿠폰 제목 (서버에서 필수). + * 허용 형식 (5가지만 가능): + * - "${숫자}원 할인 쿠폰" (1~99,999,999) + * - "${숫자}% 할인 쿠폰" (1~100) + * - "배송비 할인 쿠폰" + * - "${7자 이내} 무료 쿠폰" + * - "${7자 이내} UP 쿠폰" + * @property description 쿠폰 설명 (서버에서 필수). + * 길이 제한: WIDE/WIDE_ITEM_LIST는 최대 18자, 그 외는 최대 12자 + */ +@Serializable +data class BmsCoupon( + var title: String? = null, + var description: String? = null, + var linkMobile: String? = null, + var linkPc: String? = null, + var linkAndroid: String? = null, + var linkIos: String? = null +) diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsVideo.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsVideo.kt new file mode 100644 index 0000000..3acc3dc --- /dev/null +++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsVideo.kt @@ -0,0 +1,10 @@ +package com.solapi.sdk.message.model.kakao.bms + +import kotlinx.serialization.Serializable + +@Serializable +data class BmsVideo( + var videoUrl: String? = null, + var imageId: String? = null, + var imageLink: String? = null +) From f5d2c7386981d1c3737c209a4f75a3f977a10d04 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 27 Jan 2026 23:02:52 +0900 Subject: [PATCH 10/43] feat(kakao): add BMS Free WideItem and Carousel models --- .../sdk/message/model/kakao/bms/BmsCarousel.kt | 10 ++++++++++ .../message/model/kakao/bms/BmsCarouselHead.kt | 14 ++++++++++++++ .../message/model/kakao/bms/BmsCarouselItem.kt | 18 ++++++++++++++++++ .../message/model/kakao/bms/BmsCarouselTail.kt | 11 +++++++++++ .../message/model/kakao/bms/BmsMainWideItem.kt | 13 +++++++++++++ .../message/model/kakao/bms/BmsSubWideItem.kt | 13 +++++++++++++ 6 files changed, 79 insertions(+) create mode 100644 src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarousel.kt create mode 100644 src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselHead.kt create mode 100644 src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselItem.kt create mode 100644 src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselTail.kt create mode 100644 src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsMainWideItem.kt create mode 100644 src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsSubWideItem.kt diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarousel.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarousel.kt new file mode 100644 index 0000000..137c332 --- /dev/null +++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarousel.kt @@ -0,0 +1,10 @@ +package com.solapi.sdk.message.model.kakao.bms + +import kotlinx.serialization.Serializable + +@Serializable +data class BmsCarousel( + var head: BmsCarouselHead? = null, + var list: List? = null, + var tail: BmsCarouselTail? = null +) diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselHead.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselHead.kt new file mode 100644 index 0000000..dadd050 --- /dev/null +++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselHead.kt @@ -0,0 +1,14 @@ +package com.solapi.sdk.message.model.kakao.bms + +import kotlinx.serialization.Serializable + +@Serializable +data class BmsCarouselHead( + var header: String? = null, + var content: String? = null, + var imageId: String? = null, + var linkMobile: String? = null, + var linkPc: String? = null, + var linkAndroid: String? = null, + var linkIos: String? = null +) diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselItem.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselItem.kt new file mode 100644 index 0000000..c5e6088 --- /dev/null +++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselItem.kt @@ -0,0 +1,18 @@ +package com.solapi.sdk.message.model.kakao.bms + +import kotlinx.serialization.Serializable + +@Serializable +data class BmsCarouselItem( + // FeedItem 전용 필드 + var header: String? = null, + var content: String? = null, + // CommerceItem 전용 필드 + var commerce: BmsCommerce? = null, + var additionalContent: String? = null, + // 공통 필드 + var imageId: String? = null, + var imageLink: String? = null, + var buttons: List? = null, + var coupon: BmsCoupon? = null +) diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselTail.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselTail.kt new file mode 100644 index 0000000..fc545cf --- /dev/null +++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselTail.kt @@ -0,0 +1,11 @@ +package com.solapi.sdk.message.model.kakao.bms + +import kotlinx.serialization.Serializable + +@Serializable +data class BmsCarouselTail( + var linkMobile: String? = null, + var linkPc: String? = null, + var linkAndroid: String? = null, + var linkIos: String? = null +) diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsMainWideItem.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsMainWideItem.kt new file mode 100644 index 0000000..20d8d21 --- /dev/null +++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsMainWideItem.kt @@ -0,0 +1,13 @@ +package com.solapi.sdk.message.model.kakao.bms + +import kotlinx.serialization.Serializable + +@Serializable +data class BmsMainWideItem( + var title: String? = null, + var imageId: String? = null, + var linkMobile: String? = null, + var linkPc: String? = null, + var linkAndroid: String? = null, + var linkIos: String? = null +) diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsSubWideItem.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsSubWideItem.kt new file mode 100644 index 0000000..407df95 --- /dev/null +++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsSubWideItem.kt @@ -0,0 +1,13 @@ +package com.solapi.sdk.message.model.kakao.bms + +import kotlinx.serialization.Serializable + +@Serializable +data class BmsSubWideItem( + var title: String? = null, + var imageId: String? = null, + var linkMobile: String? = null, + var linkPc: String? = null, + var linkAndroid: String? = null, + var linkIos: String? = null +) From f7333703a205ae1c4d33d4392a84701522ce4db7 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 27 Jan 2026 23:03:30 +0900 Subject: [PATCH 11/43] feat(kakao): expand KakaoBmsOption with all BMS Free fields --- .../sdk/message/model/kakao/KakaoBmsOption.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/KakaoBmsOption.kt b/src/main/java/com/solapi/sdk/message/model/kakao/KakaoBmsOption.kt index 719f944..98ab7d9 100644 --- a/src/main/java/com/solapi/sdk/message/model/kakao/KakaoBmsOption.kt +++ b/src/main/java/com/solapi/sdk/message/model/kakao/KakaoBmsOption.kt @@ -1,9 +1,24 @@ package com.solapi.sdk.message.model.kakao +import com.solapi.sdk.message.model.kakao.bms.* import kotlinx.serialization.Serializable @Serializable data class KakaoBmsOption( - var targeting: KakaoBmsTargeting? = null + var targeting: KakaoBmsTargeting? = null, + var chatBubbleType: BmsChatBubbleType? = null, + var adult: Boolean? = null, + var header: String? = null, + var imageId: String? = null, + var imageLink: String? = null, + var additionalContent: String? = null, + var content: String? = null, + var carousel: BmsCarousel? = null, + var mainWideItem: BmsMainWideItem? = null, + var subWideItemList: List? = null, + var buttons: List? = null, + var coupon: BmsCoupon? = null, + var commerce: BmsCommerce? = null, + var video: BmsVideo? = null ) From f37b7a647c5d63067bd5e88b0660c947dae9b098 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 27 Jan 2026 23:04:44 +0900 Subject: [PATCH 12/43] test(kakao): add BMS Free serialization tests --- .../model/kakao/bms/BmsSerializationTest.kt | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 src/test/kotlin/com/solapi/sdk/message/model/kakao/bms/BmsSerializationTest.kt diff --git a/src/test/kotlin/com/solapi/sdk/message/model/kakao/bms/BmsSerializationTest.kt b/src/test/kotlin/com/solapi/sdk/message/model/kakao/bms/BmsSerializationTest.kt new file mode 100644 index 0000000..2f5c641 --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/model/kakao/bms/BmsSerializationTest.kt @@ -0,0 +1,270 @@ +package com.solapi.sdk.message.model.kakao.bms + +import com.solapi.sdk.message.lib.JsonSupport +import com.solapi.sdk.message.model.kakao.KakaoBmsOption +import com.solapi.sdk.message.model.kakao.KakaoBmsTargeting +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.serialization.encodeToString + +class BmsSerializationTest { + + @Test + fun `BmsChatBubbleType serializes to uppercase string`() { + // Given + val chatBubbleType = BmsChatBubbleType.PREMIUM_VIDEO + + // When + val json = JsonSupport.json.encodeToString(chatBubbleType) + + // Then + assertTrue(json.contains("\"PREMIUM_VIDEO\"")) + } + + @Test + fun `BmsButtonType serializes correctly`() { + // Given + val buttonTypeWL = BmsButtonType.WL + val buttonTypeBF = BmsButtonType.BF + + // When + val jsonWL = JsonSupport.json.encodeToString(buttonTypeWL) + val jsonBF = JsonSupport.json.encodeToString(buttonTypeBF) + + // Then + assertTrue(jsonWL.contains("\"WL\"")) + assertTrue(jsonBF.contains("\"BF\"")) + } + + @Test + fun `BmsButton serializes all fields correctly`() { + // Given + val button = BmsButton( + linkType = BmsButtonType.WL, + name = "버튼", + linkMobile = "https://example.com", + targetOut = true + ) + + // When + val json = JsonSupport.json.encodeToString(button) + + // Then + assertTrue(json.contains("\"linkType\":\"WL\"")) + assertTrue(json.contains("\"name\":\"버튼\"")) + assertTrue(json.contains("\"linkMobile\":\"https://example.com\"")) + assertTrue(json.contains("\"targetOut\":true")) + } + + @Test + fun `BmsCommerce serializes prices as numbers without quotes`() { + // Given + val commerce = BmsCommerce( + title = "상품", + regularPrice = 129000, + discountPrice = 99000, + discountRate = 23 + ) + + // When + val json = JsonSupport.json.encodeToString(commerce) + + // Then + assertTrue(json.contains("\"regularPrice\":129000")) + assertTrue(json.contains("\"discountPrice\":99000")) + assertTrue(json.contains("\"discountRate\":23")) + assertFalse(json.contains("\"regularPrice\":\"129000\"")) + } + + @Test + fun `BmsCoupon serializes with required fields`() { + // Given + val coupon = BmsCoupon( + title = "5000원 할인 쿠폰", + description = "설명", + linkMobile = "https://example.com" + ) + + // When + val json = JsonSupport.json.encodeToString(coupon) + + // Then + assertTrue(json.contains("\"title\":\"5000원 할인 쿠폰\"")) + assertTrue(json.contains("\"description\":\"설명\"")) + assertTrue(json.contains("\"linkMobile\":\"https://example.com\"")) + } + + @Test + fun `BmsVideo serializes all fields`() { + // Given + val video = BmsVideo( + videoUrl = "https://tv.kakao.com/v/123456", + imageId = "IMG001", + imageLink = "https://example.com" + ) + + // When + val json = JsonSupport.json.encodeToString(video) + + // Then + assertTrue(json.contains("\"videoUrl\":\"https://tv.kakao.com/v/123456\"")) + assertTrue(json.contains("\"imageId\":\"IMG001\"")) + assertTrue(json.contains("\"imageLink\":\"https://example.com\"")) + } + + @Test + fun `BmsMainWideItem serializes without header and content fields`() { + // Given + val wideItem = BmsMainWideItem( + title = "타이틀", + imageId = "IMG123", + linkMobile = "https://example.com" + ) + + // When + val json = JsonSupport.json.encodeToString(wideItem) + + // Then + assertTrue(json.contains("\"title\":\"타이틀\"")) + assertTrue(json.contains("\"imageId\":\"IMG123\"")) + assertTrue(json.contains("\"linkMobile\":\"https://example.com\"")) + assertFalse(json.contains("\"header\"")) + assertFalse(json.contains("\"content\"")) + assertFalse(json.contains("\"buttons\"")) + } + + @Test + fun `BmsCarouselItem with Feed fields serializes correctly`() { + // Given + val item = BmsCarouselItem( + header = "제목", + content = "내용", + imageId = "IMG123" + ) + + // When + val json = JsonSupport.json.encodeToString(item) + + // Then + assertTrue(json.contains("\"header\":\"제목\"")) + assertTrue(json.contains("\"content\":\"내용\"")) + assertTrue(json.contains("\"imageId\":\"IMG123\"")) + } + + @Test + fun `BmsCarouselItem with Commerce fields serializes correctly`() { + // Given + val commerce = BmsCommerce(title = "상품", regularPrice = 10000) + val item = BmsCarouselItem( + commerce = commerce, + additionalContent = "추가정보", + imageId = "IMG123" + ) + + // When + val json = JsonSupport.json.encodeToString(item) + + // Then + assertTrue(json.contains("\"commerce\"")) + assertTrue(json.contains("\"additionalContent\":\"추가정보\"")) + assertTrue(json.contains("\"imageId\":\"IMG123\"")) + } + + @Test + fun `KakaoBmsOption maintains backward compatibility with targeting only`() { + // Given + val option = KakaoBmsOption(targeting = KakaoBmsTargeting.I) + + // When + val json = JsonSupport.json.encodeToString(option) + + // Then + assertTrue(json.contains("\"targeting\":\"I\"")) + } + + @Test + fun `KakaoBmsOption with CAROUSEL_FEED serializes without head field`() { + // Given + val option = KakaoBmsOption( + targeting = KakaoBmsTargeting.I, + chatBubbleType = BmsChatBubbleType.CAROUSEL_FEED, + carousel = BmsCarousel( + list = listOf( + BmsCarouselItem(header = "제목", content = "내용", imageId = "IMG123") + ), + tail = BmsCarouselTail(linkMobile = "https://example.com") + ) + ) + + // When + val json = JsonSupport.json.encodeToString(option) + + // Then + assertTrue(json.contains("\"chatBubbleType\":\"CAROUSEL_FEED\"")) + assertTrue(json.contains("\"carousel\"")) + assertTrue(json.contains("\"list\"")) + assertTrue(json.contains("\"header\":\"제목\"")) + assertFalse(json.contains("\"head\"")) + } + + @Test + fun `KakaoBmsOption with CAROUSEL_COMMERCE serializes with head field`() { + // Given + val option = KakaoBmsOption( + targeting = KakaoBmsTargeting.I, + chatBubbleType = BmsChatBubbleType.CAROUSEL_COMMERCE, + carousel = BmsCarousel( + head = BmsCarouselHead(header = "인트로", content = "설명", imageId = "IMG000"), + list = listOf( + BmsCarouselItem( + commerce = BmsCommerce(title = "상품", regularPrice = 129000), + imageId = "IMG123" + ) + ), + tail = BmsCarouselTail(linkMobile = "https://example.com") + ) + ) + + // When + val json = JsonSupport.json.encodeToString(option) + + // Then + assertTrue(json.contains("\"chatBubbleType\":\"CAROUSEL_COMMERCE\"")) + assertTrue(json.contains("\"carousel\"")) + assertTrue(json.contains("\"head\"")) + assertTrue(json.contains("\"header\":\"인트로\"")) + assertTrue(json.contains("\"commerce\"")) + } + + @Test + fun `KakaoBmsOption serializes all field types correctly`() { + // Given + val option = KakaoBmsOption( + targeting = KakaoBmsTargeting.I, + chatBubbleType = BmsChatBubbleType.COMMERCE, + adult = false, + header = "헤더", + imageId = "IMG001", + imageLink = "https://example.com", + additionalContent = "추가내용", + content = "본문", + buttons = listOf( + BmsButton(linkType = BmsButtonType.WL, name = "버튼", linkMobile = "https://example.com") + ), + commerce = BmsCommerce(title = "상품", regularPrice = 50000), + video = BmsVideo(videoUrl = "https://tv.kakao.com/v/123456") + ) + + // When + val json = JsonSupport.json.encodeToString(option) + + // Then + assertTrue(json.contains("\"chatBubbleType\":\"COMMERCE\"")) + assertTrue(json.contains("\"targeting\":\"I\"")) + assertTrue(json.contains("\"adult\":false")) + assertTrue(json.contains("\"buttons\"")) + assertTrue(json.contains("\"commerce\"")) + assertTrue(json.contains("\"video\"")) + } +} From 54cdec3ad3ad0c7ffc035ec481bf5bfb56ae7b3f Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 27 Jan 2026 23:05:56 +0900 Subject: [PATCH 13/43] Update gitignore rule --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 90d6b69..fc3258c 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ secret.key # Generated files /docs/ -manual/ \ No newline at end of file +manual/ + +# OMO +.sisyphus/ \ No newline at end of file From 7175035f0b4f51fbce85eadd1624ef94911a39f5 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 28 Jan 2026 11:30:00 +0900 Subject: [PATCH 14/43] test(kakao): add BMS Free E2E test suite Add comprehensive E2E tests for all 8 BMS Free chat bubble types with both minimal and full field configurations. Includes test utilities, test images, and environment variable configuration. - Add BmsTestUtils helper functions for creating BMS options - Add BmsFreeE2ETest with 16 type tests + 5 error case tests - Add test images (800x800, 800x400, 400x400 PNG) - Add .env.example template for required credentials - Update .gitignore to exclude .env files Co-Authored-By: Claude Opus 4.5 --- .env.example | 10 + .gitignore | 2 + .../solapi/sdk/message/e2e/BmsFreeE2ETest.kt | 907 ++++++++++++++++++ .../solapi/sdk/message/lib/BmsTestUtils.kt | 545 +++++++++++ src/test/resources/images/test-image-1to1.png | Bin 0 -> 1057 bytes src/test/resources/images/test-image-2to1.png | Bin 0 -> 1571 bytes src/test/resources/images/test-image.png | Bin 0 -> 3059 bytes 7 files changed, 1464 insertions(+) create mode 100644 .env.example create mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/lib/BmsTestUtils.kt create mode 100644 src/test/resources/images/test-image-1to1.png create mode 100644 src/test/resources/images/test-image-2to1.png create mode 100644 src/test/resources/images/test-image.png diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..62cbf75 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# SOLAPI API Credentials +SOLAPI_API_KEY= +SOLAPI_API_SECRET= + +# Kakao Business Channel +KAKAO_PF_ID= + +# Phone Numbers (Optional) +SENDER_NUMBER= +TEST_PHONE_NUMBER= diff --git a/.gitignore b/.gitignore index fc3258c..0e656fb 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,8 @@ signing.gpg *.gpg *.asc secret.key +.env +.env.local # Generated files /docs/ diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt new file mode 100644 index 0000000..c8d43fd --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt @@ -0,0 +1,907 @@ +package com.solapi.sdk.message.e2e + +import com.solapi.sdk.SolapiClient +import com.solapi.sdk.message.lib.BmsTestUtils +import com.solapi.sdk.message.model.Message +import com.solapi.sdk.message.model.MessageType +import com.solapi.sdk.message.model.StorageType +import com.solapi.sdk.message.model.kakao.KakaoOption +import com.solapi.sdk.message.model.kakao.bms.BmsCoupon +import com.solapi.sdk.message.service.DefaultMessageService +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import java.io.File + +/** + * BMS Free 발송 E2E 테스트 + * + * 환경변수 설정 필요: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - KAKAO_PF_ID: 카카오 비즈니스 채널 ID + * - SENDER_NUMBER: 등록된 발신번호 + * - TEST_PHONE_NUMBER: 테스트 수신번호 + */ +class BmsFreeE2ETest { + + private val apiKey: String? = System.getenv("SOLAPI_API_KEY") + private val apiSecret: String? = System.getenv("SOLAPI_API_SECRET") + private val pfId: String? = System.getenv("KAKAO_PF_ID") + private val senderNumber: String = System.getenv("SENDER_NUMBER") ?: "01000000000" + private val testPhoneNumber: String = System.getenv("TEST_PHONE_NUMBER") ?: "01000000000" + + private val messageService: DefaultMessageService? by lazy { + if (apiKey != null && apiSecret != null) { + SolapiClient.createInstance(apiKey, apiSecret) + } else { + null + } + } + + /** + * 환경변수 설정 여부 확인 + * @return 환경변수가 설정되었으면 true, 아니면 false + */ + private fun assumeEnvironmentConfigured(): Boolean { + if (apiKey.isNullOrBlank() || apiSecret.isNullOrBlank() || pfId.isNullOrBlank()) { + println("환경변수가 설정되지 않아 테스트를 건너뜁니다. (SOLAPI_API_KEY, SOLAPI_API_SECRET, KAKAO_PF_ID 필요)") + return false + } + return true + } + + /** + * 테스트 이미지 업로드 + * @param filename 리소스 파일명 + * @return 업로드된 이미지 ID + */ + private fun uploadTestImage(filename: String = "test-image.png"): String? { + val imageUrl = javaClass.classLoader.getResource("images/$filename") + if (imageUrl == null) { + println("테스트 이미지가 없어 건너뜁니다: images/$filename") + return null + } + val file = File(imageUrl.toURI()) + return messageService?.uploadFile(file, StorageType.KAKAO) + } + + /** + * BMS Free 메시지 생성 + */ + private fun createBmsFreeMessage(kakaoOption: KakaoOption): Message = Message( + type = MessageType.BMS_FREE, + from = senderNumber, + to = testPhoneNumber, + kakaoOptions = kakaoOption + ) + + // ==================== TEXT 타입 테스트 ==================== + + @Test + fun `TEXT 타입 - 최소 구조`() { + if (!assumeEnvironmentConfigured()) return + + val bmsOption = BmsTestUtils.createTextBmsOption( + content = "BMS Free TEXT 최소 구조 테스트" + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("TEXT 최소 구조 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `TEXT 타입 - 전체 필드`() { + if (!assumeEnvironmentConfigured()) return + + val buttons = listOf( + BmsTestUtils.createWebLinkButton("바로가기", "https://example.com"), + BmsTestUtils.createChannelAddButton("채널 추가") + ) + + val coupon = BmsTestUtils.createPercentCoupon(10, "할인쿠폰") + + val bmsOption = BmsTestUtils.createTextBmsOptionFull( + content = "BMS Free TEXT 전체 필드 테스트", + header = "TEXT 헤더", + additionalContent = "추가 내용입니다", + buttons = buttons, + coupon = coupon, + adult = false + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("TEXT 전체 필드 - groupId: ${response.groupInfo?.groupId}") + } + + // ==================== IMAGE 타입 테스트 ==================== + + @Test + fun `IMAGE 타입 - 최소 구조`() { + if (!assumeEnvironmentConfigured()) return + + val imageId = uploadTestImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val bmsOption = BmsTestUtils.createImageBmsOption( + imageId = imageId, + content = "BMS Free IMAGE 최소 구조 테스트" + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("IMAGE 최소 구조 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `IMAGE 타입 - 전체 필드`() { + if (!assumeEnvironmentConfigured()) return + + val imageId = uploadTestImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val buttons = listOf( + BmsTestUtils.createWebLinkButton("자세히 보기", "https://example.com"), + BmsTestUtils.createAppLinkButton("앱 열기", "intent://main", "iosapp://main") + ) + + val coupon = BmsTestUtils.createWonCoupon(5000, "5000원 할인") + + val bmsOption = BmsTestUtils.createImageBmsOptionFull( + imageId = imageId, + content = "BMS Free IMAGE 전체 필드 테스트", + header = "IMAGE 헤더", + imageLink = "https://example.com/image", + additionalContent = "추가 내용", + buttons = buttons, + coupon = coupon, + adult = false + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("IMAGE 전체 필드 - groupId: ${response.groupInfo?.groupId}") + } + + // ==================== WIDE 타입 테스트 ==================== + + @Test + fun `WIDE 타입 - 최소 구조`() { + if (!assumeEnvironmentConfigured()) return + + val imageId = uploadTestImage("test-image-2to1.png") ?: uploadTestImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val bmsOption = BmsTestUtils.createWideBmsOption( + imageId = imageId, + content = "BMS Free WIDE 최소 구조 테스트" + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("WIDE 최소 구조 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `WIDE 타입 - 전체 필드`() { + if (!assumeEnvironmentConfigured()) return + + val imageId = uploadTestImage("test-image-2to1.png") ?: uploadTestImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val buttons = listOf( + BmsTestUtils.createWebLinkButton("바로가기", "https://example.com"), + BmsTestUtils.createBotKeywordButton("문의하기") + ) + + val coupon = BmsTestUtils.createShippingCoupon("무료배송") + + val bmsOption = BmsTestUtils.createWideBmsOptionFull( + imageId = imageId, + content = "BMS Free WIDE 전체 필드 테스트", + header = "WIDE 헤더", + imageLink = "https://example.com/wide", + additionalContent = "와이드 추가 내용", + buttons = buttons, + coupon = coupon, + adult = false + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("WIDE 전체 필드 - groupId: ${response.groupInfo?.groupId}") + } + + // ==================== WIDE_ITEM_LIST 타입 테스트 ==================== + + @Test + fun `WIDE_ITEM_LIST 타입 - 최소 구조`() { + if (!assumeEnvironmentConfigured()) return + + val mainImageId = uploadTestImage("test-image-2to1.png") ?: uploadTestImage() + val subImageId = uploadTestImage("test-image-1to1.png") ?: uploadTestImage() + if (mainImageId == null || subImageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val mainWideItem = BmsTestUtils.createMainWideItem( + imageId = mainImageId, + title = "메인 아이템" + ) + + val subWideItemList = listOf( + BmsTestUtils.createSubWideItem(subImageId, "서브 아이템 1"), + BmsTestUtils.createSubWideItem(subImageId, "서브 아이템 2") + ) + + val bmsOption = BmsTestUtils.createWideItemListBmsOption( + mainWideItem = mainWideItem, + subWideItemList = subWideItemList + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("WIDE_ITEM_LIST 최소 구조 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `WIDE_ITEM_LIST 타입 - 전체 필드`() { + if (!assumeEnvironmentConfigured()) return + + val mainImageId = uploadTestImage("test-image-2to1.png") ?: uploadTestImage() + val subImageId = uploadTestImage("test-image-1to1.png") ?: uploadTestImage() + if (mainImageId == null || subImageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val mainWideItem = BmsTestUtils.createMainWideItem( + imageId = mainImageId, + title = "메인 아이템 타이틀", + linkMobile = "https://example.com/main" + ) + + val subWideItemList = listOf( + BmsTestUtils.createSubWideItem(subImageId, "서브 아이템 1", "https://example.com/sub1"), + BmsTestUtils.createSubWideItem(subImageId, "서브 아이템 2", "https://example.com/sub2"), + BmsTestUtils.createSubWideItem(subImageId, "서브 아이템 3", "https://example.com/sub3") + ) + + val buttons = listOf( + BmsTestUtils.createWebLinkButton("전체보기", "https://example.com/all") + ) + + val coupon = BmsTestUtils.createFreeCoupon("커피", "무료쿠폰") + + val bmsOption = BmsTestUtils.createWideItemListBmsOptionFull( + mainWideItem = mainWideItem, + subWideItemList = subWideItemList, + header = "WIDE_ITEM_LIST 헤더", + buttons = buttons, + coupon = coupon, + adult = false + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("WIDE_ITEM_LIST 전체 필드 - groupId: ${response.groupInfo?.groupId}") + } + + // ==================== COMMERCE 타입 테스트 ==================== + + @Test + fun `COMMERCE 타입 - 최소 구조`() { + if (!assumeEnvironmentConfigured()) return + + val imageId = uploadTestImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val commerce = BmsTestUtils.createCommerce( + title = "테스트 상품", + regularPrice = 50000 + ) + + val buttons = listOf( + BmsTestUtils.createWebLinkButton("구매하기", "https://example.com/buy") + ) + + val bmsOption = BmsTestUtils.createCommerceBmsOption( + imageId = imageId, + commerce = commerce, + buttons = buttons + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("COMMERCE 최소 구조 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `COMMERCE 타입 - 전체 필드`() { + if (!assumeEnvironmentConfigured()) return + + val imageId = uploadTestImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val commerce = BmsTestUtils.createCommerce( + title = "프리미엄 상품", + regularPrice = 129000, + discountPrice = 99000, + discountRate = 23 + ) + + val buttons = listOf( + BmsTestUtils.createWebLinkButton("구매하기", "https://example.com/buy"), + BmsTestUtils.createWebLinkButton("장바구니", "https://example.com/cart") + ) + + val coupon = BmsTestUtils.createUpCoupon("포인트", "2배 적립") + + val bmsOption = BmsTestUtils.createCommerceBmsOptionFull( + imageId = imageId, + commerce = commerce, + buttons = buttons, + header = "COMMERCE 헤더", + imageLink = "https://example.com/product", + additionalContent = "무료배송 | 오늘 출발", + coupon = coupon, + adult = false + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("COMMERCE 전체 필드 - groupId: ${response.groupInfo?.groupId}") + } + + // ==================== CAROUSEL_FEED 타입 테스트 ==================== + + @Test + fun `CAROUSEL_FEED 타입 - 최소 구조`() { + if (!assumeEnvironmentConfigured()) return + + val imageId = uploadTestImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val carouselItems = listOf( + BmsTestUtils.createCarouselFeedItem(imageId, "아이템 1", "내용 1"), + BmsTestUtils.createCarouselFeedItem(imageId, "아이템 2", "내용 2") + ) + + val bmsOption = BmsTestUtils.createCarouselFeedBmsOption( + carouselItems = carouselItems + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("CAROUSEL_FEED 최소 구조 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `CAROUSEL_FEED 타입 - 전체 필드`() { + if (!assumeEnvironmentConfigured()) return + + val imageId = uploadTestImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val itemButtons = listOf( + BmsTestUtils.createWebLinkButton("자세히", "https://example.com/detail") + ) + + val carouselItems = listOf( + BmsTestUtils.createCarouselFeedItem( + imageId = imageId, + header = "피드 아이템 1", + content = "피드 내용 1", + imageLink = "https://example.com/1", + buttons = itemButtons + ), + BmsTestUtils.createCarouselFeedItem( + imageId = imageId, + header = "피드 아이템 2", + content = "피드 내용 2", + imageLink = "https://example.com/2", + buttons = itemButtons, + coupon = BmsTestUtils.createPercentCoupon(5, "할인") + ), + BmsTestUtils.createCarouselFeedItem( + imageId = imageId, + header = "피드 아이템 3", + content = "피드 내용 3", + imageLink = "https://example.com/3", + buttons = itemButtons + ) + ) + + val tail = BmsTestUtils.createCarouselTail( + linkMobile = "https://example.com/more", + linkPc = "https://example.com/more" + ) + + val bmsOption = BmsTestUtils.createCarouselFeedBmsOptionFull( + carouselItems = carouselItems, + tail = tail, + adult = false + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("CAROUSEL_FEED 전체 필드 - groupId: ${response.groupInfo?.groupId}") + } + + // ==================== CAROUSEL_COMMERCE 타입 테스트 ==================== + + @Test + fun `CAROUSEL_COMMERCE 타입 - 최소 구조`() { + if (!assumeEnvironmentConfigured()) return + + val imageId = uploadTestImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val carouselItems = listOf( + BmsTestUtils.createCarouselCommerceItem( + imageId = imageId, + commerce = BmsTestUtils.createCommerce("상품 1", 30000) + ), + BmsTestUtils.createCarouselCommerceItem( + imageId = imageId, + commerce = BmsTestUtils.createCommerce("상품 2", 40000) + ) + ) + + val bmsOption = BmsTestUtils.createCarouselCommerceBmsOption( + carouselItems = carouselItems + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("CAROUSEL_COMMERCE 최소 구조 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `CAROUSEL_COMMERCE 타입 - 전체 필드`() { + if (!assumeEnvironmentConfigured()) return + + val imageId = uploadTestImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val head = BmsTestUtils.createCarouselHead( + header = "베스트 상품", + content = "이번 주 인기 상품", + imageId = imageId, + linkMobile = "https://example.com/best" + ) + + val itemButtons = listOf( + BmsTestUtils.createWebLinkButton("구매", "https://example.com/buy") + ) + + val carouselItems = listOf( + BmsTestUtils.createCarouselCommerceItem( + imageId = imageId, + commerce = BmsTestUtils.createCommerce("상품 A", 50000, 40000, 20), + additionalContent = "무료배송", + imageLink = "https://example.com/a", + buttons = itemButtons + ), + BmsTestUtils.createCarouselCommerceItem( + imageId = imageId, + commerce = BmsTestUtils.createCommerce("상품 B", 80000, 60000, 25), + additionalContent = "오늘 출발", + imageLink = "https://example.com/b", + buttons = itemButtons, + coupon = BmsTestUtils.createWonCoupon(3000, "할인") + ), + BmsTestUtils.createCarouselCommerceItem( + imageId = imageId, + commerce = BmsTestUtils.createCommerce("상품 C", 35000), + additionalContent = "인기상품", + imageLink = "https://example.com/c", + buttons = itemButtons + ) + ) + + val tail = BmsTestUtils.createCarouselTail( + linkMobile = "https://example.com/all", + linkPc = "https://example.com/all" + ) + + val bmsOption = BmsTestUtils.createCarouselCommerceBmsOptionFull( + carouselItems = carouselItems, + tail = tail, + head = head, + adult = false + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("CAROUSEL_COMMERCE 전체 필드 - groupId: ${response.groupInfo?.groupId}") + } + + // ==================== PREMIUM_VIDEO 타입 테스트 ==================== + + @Test + fun `PREMIUM_VIDEO 타입 - 최소 구조`() { + if (!assumeEnvironmentConfigured()) return + + val imageId = uploadTestImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val video = BmsTestUtils.createVideo( + videoUrl = "https://tv.kakao.com/v/123456789", + imageId = imageId + ) + + val bmsOption = BmsTestUtils.createPremiumVideoBmsOption( + video = video, + content = "BMS Free PREMIUM_VIDEO 최소 구조 테스트" + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("PREMIUM_VIDEO 최소 구조 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `PREMIUM_VIDEO 타입 - 전체 필드`() { + if (!assumeEnvironmentConfigured()) return + + val imageId = uploadTestImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val video = BmsTestUtils.createVideo( + videoUrl = "https://tv.kakao.com/v/123456789", + imageId = imageId, + imageLink = "https://example.com/video" + ) + + val buttons = listOf( + BmsTestUtils.createWebLinkButton("영상 보기", "https://tv.kakao.com/v/123456789"), + BmsTestUtils.createChannelAddButton("채널 추가") + ) + + val bmsOption = BmsTestUtils.createPremiumVideoBmsOptionFull( + video = video, + content = "BMS Free PREMIUM_VIDEO 전체 필드 테스트", + header = "PREMIUM_VIDEO 헤더", + additionalContent = "영상 설명", + buttons = buttons, + adult = false + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) + + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("PREMIUM_VIDEO 전체 필드 - groupId: ${response.groupInfo?.groupId}") + } + + // ==================== Error Cases 테스트 ==================== + + @Test + fun `IMAGE without imageId - 필수 필드 누락`() { + if (!assumeEnvironmentConfigured()) return + + val bmsOption = BmsTestUtils.createImageBmsOption( + imageId = "", // 빈 이미지 ID + content = "이미지 없는 IMAGE 타입" + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + + var errorOccurred = false + try { + messageService!!.send(message) + } catch (e: Exception) { + errorOccurred = true + println("예상된 에러 발생: ${e.message}") + } + + assertTrue(errorOccurred, "이미지 ID 없이 IMAGE 타입 발송 시 에러가 발생해야 함") + } + + @Test + fun `COMMERCE without buttons - 필수 필드 누락`() { + if (!assumeEnvironmentConfigured()) return + + val imageId = uploadTestImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val commerce = BmsTestUtils.createCommerce("상품", 10000) + + val bmsOption = BmsTestUtils.createCommerceBmsOption( + imageId = imageId, + commerce = commerce, + buttons = emptyList() // 버튼 없음 + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + + var errorOccurred = false + try { + messageService!!.send(message) + } catch (e: Exception) { + errorOccurred = true + println("예상된 에러 발생: ${e.message}") + } + + assertTrue(errorOccurred, "버튼 없이 COMMERCE 타입 발송 시 에러가 발생해야 함") + } + + @Test + fun `PREMIUM_VIDEO with invalid videoUrl`() { + if (!assumeEnvironmentConfigured()) return + + val imageId = uploadTestImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val video = BmsTestUtils.createVideo( + videoUrl = "https://invalid-video-url.com/video", // 잘못된 비디오 URL (카카오 TV가 아님) + imageId = imageId + ) + + val bmsOption = BmsTestUtils.createPremiumVideoBmsOption( + video = video, + content = "잘못된 비디오 URL 테스트" + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + + var errorOccurred = false + try { + messageService!!.send(message) + } catch (e: Exception) { + errorOccurred = true + println("예상된 에러 발생: ${e.message}") + } + + assertTrue(errorOccurred, "잘못된 비디오 URL로 PREMIUM_VIDEO 타입 발송 시 에러가 발생해야 함") + } + + @Test + fun `Invalid coupon title format`() { + if (!assumeEnvironmentConfigured()) return + + // 잘못된 쿠폰 제목 형식 (허용되지 않는 형식) + val invalidCoupon = BmsCoupon( + title = "잘못된 쿠폰 제목", // 허용되지 않는 형식 + description = "설명" + ) + + val bmsOption = BmsTestUtils.createTextBmsOptionFull( + content = "잘못된 쿠폰 테스트", + header = "헤더", + additionalContent = "추가", + buttons = listOf(BmsTestUtils.createWebLinkButton()), + coupon = invalidCoupon + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + + var errorOccurred = false + try { + messageService!!.send(message) + } catch (e: Exception) { + errorOccurred = true + println("예상된 에러 발생: ${e.message}") + } + + assertTrue(errorOccurred, "잘못된 쿠폰 제목 형식으로 발송 시 에러가 발생해야 함") + } + + @Test + fun `CAROUSEL_FEED without carousel`() { + if (!assumeEnvironmentConfigured()) return + + val bmsOption = BmsTestUtils.createCarouselFeedBmsOption( + carouselItems = emptyList() // 빈 캐러셀 + ) + + val kakaoOption = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + + val message = createBmsFreeMessage(kakaoOption) + + var errorOccurred = false + try { + messageService!!.send(message) + } catch (e: Exception) { + errorOccurred = true + println("예상된 에러 발생: ${e.message}") + } + + assertTrue(errorOccurred, "빈 캐러셀로 CAROUSEL_FEED 타입 발송 시 에러가 발생해야 함") + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/lib/BmsTestUtils.kt b/src/test/kotlin/com/solapi/sdk/message/lib/BmsTestUtils.kt new file mode 100644 index 0000000..c87c43f --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/lib/BmsTestUtils.kt @@ -0,0 +1,545 @@ +package com.solapi.sdk.message.lib + +import com.solapi.sdk.message.model.kakao.KakaoBmsOption +import com.solapi.sdk.message.model.kakao.KakaoBmsTargeting +import com.solapi.sdk.message.model.kakao.bms.* + +/** + * BMS Free 테스트 헬퍼 함수들 + */ +object BmsTestUtils { + + /** + * 웹링크 버튼 생성 + */ + fun createWebLinkButton( + name: String = "버튼", + linkMobile: String = "https://example.com", + linkPc: String? = null, + targetOut: Boolean? = null + ): BmsButton = BmsButton( + linkType = BmsButtonType.WL, + name = name, + linkMobile = linkMobile, + linkPc = linkPc, + targetOut = targetOut + ) + + /** + * 앱링크 버튼 생성 + */ + fun createAppLinkButton( + name: String = "앱 열기", + linkAndroid: String = "intent://...", + linkIos: String = "iosapp://..." + ): BmsButton = BmsButton( + linkType = BmsButtonType.AL, + name = name, + linkAndroid = linkAndroid, + linkIos = linkIos + ) + + /** + * 채널 추가 버튼 생성 + */ + fun createChannelAddButton(name: String = "채널 추가"): BmsButton = BmsButton( + linkType = BmsButtonType.AC, + name = name + ) + + /** + * 봇 키워드 버튼 생성 + */ + fun createBotKeywordButton(name: String = "키워드"): BmsButton = BmsButton( + linkType = BmsButtonType.BK, + name = name + ) + + /** + * 쿠폰 생성 - 퍼센트 할인 + */ + fun createPercentCoupon( + percent: Int = 10, + description: String = "설명", + linkMobile: String? = "https://example.com" + ): BmsCoupon = BmsCoupon( + title = "${percent}% 할인 쿠폰", + description = description, + linkMobile = linkMobile + ) + + /** + * 쿠폰 생성 - 금액 할인 + */ + fun createWonCoupon( + won: Int = 5000, + description: String = "설명", + linkMobile: String? = "https://example.com" + ): BmsCoupon = BmsCoupon( + title = "${won}원 할인 쿠폰", + description = description, + linkMobile = linkMobile + ) + + /** + * 쿠폰 생성 - 배송비 할인 + */ + fun createShippingCoupon( + description: String = "설명", + linkMobile: String? = "https://example.com" + ): BmsCoupon = BmsCoupon( + title = "배송비 할인 쿠폰", + description = description, + linkMobile = linkMobile + ) + + /** + * 쿠폰 생성 - 무료 쿠폰 + */ + fun createFreeCoupon( + item: String = "커피", + description: String = "설명", + linkMobile: String? = "https://example.com" + ): BmsCoupon = BmsCoupon( + title = "$item 무료 쿠폰", + description = description, + linkMobile = linkMobile + ) + + /** + * 쿠폰 생성 - UP 쿠폰 + */ + fun createUpCoupon( + item: String = "포인트", + description: String = "설명", + linkMobile: String? = "https://example.com" + ): BmsCoupon = BmsCoupon( + title = "$item UP 쿠폰", + description = description, + linkMobile = linkMobile + ) + + /** + * 커머스 정보 생성 + */ + fun createCommerce( + title: String = "상품명", + regularPrice: Long = 50000, + discountPrice: Long? = null, + discountRate: Int? = null, + discountFixed: Long? = null + ): BmsCommerce = BmsCommerce( + title = title, + regularPrice = regularPrice, + discountPrice = discountPrice, + discountRate = discountRate, + discountFixed = discountFixed + ) + + /** + * 비디오 정보 생성 + */ + fun createVideo( + videoUrl: String, + imageId: String, + imageLink: String? = null + ): BmsVideo = BmsVideo( + videoUrl = videoUrl, + imageId = imageId, + imageLink = imageLink + ) + + /** + * 캐러셀 피드 아이템 생성 + */ + fun createCarouselFeedItem( + imageId: String, + header: String = "제목", + content: String = "내용", + imageLink: String? = null, + buttons: List? = null, + coupon: BmsCoupon? = null + ): BmsCarouselItem = BmsCarouselItem( + header = header, + content = content, + imageId = imageId, + imageLink = imageLink, + buttons = buttons, + coupon = coupon + ) + + /** + * 캐러셀 커머스 아이템 생성 + */ + fun createCarouselCommerceItem( + imageId: String, + commerce: BmsCommerce, + additionalContent: String? = null, + imageLink: String? = null, + buttons: List? = null, + coupon: BmsCoupon? = null + ): BmsCarouselItem = BmsCarouselItem( + commerce = commerce, + additionalContent = additionalContent, + imageId = imageId, + imageLink = imageLink, + buttons = buttons, + coupon = coupon + ) + + /** + * 캐러셀 헤드 생성 + */ + fun createCarouselHead( + header: String = "인트로", + content: String? = null, + imageId: String? = null, + linkMobile: String? = null + ): BmsCarouselHead = BmsCarouselHead( + header = header, + content = content, + imageId = imageId, + linkMobile = linkMobile + ) + + /** + * 캐러셀 테일 생성 + */ + fun createCarouselTail( + linkMobile: String = "https://example.com", + linkPc: String? = null + ): BmsCarouselTail = BmsCarouselTail( + linkMobile = linkMobile, + linkPc = linkPc + ) + + /** + * 메인 와이드 아이템 생성 + */ + fun createMainWideItem( + imageId: String, + title: String = "메인 타이틀", + linkMobile: String? = "https://example.com", + linkPc: String? = null + ): BmsMainWideItem = BmsMainWideItem( + title = title, + imageId = imageId, + linkMobile = linkMobile, + linkPc = linkPc + ) + + /** + * 서브 와이드 아이템 생성 + */ + fun createSubWideItem( + imageId: String, + title: String, + linkMobile: String? = "https://example.com", + linkPc: String? = null + ): BmsSubWideItem = BmsSubWideItem( + title = title, + imageId = imageId, + linkMobile = linkMobile, + linkPc = linkPc + ) + + /** + * TEXT 타입 BMS 옵션 생성 (최소) + */ + fun createTextBmsOption( + content: String = "텍스트 메시지 내용", + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.TEXT, + content = content + ) + + /** + * TEXT 타입 BMS 옵션 생성 (전체 필드) + */ + fun createTextBmsOptionFull( + content: String = "텍스트 메시지 내용", + header: String = "헤더", + additionalContent: String = "추가 내용", + buttons: List, + coupon: BmsCoupon? = null, + adult: Boolean = false, + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.TEXT, + adult = adult, + header = header, + content = content, + additionalContent = additionalContent, + buttons = buttons, + coupon = coupon + ) + + /** + * IMAGE 타입 BMS 옵션 생성 (최소) + */ + fun createImageBmsOption( + imageId: String, + content: String = "이미지 메시지 내용", + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.IMAGE, + imageId = imageId, + content = content + ) + + /** + * IMAGE 타입 BMS 옵션 생성 (전체 필드) + */ + fun createImageBmsOptionFull( + imageId: String, + content: String = "이미지 메시지 내용", + header: String = "헤더", + imageLink: String = "https://example.com", + additionalContent: String = "추가 내용", + buttons: List, + coupon: BmsCoupon? = null, + adult: Boolean = false, + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.IMAGE, + adult = adult, + header = header, + imageId = imageId, + imageLink = imageLink, + content = content, + additionalContent = additionalContent, + buttons = buttons, + coupon = coupon + ) + + /** + * WIDE 타입 BMS 옵션 생성 (최소) + */ + fun createWideBmsOption( + imageId: String, + content: String = "와이드 메시지 내용", + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.WIDE, + imageId = imageId, + content = content + ) + + /** + * WIDE 타입 BMS 옵션 생성 (전체 필드) + */ + fun createWideBmsOptionFull( + imageId: String, + content: String = "와이드 메시지 내용", + header: String = "헤더", + imageLink: String = "https://example.com", + additionalContent: String = "추가 내용", + buttons: List, + coupon: BmsCoupon? = null, + adult: Boolean = false, + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.WIDE, + adult = adult, + header = header, + imageId = imageId, + imageLink = imageLink, + content = content, + additionalContent = additionalContent, + buttons = buttons, + coupon = coupon + ) + + /** + * WIDE_ITEM_LIST 타입 BMS 옵션 생성 (최소) + */ + fun createWideItemListBmsOption( + mainWideItem: BmsMainWideItem, + subWideItemList: List, + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.WIDE_ITEM_LIST, + mainWideItem = mainWideItem, + subWideItemList = subWideItemList + ) + + /** + * WIDE_ITEM_LIST 타입 BMS 옵션 생성 (전체 필드) + */ + fun createWideItemListBmsOptionFull( + mainWideItem: BmsMainWideItem, + subWideItemList: List, + header: String = "헤더", + buttons: List, + coupon: BmsCoupon? = null, + adult: Boolean = false, + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.WIDE_ITEM_LIST, + adult = adult, + header = header, + mainWideItem = mainWideItem, + subWideItemList = subWideItemList, + buttons = buttons, + coupon = coupon + ) + + /** + * COMMERCE 타입 BMS 옵션 생성 (최소) + */ + fun createCommerceBmsOption( + imageId: String, + commerce: BmsCommerce, + buttons: List, + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.COMMERCE, + imageId = imageId, + commerce = commerce, + buttons = buttons + ) + + /** + * COMMERCE 타입 BMS 옵션 생성 (전체 필드) + */ + fun createCommerceBmsOptionFull( + imageId: String, + commerce: BmsCommerce, + buttons: List, + header: String = "헤더", + imageLink: String = "https://example.com", + additionalContent: String = "추가 내용", + coupon: BmsCoupon? = null, + adult: Boolean = false, + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.COMMERCE, + adult = adult, + header = header, + imageId = imageId, + imageLink = imageLink, + additionalContent = additionalContent, + commerce = commerce, + buttons = buttons, + coupon = coupon + ) + + /** + * CAROUSEL_FEED 타입 BMS 옵션 생성 (최소) + */ + fun createCarouselFeedBmsOption( + carouselItems: List, + tail: BmsCarouselTail? = null, + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.CAROUSEL_FEED, + carousel = BmsCarousel( + list = carouselItems, + tail = tail + ) + ) + + /** + * CAROUSEL_FEED 타입 BMS 옵션 생성 (전체 필드) + */ + fun createCarouselFeedBmsOptionFull( + carouselItems: List, + tail: BmsCarouselTail, + adult: Boolean = false, + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.CAROUSEL_FEED, + adult = adult, + carousel = BmsCarousel( + list = carouselItems, + tail = tail + ) + ) + + /** + * CAROUSEL_COMMERCE 타입 BMS 옵션 생성 (최소) + */ + fun createCarouselCommerceBmsOption( + carouselItems: List, + tail: BmsCarouselTail? = null, + head: BmsCarouselHead? = null, + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.CAROUSEL_COMMERCE, + carousel = BmsCarousel( + head = head, + list = carouselItems, + tail = tail + ) + ) + + /** + * CAROUSEL_COMMERCE 타입 BMS 옵션 생성 (전체 필드) + */ + fun createCarouselCommerceBmsOptionFull( + carouselItems: List, + tail: BmsCarouselTail, + head: BmsCarouselHead, + adult: Boolean = false, + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.CAROUSEL_COMMERCE, + adult = adult, + carousel = BmsCarousel( + head = head, + list = carouselItems, + tail = tail + ) + ) + + /** + * PREMIUM_VIDEO 타입 BMS 옵션 생성 (최소) + */ + fun createPremiumVideoBmsOption( + video: BmsVideo, + content: String = "비디오 메시지 내용", + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.PREMIUM_VIDEO, + video = video, + content = content + ) + + /** + * PREMIUM_VIDEO 타입 BMS 옵션 생성 (전체 필드) + */ + fun createPremiumVideoBmsOptionFull( + video: BmsVideo, + content: String = "비디오 메시지 내용", + header: String = "헤더", + additionalContent: String = "추가 내용", + buttons: List, + adult: Boolean = false, + targeting: KakaoBmsTargeting = KakaoBmsTargeting.I + ): KakaoBmsOption = KakaoBmsOption( + targeting = targeting, + chatBubbleType = BmsChatBubbleType.PREMIUM_VIDEO, + adult = adult, + header = header, + video = video, + content = content, + additionalContent = additionalContent, + buttons = buttons + ) +} diff --git a/src/test/resources/images/test-image-1to1.png b/src/test/resources/images/test-image-1to1.png new file mode 100644 index 0000000000000000000000000000000000000000..2a4a55606445e103c8246490371891f9ab57f960 GIT binary patch literal 1057 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKL985qF{<{ljGcYi}@N{tuskrs_iXkrp1JB_N zpPn1e(e9F+tRv~zm!i&~@4+A;){w~9#+nep(8da8ONfEl2@ybX;v^dqJ*i{NO6nLx zq_gpQm}t+BngR@N;!_L@IADoak2P&dOrw%9TvTxlCXfhh-8GNt$|5ZJG4 R!wJm#44$rjF6*2UngGkr1H}LU literal 0 HcmV?d00001 diff --git a/src/test/resources/images/test-image-2to1.png b/src/test/resources/images/test-image-2to1.png new file mode 100644 index 0000000000000000000000000000000000000000..aeb8e116c46d7f84f3ab2847aae89359ac6fef1e GIT binary patch literal 1571 zcmeAS@N?(olHy`uVBq!ia0y~yU{+vYV4T3g1QfaHJ~^3zf%TQAi(^Q|t+&?gTe~DWM4f8U_&S literal 0 HcmV?d00001 diff --git a/src/test/resources/images/test-image.png b/src/test/resources/images/test-image.png new file mode 100644 index 0000000000000000000000000000000000000000..d41a0effd4e7a9f4358aa458e91e41e9302a6ca7 GIT binary patch literal 3059 zcmeAS@N?(olHy`uVBq!ia0y~yU{(NO4kn;Th|olP1_th3o-U3d6}R4AGvsAp;9%J( z-mRMQh*4Na#dp<%W!_&I7z#9m7#JjU7#Nya84@-yFc?HIFtBkm97tdUb9uxV8V)jn zxuhrp=^jholH8M1Z1%6(NNPNGo=eNL2)i zG=e4}DFVeTlEaa#!>$M^Y;h+F6h$i;a3mchRY-vg5=C+pc16g63rwkm6(I!)FgYVx zhinqEB5zhAR3QZ%P%jRnkQ5<>E>SU#6a^ry*iAxFG@5&1X$LtniHUKfz@xR zo+Y-lM#?CoxtG>4j*>A)b1za_MM{Q56vd;t7n)L$f^9VSj;!1puXFv4o?4GK+bLjU Op25@A&t;ucLK6T?6AVKD literal 0 HcmV?d00001 From df8e0e365df8756abf5beb7ca881d58a5e05bf99 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 28 Jan 2026 12:04:32 +0900 Subject: [PATCH 15/43] test(e2e): improve error output to display failedMessageList details - Add printExceptionDetails helper to show SolapiMessageNotReceivedException's failedMessageList - Update all error case catch blocks to use the new helper function - Standardize environment variable names (SOLAPI_KAKAO_PF_ID, SOLAPI_SENDER, SOLAPI_RECIPIENT) - Add .omc/ to gitignore --- .gitignore | 5 +-- .../solapi/sdk/message/e2e/BmsFreeE2ETest.kt | 36 ++++++++++++------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 0e656fb..7948d99 100644 --- a/.gitignore +++ b/.gitignore @@ -41,5 +41,6 @@ secret.key /docs/ manual/ -# OMO -.sisyphus/ \ No newline at end of file +# OMO, OMC +.sisyphus/ +.omc/ \ No newline at end of file diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt index c8d43fd..7368d95 100644 --- a/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt @@ -1,6 +1,7 @@ package com.solapi.sdk.message.e2e import com.solapi.sdk.SolapiClient +import com.solapi.sdk.message.exception.SolapiMessageNotReceivedException import com.solapi.sdk.message.lib.BmsTestUtils import com.solapi.sdk.message.model.Message import com.solapi.sdk.message.model.MessageType @@ -19,17 +20,18 @@ import java.io.File * 환경변수 설정 필요: * - SOLAPI_API_KEY: SOLAPI API 키 * - SOLAPI_API_SECRET: SOLAPI API 시크릿 - * - KAKAO_PF_ID: 카카오 비즈니스 채널 ID - * - SENDER_NUMBER: 등록된 발신번호 - * - TEST_PHONE_NUMBER: 테스트 수신번호 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 테스트 수신번호 + * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID + * - SOLAPI_KAKAO_TEMPLATE_ID: 카카오 알림톡 템플릿 ID (선택) */ class BmsFreeE2ETest { private val apiKey: String? = System.getenv("SOLAPI_API_KEY") private val apiSecret: String? = System.getenv("SOLAPI_API_SECRET") - private val pfId: String? = System.getenv("KAKAO_PF_ID") - private val senderNumber: String = System.getenv("SENDER_NUMBER") ?: "01000000000" - private val testPhoneNumber: String = System.getenv("TEST_PHONE_NUMBER") ?: "01000000000" + private val pfId: String? = System.getenv("SOLAPI_KAKAO_PF_ID") + private val senderNumber: String = System.getenv("SOLAPI_SENDER") ?: "01000000000" + private val testPhoneNumber: String = System.getenv("SOLAPI_RECIPIENT") ?: "01000000000" private val messageService: DefaultMessageService? by lazy { if (apiKey != null && apiSecret != null) { @@ -45,7 +47,7 @@ class BmsFreeE2ETest { */ private fun assumeEnvironmentConfigured(): Boolean { if (apiKey.isNullOrBlank() || apiSecret.isNullOrBlank() || pfId.isNullOrBlank()) { - println("환경변수가 설정되지 않아 테스트를 건너뜁니다. (SOLAPI_API_KEY, SOLAPI_API_SECRET, KAKAO_PF_ID 필요)") + println("환경변수가 설정되지 않아 테스트를 건너뜁니다. (SOLAPI_API_KEY, SOLAPI_API_SECRET, SOLAPI_KAKAO_PF_ID 필요)") return false } return true @@ -76,6 +78,16 @@ class BmsFreeE2ETest { kakaoOptions = kakaoOption ) + private fun printExceptionDetails(e: Exception) { + println("예상된 에러 발생: ${e.message}") + if (e is SolapiMessageNotReceivedException) { + println(" 실패한 메시지 목록 (${e.failedMessageList.size}건):") + e.failedMessageList.forEachIndexed { index, failed -> + println(" [${index + 1}] to: ${failed.to}, statusCode: ${failed.statusCode}, statusMessage: ${failed.statusMessage}") + } + } + } + // ==================== TEXT 타입 테스트 ==================== @Test @@ -763,7 +775,7 @@ class BmsFreeE2ETest { messageService!!.send(message) } catch (e: Exception) { errorOccurred = true - println("예상된 에러 발생: ${e.message}") + printExceptionDetails(e) } assertTrue(errorOccurred, "이미지 ID 없이 IMAGE 타입 발송 시 에러가 발생해야 함") @@ -799,7 +811,7 @@ class BmsFreeE2ETest { messageService!!.send(message) } catch (e: Exception) { errorOccurred = true - println("예상된 에러 발생: ${e.message}") + printExceptionDetails(e) } assertTrue(errorOccurred, "버튼 없이 COMMERCE 타입 발송 시 에러가 발생해야 함") @@ -837,7 +849,7 @@ class BmsFreeE2ETest { messageService!!.send(message) } catch (e: Exception) { errorOccurred = true - println("예상된 에러 발생: ${e.message}") + printExceptionDetails(e) } assertTrue(errorOccurred, "잘못된 비디오 URL로 PREMIUM_VIDEO 타입 발송 시 에러가 발생해야 함") @@ -873,7 +885,7 @@ class BmsFreeE2ETest { messageService!!.send(message) } catch (e: Exception) { errorOccurred = true - println("예상된 에러 발생: ${e.message}") + printExceptionDetails(e) } assertTrue(errorOccurred, "잘못된 쿠폰 제목 형식으로 발송 시 에러가 발생해야 함") @@ -899,7 +911,7 @@ class BmsFreeE2ETest { messageService!!.send(message) } catch (e: Exception) { errorOccurred = true - println("예상된 에러 발생: ${e.message}") + printExceptionDetails(e) } assertTrue(errorOccurred, "빈 캐러셀로 CAROUSEL_FEED 타입 발송 시 에러가 발생해야 함") From 3ae6ba55495118fabb2eb5565198c882f77deb4b Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 28 Jan 2026 12:33:21 +0900 Subject: [PATCH 16/43] fix(e2e): correct BMS Free test field usage and StorageType mapping - Add chatBubbleType-specific image upload helpers with correct StorageType - Remove unsupported fields from test helpers: - TEXT: remove header, additionalContent - IMAGE/WIDE: remove header, content, additionalContent - COMMERCE: remove header - PREMIUM_VIDEO: remove additionalContent, limit to 1 button - Fix WIDE_ITEM_LIST to require minimum 3 sub-items - Add header as required field for WIDE_ITEM_LIST - Update PREMIUM_VIDEO video URL to valid Kakao TV link - Change COMMERCE without buttons test to expect success Resolves API validation errors discovered during E2E testing. --- .../solapi/sdk/message/e2e/BmsFreeE2ETest.kt | 196 +++++++++++++----- .../solapi/sdk/message/lib/BmsTestUtils.kt | 31 +-- 2 files changed, 150 insertions(+), 77 deletions(-) diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt index 7368d95..ec5063a 100644 --- a/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt @@ -54,7 +54,7 @@ class BmsFreeE2ETest { } /** - * 테스트 이미지 업로드 + * 테스트 이미지 업로드 (일반 - KAKAO 타입, PREMIUM_VIDEO 썸네일용) * @param filename 리소스 파일명 * @return 업로드된 이미지 ID */ @@ -69,12 +69,100 @@ class BmsFreeE2ETest { } /** - * BMS Free 메시지 생성 + * BMS 타입 이미지 업로드 (IMAGE, COMMERCE용) + * @param filename 리소스 파일명 + * @return 업로드된 이미지 ID + */ + private fun uploadBmsImage(filename: String = "test-image.png"): String? { + val imageUrl = javaClass.classLoader.getResource("images/$filename") + if (imageUrl == null) { + println("테스트 이미지가 없어 건너뜁니다: images/$filename") + return null + } + val file = File(imageUrl.toURI()) + return messageService?.uploadFile(file, StorageType.BMS) + } + + /** + * BMS WIDE 타입 이미지 업로드 (WIDE용 - 2:1 비율) + * @param filename 리소스 파일명 + * @return 업로드된 이미지 ID + */ + private fun uploadBmsWideImage(filename: String = "test-image-2to1.png"): String? { + val imageUrl = javaClass.classLoader.getResource("images/$filename") + if (imageUrl == null) { + println("테스트 이미지가 없어 건너뜁니다: images/$filename") + return null + } + val file = File(imageUrl.toURI()) + return messageService?.uploadFile(file, StorageType.BMS_WIDE) + } + + /** + * BMS WIDE_ITEM_LIST 메인 이미지 업로드 (2:1 비율) + * @param filename 리소스 파일명 + * @return 업로드된 이미지 ID */ - private fun createBmsFreeMessage(kakaoOption: KakaoOption): Message = Message( + private fun uploadBmsWideMainItemImage(filename: String = "test-image-2to1.png"): String? { + val imageUrl = javaClass.classLoader.getResource("images/$filename") + if (imageUrl == null) { + println("테스트 이미지가 없어 건너뜁니다: images/$filename") + return null + } + val file = File(imageUrl.toURI()) + return messageService?.uploadFile(file, StorageType.BMS_WIDE_MAIN_ITEM_LIST) + } + + /** + * BMS WIDE_ITEM_LIST 서브 이미지 업로드 (1:1 비율) + * @param filename 리소스 파일명 + * @return 업로드된 이미지 ID + */ + private fun uploadBmsWideSubItemImage(filename: String = "test-image.png"): String? { + val imageUrl = javaClass.classLoader.getResource("images/$filename") + if (imageUrl == null) { + println("테스트 이미지가 없어 건너뜁니다: images/$filename") + return null + } + val file = File(imageUrl.toURI()) + return messageService?.uploadFile(file, StorageType.BMS_WIDE_SUB_ITEM_LIST) + } + + /** + * BMS CAROUSEL_FEED 이미지 업로드 (2:1 비율) + * @param filename 리소스 파일명 + * @return 업로드된 이미지 ID + */ + private fun uploadBmsCarouselFeedImage(filename: String = "test-image-2to1.png"): String? { + val imageUrl = javaClass.classLoader.getResource("images/$filename") + if (imageUrl == null) { + println("테스트 이미지가 없어 건너뜁니다: images/$filename") + return null + } + val file = File(imageUrl.toURI()) + return messageService?.uploadFile(file, StorageType.BMS_CAROUSEL_FEED_LIST) + } + + /** + * BMS CAROUSEL_COMMERCE 이미지 업로드 (2:1 비율) + * @param filename 리소스 파일명 + * @return 업로드된 이미지 ID + */ + private fun uploadBmsCarouselCommerceImage(filename: String = "test-image-2to1.png"): String? { + val imageUrl = javaClass.classLoader.getResource("images/$filename") + if (imageUrl == null) { + println("테스트 이미지가 없어 건너뜁니다: images/$filename") + return null + } + val file = File(imageUrl.toURI()) + return messageService?.uploadFile(file, StorageType.BMS_CAROUSEL_COMMERCE_LIST) + } + + private fun createBmsFreeMessage(kakaoOption: KakaoOption, text: String? = null): Message = Message( type = MessageType.BMS_FREE, from = senderNumber, to = testPhoneNumber, + text = text, kakaoOptions = kakaoOption ) @@ -124,8 +212,6 @@ class BmsFreeE2ETest { val bmsOption = BmsTestUtils.createTextBmsOptionFull( content = "BMS Free TEXT 전체 필드 테스트", - header = "TEXT 헤더", - additionalContent = "추가 내용입니다", buttons = buttons, coupon = coupon, adult = false @@ -150,7 +236,7 @@ class BmsFreeE2ETest { fun `IMAGE 타입 - 최소 구조`() { if (!assumeEnvironmentConfigured()) return - val imageId = uploadTestImage() + val imageId = uploadBmsImage() if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -178,7 +264,7 @@ class BmsFreeE2ETest { fun `IMAGE 타입 - 전체 필드`() { if (!assumeEnvironmentConfigured()) return - val imageId = uploadTestImage() + val imageId = uploadBmsImage() if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -193,10 +279,7 @@ class BmsFreeE2ETest { val bmsOption = BmsTestUtils.createImageBmsOptionFull( imageId = imageId, - content = "BMS Free IMAGE 전체 필드 테스트", - header = "IMAGE 헤더", imageLink = "https://example.com/image", - additionalContent = "추가 내용", buttons = buttons, coupon = coupon, adult = false @@ -207,7 +290,7 @@ class BmsFreeE2ETest { bms = bmsOption ) - val message = createBmsFreeMessage(kakaoOption) + val message = createBmsFreeMessage(kakaoOption, text = "BMS Free IMAGE 전체 필드 테스트") val response = messageService!!.send(message) assertNotNull(response) @@ -221,7 +304,7 @@ class BmsFreeE2ETest { fun `WIDE 타입 - 최소 구조`() { if (!assumeEnvironmentConfigured()) return - val imageId = uploadTestImage("test-image-2to1.png") ?: uploadTestImage() + val imageId = uploadBmsWideImage() if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -249,7 +332,7 @@ class BmsFreeE2ETest { fun `WIDE 타입 - 전체 필드`() { if (!assumeEnvironmentConfigured()) return - val imageId = uploadTestImage("test-image-2to1.png") ?: uploadTestImage() + val imageId = uploadBmsWideImage() if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -264,10 +347,7 @@ class BmsFreeE2ETest { val bmsOption = BmsTestUtils.createWideBmsOptionFull( imageId = imageId, - content = "BMS Free WIDE 전체 필드 테스트", - header = "WIDE 헤더", imageLink = "https://example.com/wide", - additionalContent = "와이드 추가 내용", buttons = buttons, coupon = coupon, adult = false @@ -278,7 +358,7 @@ class BmsFreeE2ETest { bms = bmsOption ) - val message = createBmsFreeMessage(kakaoOption) + val message = createBmsFreeMessage(kakaoOption, text = "BMS Free WIDE 전체 필드 테스트") val response = messageService!!.send(message) assertNotNull(response) @@ -292,8 +372,8 @@ class BmsFreeE2ETest { fun `WIDE_ITEM_LIST 타입 - 최소 구조`() { if (!assumeEnvironmentConfigured()) return - val mainImageId = uploadTestImage("test-image-2to1.png") ?: uploadTestImage() - val subImageId = uploadTestImage("test-image-1to1.png") ?: uploadTestImage() + val mainImageId = uploadBmsWideMainItemImage() + val subImageId = uploadBmsWideSubItemImage() if (mainImageId == null || subImageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -304,9 +384,11 @@ class BmsFreeE2ETest { title = "메인 아이템" ) + // WIDE_ITEM_LIST는 최소 3개의 서브 아이템이 필요합니다 val subWideItemList = listOf( BmsTestUtils.createSubWideItem(subImageId, "서브 아이템 1"), - BmsTestUtils.createSubWideItem(subImageId, "서브 아이템 2") + BmsTestUtils.createSubWideItem(subImageId, "서브 아이템 2"), + BmsTestUtils.createSubWideItem(subImageId, "서브 아이템 3") ) val bmsOption = BmsTestUtils.createWideItemListBmsOption( @@ -331,8 +413,8 @@ class BmsFreeE2ETest { fun `WIDE_ITEM_LIST 타입 - 전체 필드`() { if (!assumeEnvironmentConfigured()) return - val mainImageId = uploadTestImage("test-image-2to1.png") ?: uploadTestImage() - val subImageId = uploadTestImage("test-image-1to1.png") ?: uploadTestImage() + val mainImageId = uploadBmsWideMainItemImage() + val subImageId = uploadBmsWideSubItemImage() if (mainImageId == null || subImageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -384,7 +466,7 @@ class BmsFreeE2ETest { fun `COMMERCE 타입 - 최소 구조`() { if (!assumeEnvironmentConfigured()) return - val imageId = uploadTestImage() + val imageId = uploadBmsImage("test-image-2to1.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -422,7 +504,7 @@ class BmsFreeE2ETest { fun `COMMERCE 타입 - 전체 필드`() { if (!assumeEnvironmentConfigured()) return - val imageId = uploadTestImage() + val imageId = uploadBmsImage("test-image-2to1.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -446,7 +528,6 @@ class BmsFreeE2ETest { imageId = imageId, commerce = commerce, buttons = buttons, - header = "COMMERCE 헤더", imageLink = "https://example.com/product", additionalContent = "무료배송 | 오늘 출발", coupon = coupon, @@ -472,15 +553,19 @@ class BmsFreeE2ETest { fun `CAROUSEL_FEED 타입 - 최소 구조`() { if (!assumeEnvironmentConfigured()) return - val imageId = uploadTestImage() + val imageId = uploadBmsCarouselFeedImage() if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return } + val itemButtons = listOf( + BmsTestUtils.createWebLinkButton("바로가기", "https://example.com") + ) + val carouselItems = listOf( - BmsTestUtils.createCarouselFeedItem(imageId, "아이템 1", "내용 1"), - BmsTestUtils.createCarouselFeedItem(imageId, "아이템 2", "내용 2") + BmsTestUtils.createCarouselFeedItem(imageId, "아이템 1", "내용 1", buttons = itemButtons), + BmsTestUtils.createCarouselFeedItem(imageId, "아이템 2", "내용 2", buttons = itemButtons) ) val bmsOption = BmsTestUtils.createCarouselFeedBmsOption( @@ -504,7 +589,7 @@ class BmsFreeE2ETest { fun `CAROUSEL_FEED 타입 - 전체 필드`() { if (!assumeEnvironmentConfigured()) return - val imageId = uploadTestImage() + val imageId = uploadBmsCarouselFeedImage() if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -569,20 +654,26 @@ class BmsFreeE2ETest { fun `CAROUSEL_COMMERCE 타입 - 최소 구조`() { if (!assumeEnvironmentConfigured()) return - val imageId = uploadTestImage() + val imageId = uploadBmsCarouselCommerceImage() if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return } + val itemButtons = listOf( + BmsTestUtils.createWebLinkButton("구매하기", "https://example.com") + ) + val carouselItems = listOf( BmsTestUtils.createCarouselCommerceItem( imageId = imageId, - commerce = BmsTestUtils.createCommerce("상품 1", 30000) + commerce = BmsTestUtils.createCommerce("상품 1", 30000), + buttons = itemButtons ), BmsTestUtils.createCarouselCommerceItem( imageId = imageId, - commerce = BmsTestUtils.createCommerce("상품 2", 40000) + commerce = BmsTestUtils.createCommerce("상품 2", 40000), + buttons = itemButtons ) ) @@ -607,7 +698,7 @@ class BmsFreeE2ETest { fun `CAROUSEL_COMMERCE 타입 - 전체 필드`() { if (!assumeEnvironmentConfigured()) return - val imageId = uploadTestImage() + val imageId = uploadBmsCarouselCommerceImage() if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -687,7 +778,7 @@ class BmsFreeE2ETest { } val video = BmsTestUtils.createVideo( - videoUrl = "https://tv.kakao.com/v/123456789", + videoUrl = "https://tv.kakao.com/v/460734285", imageId = imageId ) @@ -720,22 +811,21 @@ class BmsFreeE2ETest { } val video = BmsTestUtils.createVideo( - videoUrl = "https://tv.kakao.com/v/123456789", + videoUrl = "https://tv.kakao.com/v/460734285", imageId = imageId, imageLink = "https://example.com/video" ) val buttons = listOf( - BmsTestUtils.createWebLinkButton("영상 보기", "https://tv.kakao.com/v/123456789"), - BmsTestUtils.createChannelAddButton("채널 추가") + BmsTestUtils.createWebLinkButton("영상 보기", "https://tv.kakao.com/v/460734285") ) val bmsOption = BmsTestUtils.createPremiumVideoBmsOptionFull( video = video, content = "BMS Free PREMIUM_VIDEO 전체 필드 테스트", header = "PREMIUM_VIDEO 헤더", - additionalContent = "영상 설명", buttons = buttons, + coupon = BmsTestUtils.createPercentCoupon(10, "프리미엄 비디오 쿠폰"), adult = false ) @@ -782,10 +872,10 @@ class BmsFreeE2ETest { } @Test - fun `COMMERCE without buttons - 필수 필드 누락`() { + fun `COMMERCE without buttons - 버튼 없이 발송 허용`() { if (!assumeEnvironmentConfigured()) return - val imageId = uploadTestImage() + val imageId = uploadBmsImage("test-image-2to1.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -796,7 +886,7 @@ class BmsFreeE2ETest { val bmsOption = BmsTestUtils.createCommerceBmsOption( imageId = imageId, commerce = commerce, - buttons = emptyList() // 버튼 없음 + buttons = emptyList() ) val kakaoOption = KakaoOption( @@ -805,16 +895,11 @@ class BmsFreeE2ETest { ) val message = createBmsFreeMessage(kakaoOption) + val response = messageService!!.send(message) - var errorOccurred = false - try { - messageService!!.send(message) - } catch (e: Exception) { - errorOccurred = true - printExceptionDetails(e) - } - - assertTrue(errorOccurred, "버튼 없이 COMMERCE 타입 발송 시 에러가 발생해야 함") + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("COMMERCE 버튼 없이 발송 - groupId: ${response.groupInfo?.groupId}") } @Test @@ -859,16 +944,15 @@ class BmsFreeE2ETest { fun `Invalid coupon title format`() { if (!assumeEnvironmentConfigured()) return - // 잘못된 쿠폰 제목 형식 (허용되지 않는 형식) val invalidCoupon = BmsCoupon( - title = "잘못된 쿠폰 제목", // 허용되지 않는 형식 + title = "잘못된 쿠폰 제목", description = "설명" ) - val bmsOption = BmsTestUtils.createTextBmsOptionFull( - content = "잘못된 쿠폰 테스트", - header = "헤더", - additionalContent = "추가", + // TEXT 타입은 adult, content, buttons, coupon만 지원 + val bmsOption = BmsTestUtils.createTextBmsOption( + content = "잘못된 쿠폰 테스트" + ).copy( buttons = listOf(BmsTestUtils.createWebLinkButton()), coupon = invalidCoupon ) diff --git a/src/test/kotlin/com/solapi/sdk/message/lib/BmsTestUtils.kt b/src/test/kotlin/com/solapi/sdk/message/lib/BmsTestUtils.kt index c87c43f..3910a65 100644 --- a/src/test/kotlin/com/solapi/sdk/message/lib/BmsTestUtils.kt +++ b/src/test/kotlin/com/solapi/sdk/message/lib/BmsTestUtils.kt @@ -257,11 +257,10 @@ object BmsTestUtils { /** * TEXT 타입 BMS 옵션 생성 (전체 필드) + * TEXT 타입은 adult, content, buttons, coupon만 지원 (header, additionalContent 미지원) */ fun createTextBmsOptionFull( content: String = "텍스트 메시지 내용", - header: String = "헤더", - additionalContent: String = "추가 내용", buttons: List, coupon: BmsCoupon? = null, adult: Boolean = false, @@ -270,9 +269,7 @@ object BmsTestUtils { targeting = targeting, chatBubbleType = BmsChatBubbleType.TEXT, adult = adult, - header = header, content = content, - additionalContent = additionalContent, buttons = buttons, coupon = coupon ) @@ -293,13 +290,11 @@ object BmsTestUtils { /** * IMAGE 타입 BMS 옵션 생성 (전체 필드) + * IMAGE 타입은 header, additionalContent 미지원 - Message.text로 content 전달 */ fun createImageBmsOptionFull( imageId: String, - content: String = "이미지 메시지 내용", - header: String = "헤더", imageLink: String = "https://example.com", - additionalContent: String = "추가 내용", buttons: List, coupon: BmsCoupon? = null, adult: Boolean = false, @@ -308,11 +303,8 @@ object BmsTestUtils { targeting = targeting, chatBubbleType = BmsChatBubbleType.IMAGE, adult = adult, - header = header, imageId = imageId, imageLink = imageLink, - content = content, - additionalContent = additionalContent, buttons = buttons, coupon = coupon ) @@ -336,10 +328,7 @@ object BmsTestUtils { */ fun createWideBmsOptionFull( imageId: String, - content: String = "와이드 메시지 내용", - header: String = "헤더", imageLink: String = "https://example.com", - additionalContent: String = "추가 내용", buttons: List, coupon: BmsCoupon? = null, adult: Boolean = false, @@ -348,25 +337,25 @@ object BmsTestUtils { targeting = targeting, chatBubbleType = BmsChatBubbleType.WIDE, adult = adult, - header = header, imageId = imageId, imageLink = imageLink, - content = content, - additionalContent = additionalContent, buttons = buttons, coupon = coupon ) /** * WIDE_ITEM_LIST 타입 BMS 옵션 생성 (최소) + * header는 WIDE_ITEM_LIST 타입의 필수 필드 */ fun createWideItemListBmsOption( mainWideItem: BmsMainWideItem, subWideItemList: List, + header: String = "WIDE_ITEM_LIST", targeting: KakaoBmsTargeting = KakaoBmsTargeting.I ): KakaoBmsOption = KakaoBmsOption( targeting = targeting, chatBubbleType = BmsChatBubbleType.WIDE_ITEM_LIST, + header = header, mainWideItem = mainWideItem, subWideItemList = subWideItemList ) @@ -411,12 +400,12 @@ object BmsTestUtils { /** * COMMERCE 타입 BMS 옵션 생성 (전체 필드) + * COMMERCE 타입은 header 미지원, additionalContent 지원 */ fun createCommerceBmsOptionFull( imageId: String, commerce: BmsCommerce, buttons: List, - header: String = "헤더", imageLink: String = "https://example.com", additionalContent: String = "추가 내용", coupon: BmsCoupon? = null, @@ -426,7 +415,6 @@ object BmsTestUtils { targeting = targeting, chatBubbleType = BmsChatBubbleType.COMMERCE, adult = adult, - header = header, imageId = imageId, imageLink = imageLink, additionalContent = additionalContent, @@ -523,13 +511,14 @@ object BmsTestUtils { /** * PREMIUM_VIDEO 타입 BMS 옵션 생성 (전체 필드) + * PREMIUM_VIDEO 타입은 adult, header, content, video, buttons, coupon만 지원 (additionalContent 미지원) */ fun createPremiumVideoBmsOptionFull( video: BmsVideo, content: String = "비디오 메시지 내용", header: String = "헤더", - additionalContent: String = "추가 내용", buttons: List, + coupon: BmsCoupon? = null, adult: Boolean = false, targeting: KakaoBmsTargeting = KakaoBmsTargeting.I ): KakaoBmsOption = KakaoBmsOption( @@ -539,7 +528,7 @@ object BmsTestUtils { header = header, video = video, content = content, - additionalContent = additionalContent, - buttons = buttons + buttons = buttons, + coupon = coupon ) } From d7c974b8cfdb16c5bd824bcab7f83dd390a0160c Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 28 Jan 2026 14:30:42 +0900 Subject: [PATCH 17/43] feat(dto): add LocalDateTime support for date fields Add convenience methods to set date fields using java.time.LocalDateTime in addition to existing kotlin.time.Instant support. - Add LocalDateTimeSupport utility with toKotlinInstant() extension - Add setScheduledDateFromLocalDateTime() to SendRequestConfig - Add setStartDateFromLocalDateTime/setEndDateFromLocalDateTime to MessageListRequest - Add setStartDateFromLocalDateTime/setEndDateFromLocalDateTime to MessageListBaseRequest - Add setScheduledDateFromLocalDateTime() to MultipleDetailMessageSendingRequest - Add secondary constructor and fromLocalDateTime() to KakaoTemplateDateQuery - Support both system default and explicit ZoneId for timezone handling - Preserve nanosecond precision using ISO-8601 string conversion - Add unit tests and E2E tests for scheduled message sending --- .../dto/request/MessageListBaseRequest.kt | 21 +- .../message/dto/request/MessageListRequest.kt | 21 +- .../MultipleDetailMessageSendingRequest.kt | 13 +- .../message/dto/request/SendRequestConfig.kt | 13 +- .../request/kakao/KakaoTemplateDateQuery.kt | 28 ++ .../sdk/message/lib/LocalDateTimeSupport.kt | 43 +++ .../dto/request/DtoSerializationTest.kt | 113 +++++++ .../kakao/KakaoTemplateDateQueryTest.kt | 79 +++++ .../message/e2e/ScheduledMessageE2ETest.kt | 301 ++++++++++++++++++ .../message/lib/LocalDateTimeSupportTest.kt | 83 +++++ 10 files changed, 711 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/solapi/sdk/message/lib/LocalDateTimeSupport.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQueryTest.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/ScheduledMessageE2ETest.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/lib/LocalDateTimeSupportTest.kt diff --git a/src/main/java/com/solapi/sdk/message/dto/request/MessageListBaseRequest.kt b/src/main/java/com/solapi/sdk/message/dto/request/MessageListBaseRequest.kt index 6061606..519da29 100644 --- a/src/main/java/com/solapi/sdk/message/dto/request/MessageListBaseRequest.kt +++ b/src/main/java/com/solapi/sdk/message/dto/request/MessageListBaseRequest.kt @@ -1,6 +1,9 @@ package com.solapi.sdk.message.dto.request +import com.solapi.sdk.message.lib.toKotlinInstant import com.solapi.sdk.message.model.CommonMessageProperty +import java.time.LocalDateTime +import java.time.ZoneId import kotlin.time.Instant import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable @@ -24,4 +27,20 @@ data class MessageListBaseRequest( @Contextual var endDate: Instant? = null -) : CommonMessageProperty +) : CommonMessageProperty { + @JvmOverloads + fun setStartDateFromLocalDateTime( + localDateTime: LocalDateTime, + zoneId: ZoneId = ZoneId.systemDefault() + ) { + this.startDate = localDateTime.toKotlinInstant(zoneId) + } + + @JvmOverloads + fun setEndDateFromLocalDateTime( + localDateTime: LocalDateTime, + zoneId: ZoneId = ZoneId.systemDefault() + ) { + this.endDate = localDateTime.toKotlinInstant(zoneId) + } +} diff --git a/src/main/java/com/solapi/sdk/message/dto/request/MessageListRequest.kt b/src/main/java/com/solapi/sdk/message/dto/request/MessageListRequest.kt index 009e41d..8e81242 100644 --- a/src/main/java/com/solapi/sdk/message/dto/request/MessageListRequest.kt +++ b/src/main/java/com/solapi/sdk/message/dto/request/MessageListRequest.kt @@ -1,7 +1,10 @@ package com.solapi.sdk.message.dto.request +import com.solapi.sdk.message.lib.toKotlinInstant import com.solapi.sdk.message.model.CommonMessageProperty import com.solapi.sdk.message.model.MessageStatusType +import java.time.LocalDateTime +import java.time.ZoneId import kotlin.time.Instant import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable @@ -70,4 +73,20 @@ data class MessageListRequest( * 발송 상태 */ var status: MessageStatusType? = null -) : CommonMessageProperty +) : CommonMessageProperty { + @JvmOverloads + fun setStartDateFromLocalDateTime( + localDateTime: LocalDateTime, + zoneId: ZoneId = ZoneId.systemDefault() + ) { + this.startDate = localDateTime.toKotlinInstant(zoneId) + } + + @JvmOverloads + fun setEndDateFromLocalDateTime( + localDateTime: LocalDateTime, + zoneId: ZoneId = ZoneId.systemDefault() + ) { + this.endDate = localDateTime.toKotlinInstant(zoneId) + } +} diff --git a/src/main/java/com/solapi/sdk/message/dto/request/MultipleDetailMessageSendingRequest.kt b/src/main/java/com/solapi/sdk/message/dto/request/MultipleDetailMessageSendingRequest.kt index 87a736f..7a72264 100644 --- a/src/main/java/com/solapi/sdk/message/dto/request/MultipleDetailMessageSendingRequest.kt +++ b/src/main/java/com/solapi/sdk/message/dto/request/MultipleDetailMessageSendingRequest.kt @@ -1,6 +1,9 @@ package com.solapi.sdk.message.dto.request +import com.solapi.sdk.message.lib.toKotlinInstant import com.solapi.sdk.message.model.Message +import java.time.LocalDateTime +import java.time.ZoneId import kotlin.time.Instant import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable @@ -11,4 +14,12 @@ data class MultipleDetailMessageSendingRequest( @Contextual var scheduledDate: Instant? = null, var showMessageList: Boolean = false, -) : AbstractDefaultMessageRequest() +) : AbstractDefaultMessageRequest() { + @JvmOverloads + fun setScheduledDateFromLocalDateTime( + localDateTime: LocalDateTime, + zoneId: ZoneId = ZoneId.systemDefault() + ) { + this.scheduledDate = localDateTime.toKotlinInstant(zoneId) + } +} diff --git a/src/main/java/com/solapi/sdk/message/dto/request/SendRequestConfig.kt b/src/main/java/com/solapi/sdk/message/dto/request/SendRequestConfig.kt index 7c8662d..2160019 100644 --- a/src/main/java/com/solapi/sdk/message/dto/request/SendRequestConfig.kt +++ b/src/main/java/com/solapi/sdk/message/dto/request/SendRequestConfig.kt @@ -1,5 +1,8 @@ package com.solapi.sdk.message.dto.request +import com.solapi.sdk.message.lib.toKotlinInstant +import java.time.LocalDateTime +import java.time.ZoneId import kotlin.time.Instant import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable @@ -11,4 +14,12 @@ data class SendRequestConfig( var showMessageList: Boolean = false, @Contextual var scheduledDate: Instant? = null -) +) { + @JvmOverloads + fun setScheduledDateFromLocalDateTime( + localDateTime: LocalDateTime, + zoneId: ZoneId = ZoneId.systemDefault() + ) { + this.scheduledDate = localDateTime.toKotlinInstant(zoneId) + } +} diff --git a/src/main/java/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQuery.kt b/src/main/java/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQuery.kt index 13153b2..e221749 100644 --- a/src/main/java/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQuery.kt +++ b/src/main/java/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQuery.kt @@ -1,11 +1,24 @@ package com.solapi.sdk.message.dto.request.kakao +import com.solapi.sdk.message.lib.toKotlinInstant +import java.time.LocalDateTime +import java.time.ZoneId import kotlin.time.Instant data class KakaoTemplateDateQuery( val date: Instant, val queryCondition: KakaoAlimtalkTemplateDateQueryCondition, ) { + @JvmOverloads + constructor( + localDateTime: LocalDateTime, + queryCondition: KakaoAlimtalkTemplateDateQueryCondition, + zoneId: ZoneId = ZoneId.systemDefault() + ) : this( + date = localDateTime.toKotlinInstant(zoneId), + queryCondition = queryCondition + ) + enum class KakaoAlimtalkTemplateDateQueryCondition { EQUALS, GREATER_THEN_OR_EQUAL, @@ -13,4 +26,19 @@ data class KakaoTemplateDateQuery( LESS_THEN_OR_EQUAL, LESS_THEN } + + companion object { + @JvmStatic + @JvmOverloads + fun fromLocalDateTime( + localDateTime: LocalDateTime, + queryCondition: KakaoAlimtalkTemplateDateQueryCondition, + zoneId: ZoneId = ZoneId.systemDefault() + ): KakaoTemplateDateQuery { + return KakaoTemplateDateQuery( + date = localDateTime.toKotlinInstant(zoneId), + queryCondition = queryCondition + ) + } + } } diff --git a/src/main/java/com/solapi/sdk/message/lib/LocalDateTimeSupport.kt b/src/main/java/com/solapi/sdk/message/lib/LocalDateTimeSupport.kt new file mode 100644 index 0000000..260c9f0 --- /dev/null +++ b/src/main/java/com/solapi/sdk/message/lib/LocalDateTimeSupport.kt @@ -0,0 +1,43 @@ +package com.solapi.sdk.message.lib + +import kotlin.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime + +/** + * LocalDateTime을 kotlin.time.Instant로 변환하는 유틸리티. + * + * LocalDateTime은 시간대 정보가 없으므로, 변환 시 시간대를 지정해야 합니다. + * 기본값은 시스템 기본 시간대(ZoneId.systemDefault())입니다. + * + * 나노초 정밀도를 유지하기 위해 ISO-8601 문자열 변환을 사용합니다. + */ +object LocalDateTimeSupport { + /** + * LocalDateTime을 kotlin.time.Instant로 변환합니다. + * + * @param localDateTime 변환할 LocalDateTime + * @param zoneId 적용할 시간대 (기본값: 시스템 기본 시간대) + * @return 변환된 kotlin.time.Instant + */ + @JvmStatic + @JvmOverloads + fun toKotlinInstant(localDateTime: LocalDateTime, zoneId: ZoneId = ZoneId.systemDefault()): Instant { + val zonedDateTime: ZonedDateTime = localDateTime.atZone(zoneId) + val javaInstant: java.time.Instant = zonedDateTime.toInstant() + // Use ISO-8601 string conversion to preserve nanosecond precision + return Instant.parse(javaInstant.toString()) + } +} + +/** + * LocalDateTime을 kotlin.time.Instant로 변환하는 확장 함수. + * + * @param zoneId 적용할 시간대 (기본값: 시스템 기본 시간대) + * @return 변환된 kotlin.time.Instant + */ +@JvmOverloads +fun LocalDateTime.toKotlinInstant(zoneId: ZoneId = ZoneId.systemDefault()): Instant { + return LocalDateTimeSupport.toKotlinInstant(this, zoneId) +} diff --git a/src/test/kotlin/com/solapi/sdk/message/dto/request/DtoSerializationTest.kt b/src/test/kotlin/com/solapi/sdk/message/dto/request/DtoSerializationTest.kt index a676105..85255d8 100644 --- a/src/test/kotlin/com/solapi/sdk/message/dto/request/DtoSerializationTest.kt +++ b/src/test/kotlin/com/solapi/sdk/message/dto/request/DtoSerializationTest.kt @@ -157,4 +157,117 @@ class DtoSerializationTest { // Then assertEquals(preciseInstant, restored.scheduledDate) } + + @Test + fun `SendRequestConfig setScheduledDateFromLocalDateTime with system default timezone`() { + // Given + val config = SendRequestConfig(appId = "test-app") + val localDateTime = java.time.LocalDateTime.of(2024, 6, 15, 14, 30, 0) + + // When + config.setScheduledDateFromLocalDateTime(localDateTime) + + // Then + val expectedJavaInstant = localDateTime + .atZone(java.time.ZoneId.systemDefault()) + .toInstant() + val actualJavaInstant = java.time.Instant.parse(config.scheduledDate.toString()) + assertEquals(expectedJavaInstant, actualJavaInstant) + } + + @Test + fun `SendRequestConfig setScheduledDateFromLocalDateTime with explicit UTC timezone`() { + // Given + val config = SendRequestConfig(appId = "test-app") + val localDateTime = java.time.LocalDateTime.of(2024, 6, 15, 14, 30, 0) + + // When + config.setScheduledDateFromLocalDateTime(localDateTime, java.time.ZoneOffset.UTC) + + // Then + val expectedInstant = Instant.parse("2024-06-15T14:30:00Z") + assertEquals(expectedInstant, config.scheduledDate) + } + + @Test + fun `SendRequestConfig backward compatibility - existing Instant API still works`() { + // Given + val instant = Instant.parse("2024-06-15T14:30:00Z") + + // When + val config = SendRequestConfig( + appId = "test-app", + scheduledDate = instant + ) + + // Then + assertEquals(instant, config.scheduledDate) + val json = JsonSupport.json.encodeToString(config) + assertTrue(json.contains("\"scheduledDate\":\"2024-06-15T14:30:00Z\"")) + } + + @Test + fun `MessageListRequest setStartDateFromLocalDateTime works`() { + // Given + val request = MessageListRequest() + val localDateTime = java.time.LocalDateTime.of(2024, 1, 1, 0, 0, 0) + + // When + request.setStartDateFromLocalDateTime(localDateTime, java.time.ZoneOffset.UTC) + + // Then + assertEquals(Instant.parse("2024-01-01T00:00:00Z"), request.startDate) + } + + @Test + fun `MessageListRequest setEndDateFromLocalDateTime works`() { + // Given + val request = MessageListRequest() + val localDateTime = java.time.LocalDateTime.of(2024, 12, 31, 23, 59, 59) + + // When + request.setEndDateFromLocalDateTime(localDateTime, java.time.ZoneOffset.UTC) + + // Then + assertEquals(Instant.parse("2024-12-31T23:59:59Z"), request.endDate) + } + + @Test + fun `MessageListBaseRequest setStartDateFromLocalDateTime works`() { + // Given + val request = MessageListBaseRequest() + val localDateTime = java.time.LocalDateTime.of(2024, 2, 1, 0, 0, 0) + + // When + request.setStartDateFromLocalDateTime(localDateTime, java.time.ZoneOffset.UTC) + + // Then + assertEquals(Instant.parse("2024-02-01T00:00:00Z"), request.startDate) + } + + @Test + fun `MessageListBaseRequest setEndDateFromLocalDateTime works`() { + // Given + val request = MessageListBaseRequest() + val localDateTime = java.time.LocalDateTime.of(2024, 2, 28, 23, 59, 59) + + // When + request.setEndDateFromLocalDateTime(localDateTime, java.time.ZoneOffset.UTC) + + // Then + assertEquals(Instant.parse("2024-02-28T23:59:59Z"), request.endDate) + } + + @Test + fun `MultipleDetailMessageSendingRequest setScheduledDateFromLocalDateTime works`() { + // Given + val request = MultipleDetailMessageSendingRequest(messages = emptyList()) + val localDateTime = java.time.LocalDateTime.of(2024, 3, 15, 9, 0, 0) + + // When + request.setScheduledDateFromLocalDateTime(localDateTime, java.time.ZoneOffset.UTC) + + // Then + assertEquals(Instant.parse("2024-03-15T09:00:00Z"), request.scheduledDate) + } } diff --git a/src/test/kotlin/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQueryTest.kt b/src/test/kotlin/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQueryTest.kt new file mode 100644 index 0000000..4ad07e6 --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQueryTest.kt @@ -0,0 +1,79 @@ +package com.solapi.sdk.message.dto.request.kakao + +import java.time.LocalDateTime +import java.time.ZoneOffset +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +class KakaoTemplateDateQueryTest { + + @Test + fun `secondary constructor creates query from LocalDateTime`() { + // Given + val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0) + + // When + val query = KakaoTemplateDateQuery( + localDateTime, + KakaoTemplateDateQuery.KakaoAlimtalkTemplateDateQueryCondition.EQUALS, + ZoneOffset.UTC + ) + + // Then + assertEquals(Instant.parse("2024-06-15T14:30:00Z"), query.date) + assertEquals(KakaoTemplateDateQuery.KakaoAlimtalkTemplateDateQueryCondition.EQUALS, query.queryCondition) + } + + @Test + fun `fromLocalDateTime factory method creates query`() { + // Given + val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0) + + // When + val query = KakaoTemplateDateQuery.fromLocalDateTime( + localDateTime, + KakaoTemplateDateQuery.KakaoAlimtalkTemplateDateQueryCondition.GREATER_THEN, + ZoneOffset.UTC + ) + + // Then + assertEquals(Instant.parse("2024-06-15T14:30:00Z"), query.date) + assertEquals(KakaoTemplateDateQuery.KakaoAlimtalkTemplateDateQueryCondition.GREATER_THEN, query.queryCondition) + } + + @Test + fun `existing Instant constructor still works - backward compatibility`() { + // Given + val instant = Instant.parse("2024-06-15T14:30:00Z") + + // When + val query = KakaoTemplateDateQuery( + instant, + KakaoTemplateDateQuery.KakaoAlimtalkTemplateDateQueryCondition.LESS_THEN + ) + + // Then + assertEquals(instant, query.date) + assertEquals(KakaoTemplateDateQuery.KakaoAlimtalkTemplateDateQueryCondition.LESS_THEN, query.queryCondition) + } + + @Test + fun `secondary constructor uses system default timezone when not specified`() { + // Given + val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0) + + // When + val query = KakaoTemplateDateQuery( + localDateTime, + KakaoTemplateDateQuery.KakaoAlimtalkTemplateDateQueryCondition.EQUALS + ) + + // Then + val expectedJavaInstant = localDateTime + .atZone(java.time.ZoneId.systemDefault()) + .toInstant() + val actualJavaInstant = java.time.Instant.parse(query.date.toString()) + assertEquals(expectedJavaInstant, actualJavaInstant) + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/ScheduledMessageE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/ScheduledMessageE2ETest.kt new file mode 100644 index 0000000..0b2754b --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/ScheduledMessageE2ETest.kt @@ -0,0 +1,301 @@ +package com.solapi.sdk.message.e2e + +import com.solapi.sdk.SolapiClient +import com.solapi.sdk.message.dto.request.SendRequestConfig +import com.solapi.sdk.message.exception.SolapiMessageNotReceivedException +import com.solapi.sdk.message.model.Message +import com.solapi.sdk.message.service.DefaultMessageService +import java.time.LocalDateTime +import java.time.ZoneId +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * 예약 발송 E2E 테스트 + * + * 환경변수 설정 필요: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 테스트 수신번호 + */ +class ScheduledMessageE2ETest { + + private val apiKey: String? = System.getenv("SOLAPI_API_KEY") + private val apiSecret: String? = System.getenv("SOLAPI_API_SECRET") + private val senderNumber: String = System.getenv("SOLAPI_SENDER") ?: "01000000000" + private val testPhoneNumber: String = System.getenv("SOLAPI_RECIPIENT") ?: "01000000000" + + private val messageService: DefaultMessageService? by lazy { + if (apiKey != null && apiSecret != null) { + SolapiClient.createInstance(apiKey, apiSecret) + } else { + null + } + } + + private fun assumeEnvironmentConfigured(): Boolean { + if (apiKey.isNullOrBlank() || apiSecret.isNullOrBlank()) { + println("환경변수가 설정되지 않아 테스트를 건너뜁니다. (SOLAPI_API_KEY, SOLAPI_API_SECRET 필요)") + return false + } + return true + } + + private fun printExceptionDetails(e: Exception) { + println("예상된 에러 발생: ${e.message}") + if (e is SolapiMessageNotReceivedException) { + println(" 실패한 메시지 목록 (${e.failedMessageList.size}건):") + e.failedMessageList.forEachIndexed { index, failed -> + println(" [${index + 1}] to: ${failed.to}, statusCode: ${failed.statusCode}, statusMessage: ${failed.statusMessage}") + } + } + } + + // ==================== LocalDateTime 예약 발송 테스트 ==================== + + @Test + fun `예약 발송 - LocalDateTime 시스템 기본 타임존 사용`() { + if (!assumeEnvironmentConfigured()) return + + // Given + val message = Message( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] LocalDateTime 예약 발송 테스트 (시스템 기본 타임존)" + ) + + val scheduledTime = LocalDateTime.now().plusMinutes(10) + val config = SendRequestConfig() + config.setScheduledDateFromLocalDateTime(scheduledTime) + + println("예약 시간 (LocalDateTime): $scheduledTime") + println("예약 시간 (Instant): ${config.scheduledDate}") + + // When + val response = messageService!!.send(message, config) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("예약 발송 성공 - groupId: ${response.groupInfo?.groupId}") + println(" scheduledDate: ${response.groupInfo?.scheduledDate}") + } + + @Test + fun `예약 발송 - LocalDateTime 명시적 타임존 사용 (Asia Seoul)`() { + if (!assumeEnvironmentConfigured()) return + + // Given + val message = Message( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] LocalDateTime 예약 발송 테스트 (Asia/Seoul)" + ) + + val seoulZone = ZoneId.of("Asia/Seoul") + val scheduledTime = LocalDateTime.now(seoulZone).plusMinutes(15) + val config = SendRequestConfig() + config.setScheduledDateFromLocalDateTime(scheduledTime, seoulZone) + + println("예약 시간 (LocalDateTime, Seoul): $scheduledTime") + println("예약 시간 (Instant/UTC): ${config.scheduledDate}") + + // When + val response = messageService!!.send(message, config) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("예약 발송 성공 - groupId: ${response.groupInfo?.groupId}") + println(" scheduledDate: ${response.groupInfo?.scheduledDate}") + } + + @Test + fun `예약 발송 - 기존 Instant API 하위호환성 확인`() { + if (!assumeEnvironmentConfigured()) return + + // Given + val message = Message( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] Instant 예약 발송 테스트 (하위호환성)" + ) + + val scheduledInstant = Instant.fromEpochMilliseconds( + System.currentTimeMillis() + 20 * 60 * 1000 // 20분 후 + ) + val config = SendRequestConfig(scheduledDate = scheduledInstant) + + println("예약 시간 (Instant): $scheduledInstant") + + // When + val response = messageService!!.send(message, config) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("예약 발송 성공 (Instant API) - groupId: ${response.groupInfo?.groupId}") + println(" scheduledDate: ${response.groupInfo?.scheduledDate}") + } + + @Test + fun `예약 발송 - 다중 메시지 LocalDateTime 사용`() { + if (!assumeEnvironmentConfigured()) return + + // Given + val messages = listOf( + Message( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 다중 메시지 예약 발송 1/2" + ), + Message( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 다중 메시지 예약 발송 2/2" + ) + ) + + val scheduledTime = LocalDateTime.now().plusMinutes(25) + val config = SendRequestConfig() + config.setScheduledDateFromLocalDateTime(scheduledTime) + + println("예약 시간: $scheduledTime") + println("메시지 수: ${messages.size}") + + // When + val response = messageService!!.send(messages, config) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("다중 메시지 예약 발송 성공 - groupId: ${response.groupInfo?.groupId}") + println(" count: ${response.groupInfo?.count}") + println(" scheduledDate: ${response.groupInfo?.scheduledDate}") + } + + // ==================== 특수 케이스 테스트 ==================== + + @Test + fun `예약 발송 - 과거 시간 지정시 즉시 발송 처리`() { + if (!assumeEnvironmentConfigured()) return + + // Given + val message = Message( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 과거 시간 예약 발송 (즉시 발송 예상)" + ) + + val pastTime = LocalDateTime.now().minusMinutes(10) + val config = SendRequestConfig() + config.setScheduledDateFromLocalDateTime(pastTime) + + println("예약 시간 (과거): $pastTime") + + // When + val response = messageService!!.send(message, config) + + // Then - 과거 시간은 에러 없이 즉시 발송 처리됨 + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("과거 시간 예약 → 즉시 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `예약 발송 - 6개월 이내 미래 시간은 성공`() { + if (!assumeEnvironmentConfigured()) return + + // Given + val message = Message( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 5개월 후 예약 발송" + ) + + val fiveMonthsLater = LocalDateTime.now().plusMonths(5) + val config = SendRequestConfig() + config.setScheduledDateFromLocalDateTime(fiveMonthsLater) + + println("예약 시간 (5개월 후): $fiveMonthsLater") + + // When + val response = messageService!!.send(message, config) + + // Then - 6개월 이내는 성공 + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("5개월 후 예약 발송 성공 - groupId: ${response.groupInfo?.groupId}") + println(" scheduledDate: ${response.groupInfo?.scheduledDate}") + } + + @Test + fun `예약 발송 - 6개월 초과 미래 시간 지정시 에러`() { + if (!assumeEnvironmentConfigured()) return + + // Given + val message = Message( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 7개월 후 예약 발송 (에러 예상)" + ) + + val sevenMonthsLater = LocalDateTime.now().plusMonths(7) + val config = SendRequestConfig() + config.setScheduledDateFromLocalDateTime(sevenMonthsLater) + + println("예약 시간 (7개월 후): $sevenMonthsLater") + + // When & Then + var errorOccurred = false + try { + messageService!!.send(message, config) + } catch (e: Exception) { + errorOccurred = true + printExceptionDetails(e) + } + + assertTrue(errorOccurred, "6개월 초과 예약 발송 시 에러가 발생해야 함") + } + + // ==================== 나노초 정밀도 테스트 ==================== + + @Test + fun `예약 발송 - 나노초 정밀도 유지 확인`() { + if (!assumeEnvironmentConfigured()) return + + // Given + val message = Message( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 나노초 정밀도 테스트" + ) + + // 나노초가 포함된 시간 + val scheduledTime = LocalDateTime.now().plusMinutes(30).withNano(123456789) + val config = SendRequestConfig() + config.setScheduledDateFromLocalDateTime(scheduledTime) + + println("예약 시간 (나노초 포함): $scheduledTime") + println("변환된 Instant: ${config.scheduledDate}") + + // 나노초가 보존되었는지 확인 + val instantString = config.scheduledDate.toString() + println("Instant 문자열: $instantString") + assertTrue( + instantString.contains(".123456789Z") || instantString.contains(".123456789"), + "나노초 정밀도가 유지되어야 함" + ) + + // When + val response = messageService!!.send(message, config) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("나노초 정밀도 테스트 성공 - groupId: ${response.groupInfo?.groupId}") + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/lib/LocalDateTimeSupportTest.kt b/src/test/kotlin/com/solapi/sdk/message/lib/LocalDateTimeSupportTest.kt new file mode 100644 index 0000000..28f4c62 --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/lib/LocalDateTimeSupportTest.kt @@ -0,0 +1,83 @@ +package com.solapi.sdk.message.lib + +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +class LocalDateTimeSupportTest { + + @Test + fun `toKotlinInstant converts LocalDateTime with system default timezone`() { + // Given + val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0) + + // When + val instant = localDateTime.toKotlinInstant() + + // Then + val expectedJavaInstant = localDateTime + .atZone(ZoneId.systemDefault()) + .toInstant() + val actualJavaInstant = java.time.Instant.parse(instant.toString()) + assertEquals(expectedJavaInstant, actualJavaInstant) + } + + @Test + fun `toKotlinInstant converts LocalDateTime with explicit UTC timezone`() { + // Given + val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0) + val utcZone = ZoneOffset.UTC + + // When + val instant = localDateTime.toKotlinInstant(utcZone) + + // Then + val expectedInstant = Instant.parse("2024-06-15T14:30:00Z") + assertEquals(expectedInstant, instant) + } + + @Test + fun `toKotlinInstant converts LocalDateTime with Asia Seoul timezone`() { + // Given + val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0) + val seoulZone = ZoneId.of("Asia/Seoul") + + // When + val instant = localDateTime.toKotlinInstant(seoulZone) + + // Then + val expectedInstant = Instant.parse("2024-06-15T05:30:00Z") + assertEquals(expectedInstant, instant) + } + + @Test + fun `LocalDateTimeSupport static method works for Java interop`() { + // Given + val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0) + val utcZone = ZoneOffset.UTC + + // When + val instant = LocalDateTimeSupport.toKotlinInstant(localDateTime, utcZone) + + // Then + val expectedInstant = Instant.parse("2024-06-15T14:30:00Z") + assertEquals(expectedInstant, instant) + } + + @Test + fun `toKotlinInstant preserves nanosecond precision`() { + // Given + val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0, 123456789) + val utcZone = ZoneOffset.UTC + + // When + val instant = localDateTime.toKotlinInstant(utcZone) + + // Then + val expectedInstant = Instant.parse("2024-06-15T14:30:00.123456789Z") + assertEquals(expectedInstant, instant) + } +} From e2889f6c9fe6819784c4d208a5304e58a2ed9489 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 28 Jan 2026 15:46:30 +0900 Subject: [PATCH 18/43] build: add kotlinx.serialization to shadow JAR relocate list Relocate kotlinx.serialization to com.solapi.shadow.kotlinx.serialization to prevent dependency conflicts with external SDK users who may use different versions of kotlinx-serialization in their projects. --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 613a712..6edf627 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -128,12 +128,12 @@ tasks.named("sourcesJar") { tasks.shadowJar { mergeServiceFiles() - // 의존성 충돌을 피하기 위해 필요한 패키지만 relocate relocate("com.fasterxml", "com.solapi.shadow.com.fasterxml") relocate("okhttp3", "com.solapi.shadow.okhttp3") relocate("okio", "com.solapi.shadow.okio") relocate("retrofit2", "com.solapi.shadow.retrofit2") relocate("org.apache", "com.solapi.shadow.org.apache") + relocate("kotlinx.serialization", "com.solapi.shadow.kotlinx.serialization") archiveClassifier.set("") } From 7078b204a98cf892d71cc3c894ab7f4bf808cb74 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 29 Jan 2026 10:15:38 +0900 Subject: [PATCH 19/43] Remove NurigoApp.kt --- src/main/java/com/solapi/sdk/NurigoApp.kt | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 src/main/java/com/solapi/sdk/NurigoApp.kt diff --git a/src/main/java/com/solapi/sdk/NurigoApp.kt b/src/main/java/com/solapi/sdk/NurigoApp.kt deleted file mode 100644 index 2949efb..0000000 --- a/src/main/java/com/solapi/sdk/NurigoApp.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.solapi.sdk - -import com.solapi.sdk.message.service.DefaultMessageService - -object NurigoApp { - @Deprecated( - message = "This method will be removed in a future version", - replaceWith = ReplaceWith("SolapiClient.createInstance(apiKey, apiSecretKey)"), - level = DeprecationLevel.WARNING - ) - fun initialize(apiKey: String, apiSecretKey: String, apiUrl: String): DefaultMessageService { - return DefaultMessageService(apiKey, apiSecretKey, apiUrl) - } -} \ No newline at end of file From d74385d36fa0754a88d0f12069e3d13e69042bd3 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 29 Jan 2026 11:04:40 +0900 Subject: [PATCH 20/43] refactor: extract BaseE2ETest and E2ETestUtils for E2E test infrastructure - Add BaseE2ETest with common environment variable handling - API key/secret configuration - Sender/recipient phone numbers - Kakao pfId and templateId - assumeBasicEnvironmentConfigured/assumeKakaoEnvironmentConfigured helpers - printExceptionDetails for error handling - Add E2ETestUtils with reusable message factory methods - SMS, LMS, MMS message creation - Kakao Alimtalk/FriendTalk message creation - Voice message creation - Kakao button factories (WL, AL, BK, MD, AC) - Custom fields message creation - Batch SMS message creation - Long text generation for LMS tests Co-Authored-By: Claude Opus 4.5 --- .../sdk/message/e2e/base/BaseE2ETest.kt | 87 +++++ .../sdk/message/e2e/lib/E2ETestUtils.kt | 296 ++++++++++++++++++ 2 files changed, 383 insertions(+) create mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/base/BaseE2ETest.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/lib/E2ETestUtils.kt diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/base/BaseE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/base/BaseE2ETest.kt new file mode 100644 index 0000000..5fe523d --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/base/BaseE2ETest.kt @@ -0,0 +1,87 @@ +package com.solapi.sdk.message.e2e.base + +import com.solapi.sdk.SolapiClient +import com.solapi.sdk.message.exception.SolapiMessageNotReceivedException +import com.solapi.sdk.message.service.DefaultMessageService + +/** + * E2E 테스트를 위한 공통 베이스 클래스 + * + * 환경변수 설정 필요: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 테스트 수신번호 + * + * 카카오 테스트 추가 환경변수: + * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID + * - SOLAPI_KAKAO_TEMPLATE_ID: 카카오 알림톡 템플릿 ID (선택) + */ +abstract class BaseE2ETest { + + protected val apiKey: String? = System.getenv("SOLAPI_API_KEY") + protected val apiSecret: String? = System.getenv("SOLAPI_API_SECRET") + protected val senderNumber: String = System.getenv("SOLAPI_SENDER") ?: "01000000000" + protected val testPhoneNumber: String = System.getenv("SOLAPI_RECIPIENT") ?: "01000000000" + protected val pfId: String? = System.getenv("SOLAPI_KAKAO_PF_ID") + protected val templateId: String? = System.getenv("SOLAPI_KAKAO_TEMPLATE_ID") + + protected val messageService: DefaultMessageService? by lazy { + if (apiKey != null && apiSecret != null) { + SolapiClient.createInstance(apiKey, apiSecret) + } else { + null + } + } + + /** + * 기본 환경변수 설정 여부 확인 (API Key, Secret) + * @return 환경변수가 설정되었으면 true, 아니면 false + */ + protected fun assumeBasicEnvironmentConfigured(): Boolean { + if (apiKey.isNullOrBlank() || apiSecret.isNullOrBlank()) { + println("환경변수가 설정되지 않아 테스트를 건너뜁니다. (SOLAPI_API_KEY, SOLAPI_API_SECRET 필요)") + return false + } + return true + } + + /** + * 카카오 환경변수 설정 여부 확인 (pfId 포함) + * @return 환경변수가 설정되었으면 true, 아니면 false + */ + protected fun assumeKakaoEnvironmentConfigured(): Boolean { + if (!assumeBasicEnvironmentConfigured()) return false + if (pfId.isNullOrBlank()) { + println("카카오 환경변수가 설정되지 않아 테스트를 건너뜁니다. (SOLAPI_KAKAO_PF_ID 필요)") + return false + } + return true + } + + /** + * 카카오 알림톡 템플릿 환경변수 설정 여부 확인 + * @return 환경변수가 설정되었으면 true, 아니면 false + */ + protected fun assumeKakaoTemplateConfigured(): Boolean { + if (!assumeKakaoEnvironmentConfigured()) return false + if (templateId.isNullOrBlank()) { + println("카카오 템플릿 환경변수가 설정되지 않아 테스트를 건너뜁니다. (SOLAPI_KAKAO_TEMPLATE_ID 필요)") + return false + } + return true + } + + /** + * 예외 상세 정보 출력 + */ + protected fun printExceptionDetails(e: Exception) { + println("예상된 에러 발생: ${e.message}") + if (e is SolapiMessageNotReceivedException) { + println(" 실패한 메시지 목록 (${e.failedMessageList.size}건):") + e.failedMessageList.forEachIndexed { index, failed -> + println(" [${index + 1}] to: ${failed.to}, statusCode: ${failed.statusCode}, statusMessage: ${failed.statusMessage}") + } + } + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/lib/E2ETestUtils.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/lib/E2ETestUtils.kt new file mode 100644 index 0000000..5ee8541 --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/lib/E2ETestUtils.kt @@ -0,0 +1,296 @@ +package com.solapi.sdk.message.e2e.lib + +import com.solapi.sdk.message.model.Message +import com.solapi.sdk.message.model.MessageType +import com.solapi.sdk.message.model.kakao.KakaoButton +import com.solapi.sdk.message.model.kakao.KakaoButtonType +import com.solapi.sdk.message.model.kakao.KakaoOption +import com.solapi.sdk.message.model.voice.VoiceOption +import com.solapi.sdk.message.model.voice.VoiceType + +/** + * E2E 테스트를 위한 메시지 팩토리 및 유틸리티 함수들 + */ +object E2ETestUtils { + + // ==================== SMS/LMS/MMS 메시지 팩토리 ==================== + + /** + * SMS 메시지 생성 + */ + fun createSmsMessage( + from: String, + to: String, + text: String = "[SDK 테스트] SMS 메시지입니다." + ): Message = Message( + from = from, + to = to, + text = text + ) + + /** + * LMS 메시지 생성 (명시적 타입 지정) + */ + fun createLmsMessage( + from: String, + to: String, + text: String = generateLongText(100), + subject: String? = null + ): Message = Message( + type = MessageType.LMS, + from = from, + to = to, + text = text, + subject = subject + ) + + /** + * MMS 메시지 생성 + */ + fun createMmsMessage( + from: String, + to: String, + text: String = "[SDK 테스트] MMS 메시지입니다.", + imageId: String, + subject: String? = null + ): Message = Message( + type = MessageType.MMS, + from = from, + to = to, + text = text, + imageId = imageId, + subject = subject + ) + + // ==================== 카카오 메시지 팩토리 ==================== + + /** + * 알림톡 메시지 생성 + */ + fun createAlimtalkMessage( + from: String, + to: String, + pfId: String, + templateId: String, + variables: Map? = null + ): Message = Message( + type = MessageType.ATA, + from = from, + to = to, + kakaoOptions = KakaoOption( + pfId = pfId, + templateId = templateId, + variables = variables + ) + ) + + /** + * 알림톡 메시지 생성 (대체 발송 비활성화) + */ + fun createAlimtalkMessageWithoutFallback( + from: String, + to: String, + pfId: String, + templateId: String, + variables: Map? = null + ): Message = Message( + type = MessageType.ATA, + from = from, + to = to, + kakaoOptions = KakaoOption( + pfId = pfId, + templateId = templateId, + variables = variables, + disableSms = true + ) + ) + + /** + * 친구톡 메시지 생성 (텍스트만) + */ + fun createFriendTalkMessage( + from: String, + to: String, + text: String = "[SDK 테스트] 친구톡 메시지입니다.", + pfId: String, + buttons: List? = null, + adFlag: Boolean = false + ): Message = Message( + type = MessageType.CTA, + from = from, + to = to, + text = text, + kakaoOptions = KakaoOption( + pfId = pfId, + buttons = buttons, + adFlag = adFlag + ) + ) + + /** + * 친구톡 이미지 메시지 생성 + */ + fun createFriendTalkImageMessage( + from: String, + to: String, + text: String = "[SDK 테스트] 친구톡 이미지 메시지입니다.", + pfId: String, + imageId: String, + buttons: List? = null, + adFlag: Boolean = false + ): Message = Message( + type = MessageType.CTI, + from = from, + to = to, + text = text, + kakaoOptions = KakaoOption( + pfId = pfId, + imageId = imageId, + buttons = buttons, + adFlag = adFlag + ) + ) + + // ==================== 음성 메시지 팩토리 ==================== + + /** + * 음성 메시지 생성 + */ + fun createVoiceMessage( + from: String, + to: String, + text: String = "안녕하세요. 테스트 음성 메시지입니다.", + voiceType: VoiceType = VoiceType.FEMALE, + headerMessage: String? = null, + tailMessage: String? = null + ): Message = Message( + type = MessageType.VOICE, + from = from, + to = to, + text = text, + voiceOptions = VoiceOption( + voiceType = voiceType, + headerMessage = headerMessage, + tailMessage = tailMessage + ) + ) + + // ==================== 카카오 버튼 팩토리 ==================== + + /** + * 웹링크 버튼 생성 + */ + fun createWebLinkButton( + buttonName: String = "바로가기", + linkMo: String = "https://example.com", + linkPc: String? = null + ): KakaoButton = KakaoButton( + buttonName = buttonName, + buttonType = KakaoButtonType.WL, + linkMo = linkMo, + linkPc = linkPc + ) + + /** + * 앱링크 버튼 생성 + */ + fun createAppLinkButton( + buttonName: String = "앱 열기", + linkAnd: String = "intent://main", + linkIos: String = "iosapp://main" + ): KakaoButton = KakaoButton( + buttonName = buttonName, + buttonType = KakaoButtonType.AL, + linkAnd = linkAnd, + linkIos = linkIos + ) + + /** + * 봇키워드 버튼 생성 + */ + fun createBotKeywordButton( + buttonName: String = "문의하기" + ): KakaoButton = KakaoButton( + buttonName = buttonName, + buttonType = KakaoButtonType.BK + ) + + /** + * 메시지전달 버튼 생성 + */ + fun createMessageDeliveryButton( + buttonName: String = "전달하기" + ): KakaoButton = KakaoButton( + buttonName = buttonName, + buttonType = KakaoButtonType.MD + ) + + /** + * 채널 추가 버튼 생성 + */ + fun createChannelAddButton( + buttonName: String = "채널 추가" + ): KakaoButton = KakaoButton( + buttonName = buttonName, + buttonType = KakaoButtonType.AC + ) + + // ==================== Custom Fields 메시지 팩토리 ==================== + + /** + * Custom Fields 포함 메시지 생성 + */ + fun createMessageWithCustomFields( + from: String, + to: String, + text: String = "[SDK 테스트] Custom Fields 테스트", + customFields: MutableMap + ): Message = Message( + from = from, + to = to, + text = text, + customFields = customFields + ) + + // ==================== 배치 메시지 팩토리 ==================== + + /** + * 배치 SMS 메시지 생성 + */ + fun createBatchSmsMessages( + from: String, + toList: List, + textPrefix: String = "[SDK 테스트] 배치 SMS" + ): List = toList.mapIndexed { index, to -> + Message( + from = from, + to = to, + text = "$textPrefix ${index + 1}/${toList.size}" + ) + } + + // ==================== 유틸리티 함수 ==================== + + /** + * LMS 테스트용 긴 텍스트 생성 + * @param byteLength 목표 바이트 길이 (한글 기준 약 2배 문자 수) + */ + fun generateLongText(byteLength: Int = 100): String { + val prefix = "[SDK 테스트] LMS 긴 메시지 테스트입니다. " + val filler = "가나다라마바사아자차카타파하" + val sb = StringBuilder(prefix) + + while (sb.toString().toByteArray(Charsets.UTF_8).size < byteLength) { + sb.append(filler) + } + + return sb.toString() + } + + /** + * 최대 길이 LMS 메시지 생성 (약 2000바이트) + */ + fun generateMaxLengthLmsText(): String { + return generateLongText(1900) // 약간의 여유를 두고 1900바이트 + } +} From 756741a3dbc02d0e399e7ebbb534ba399cd71b54 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 29 Jan 2026 11:05:13 +0900 Subject: [PATCH 21/43] test(e2e): add comprehensive E2E tests for solapi-kotlin SDK Add E2E tests covering all major SDK features: SMS Tests (SmsE2ETest): - Single message sending - Phone number with dashes (auto-stripped) - Batch sending to multiple recipients - Error cases: invalid sender, empty message LMS Tests (LmsE2ETest): - Explicit MessageType.LMS - Auto-detection via autoTypeDetect - Maximum length (~2000 bytes) - Subject with long text MMS Tests (MmsE2ETest): - Image upload to StorageType.MMS - Single MMS with image - Invalid imageId error case - Subject and body with image Kakao Alimtalk Tests (KakaoAlimtalkE2ETest): - Basic template sending - Variable substitution - Invalid template ID error case - SMS fallback disabled (disableSms) - Variable auto-formatting Kakao FriendTalk Tests (KakaoFriendTalkE2ETest): - Text-only CTA - Button types: WL, AL, BK, MD - Ad flag (adFlag) - Multiple buttons - Image CTI with buttons Voice Tests (VoiceE2ETest): - Female/Male voice types - Header message - Tail message (requires headerMessage) - Full options combination - Default voice type Message List Tests (MessageListE2ETest): - Basic query - Filters: from, to, type, status - Date range filtering - Pagination with startKey - Specific messageIds query - Combined filters Balance Tests (BalanceE2ETest): - Balance query - Daily quota query Custom Fields Tests (CustomFieldsE2ETest): - Basic custom fields - Various values (unicode, special chars) - Single field - Long value Duplicate Handling Tests (DuplicateHandlingE2ETest): - allowDuplicates: true/false - showMessageList option - Combined options Co-Authored-By: Claude Opus 4.5 --- .../solapi/sdk/message/e2e/BalanceE2ETest.kt | 42 +++ .../sdk/message/e2e/CustomFieldsE2ETest.kt | 132 ++++++++ .../message/e2e/DuplicateHandlingE2ETest.kt | 192 +++++++++++ .../sdk/message/e2e/KakaoAlimtalkE2ETest.kt | 153 +++++++++ .../sdk/message/e2e/KakaoFriendTalkE2ETest.kt | 313 ++++++++++++++++++ .../com/solapi/sdk/message/e2e/LmsE2ETest.kt | 114 +++++++ .../sdk/message/e2e/MessageListE2ETest.kt | 220 ++++++++++++ .../com/solapi/sdk/message/e2e/MmsE2ETest.kt | 161 +++++++++ .../com/solapi/sdk/message/e2e/SmsE2ETest.kt | 132 ++++++++ .../solapi/sdk/message/e2e/VoiceE2ETest.kt | 163 +++++++++ 10 files changed, 1622 insertions(+) create mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/BalanceE2ETest.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/CustomFieldsE2ETest.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/DuplicateHandlingE2ETest.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/KakaoAlimtalkE2ETest.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/KakaoFriendTalkE2ETest.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/LmsE2ETest.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/MessageListE2ETest.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/MmsE2ETest.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/SmsE2ETest.kt create mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/VoiceE2ETest.kt diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/BalanceE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/BalanceE2ETest.kt new file mode 100644 index 0000000..15f18f0 --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/BalanceE2ETest.kt @@ -0,0 +1,42 @@ +package com.solapi.sdk.message.e2e + +import com.solapi.sdk.message.e2e.base.BaseE2ETest +import kotlin.test.Test +import kotlin.test.assertNotNull + +/** + * 잔액 조회 E2E 테스트 + * + * 환경변수 설정 필요: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + */ +class BalanceE2ETest : BaseE2ETest() { + + @Test + fun `잔액 조회`() { + if (!assumeBasicEnvironmentConfigured()) return + + // When + val balance = messageService!!.getBalance() + + // Then + assertNotNull(balance) + println("잔액 조회 성공") + println(" balance: ${balance.balance}") + println(" point: ${balance.point}") + } + + @Test + fun `일일 발송량 한도 조회`() { + if (!assumeBasicEnvironmentConfigured()) return + + // When + val quota = messageService!!.getQuota() + + // Then + assertNotNull(quota) + println("일일 발송량 한도 조회 성공") + println(" quota: $quota") + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/CustomFieldsE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/CustomFieldsE2ETest.kt new file mode 100644 index 0000000..620e664 --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/CustomFieldsE2ETest.kt @@ -0,0 +1,132 @@ +package com.solapi.sdk.message.e2e + +import com.solapi.sdk.message.e2e.base.BaseE2ETest +import com.solapi.sdk.message.e2e.lib.E2ETestUtils +import kotlin.test.Test +import kotlin.test.assertNotNull + +/** + * Custom Fields E2E 테스트 + * + * Custom Fields는 메시지에 사용자 정의 데이터를 추가할 수 있는 기능입니다. + * 발송 후 메시지 조회 시에도 해당 필드가 포함됩니다. + * + * 환경변수 설정 필요: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 테스트 수신번호 + */ +class CustomFieldsE2ETest : BaseE2ETest() { + + @Test + fun `Custom Fields 포함 발송`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given + val customFields = mutableMapOf( + "orderId" to "ORD-12345", + "userId" to "USER-67890", + "category" to "notification" + ) + + val message = E2ETestUtils.createMessageWithCustomFields( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] Custom Fields 테스트입니다.", + customFields = customFields + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("Custom Fields 포함 발송 성공 - groupId: ${response.groupInfo?.groupId}") + println(" customFields: $customFields") + } + + @Test + fun `Custom Fields 다양한 값`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 특수문자, 유니코드 포함 + val customFields = mutableMapOf( + "key_with_underscore" to "value1", + "한글키" to "한글값", + "emoji" to "🚀🎉", + "special" to "!@#\$%^&*()", + "number" to "12345", + "empty" to "" + ) + + val message = E2ETestUtils.createMessageWithCustomFields( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] Custom Fields 다양한 값 테스트", + customFields = customFields + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("Custom Fields 다양한 값 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `Custom Fields 단일 필드`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 하나의 커스텀 필드만 사용 + val customFields = mutableMapOf( + "trackingId" to "TRK-${System.currentTimeMillis()}" + ) + + val message = E2ETestUtils.createMessageWithCustomFields( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] Custom Fields 단일 필드 테스트", + customFields = customFields + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("Custom Fields 단일 필드 발송 성공 - groupId: ${response.groupInfo?.groupId}") + println(" trackingId: ${customFields["trackingId"]}") + } + + @Test + fun `Custom Fields 긴 값`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 긴 문자열 값 + val longValue = "A".repeat(200) + val customFields = mutableMapOf( + "longField" to longValue + ) + + val message = E2ETestUtils.createMessageWithCustomFields( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] Custom Fields 긴 값 테스트", + customFields = customFields + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("Custom Fields 긴 값 발송 성공 - groupId: ${response.groupInfo?.groupId}") + println(" longField 길이: ${longValue.length}") + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/DuplicateHandlingE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/DuplicateHandlingE2ETest.kt new file mode 100644 index 0000000..107be87 --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/DuplicateHandlingE2ETest.kt @@ -0,0 +1,192 @@ +package com.solapi.sdk.message.e2e + +import com.solapi.sdk.message.dto.request.SendRequestConfig +import com.solapi.sdk.message.e2e.base.BaseE2ETest +import com.solapi.sdk.message.e2e.lib.E2ETestUtils +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * 중복 수신번호 처리 E2E 테스트 + * + * SendRequestConfig의 allowDuplicates 옵션을 통해 동일 수신번호로 + * 여러 메시지 발송 시 중복 허용 여부를 제어할 수 있습니다. + * + * 환경변수 설정 필요: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 테스트 수신번호 + */ +class DuplicateHandlingE2ETest : BaseE2ETest() { + + @Test + fun `중복 수신번호 허용`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 동일 수신번호로 여러 메시지 생성 + val messages = listOf( + E2ETestUtils.createSmsMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 중복 허용 테스트 1/3" + ), + E2ETestUtils.createSmsMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 중복 허용 테스트 2/3" + ), + E2ETestUtils.createSmsMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 중복 허용 테스트 3/3" + ) + ) + + val config = SendRequestConfig( + allowDuplicates = true + ) + + // When + val response = messageService!!.send(messages, config) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("중복 수신번호 허용 발송 성공 - groupId: ${response.groupInfo?.groupId}") + println(" 발송 요청: ${messages.size}건, 접수: ${response.groupInfo?.count?.total ?: 0}건") + } + + @Test + fun `중복 수신번호 비허용`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 동일 수신번호로 여러 메시지 생성 (중복 비허용) + val messages = listOf( + E2ETestUtils.createSmsMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 중복 비허용 테스트 1/3" + ), + E2ETestUtils.createSmsMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 중복 비허용 테스트 2/3" + ), + E2ETestUtils.createSmsMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 중복 비허용 테스트 3/3" + ) + ) + + val config = SendRequestConfig( + allowDuplicates = false + ) + + // When + val response = messageService!!.send(messages, config) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("중복 수신번호 비허용 발송 - groupId: ${response.groupInfo?.groupId}") + println(" 발송 요청: ${messages.size}건, 접수: ${response.groupInfo?.count?.total ?: 0}건") + // 중복 비허용 시 동일 수신번호는 1건만 접수됨 + } + + @Test + fun `showMessageList 옵션`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - showMessageList = true로 메시지 목록 포함 + val message = E2ETestUtils.createSmsMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] showMessageList 옵션 테스트" + ) + + val config = SendRequestConfig( + showMessageList = true + ) + + // When + val response = messageService!!.send(message, config) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("showMessageList 옵션 발송 성공 - groupId: ${response.groupInfo?.groupId}") + println(" messageList 포함 여부: ${response.messageList.isNotEmpty()}") + response.messageList.forEach { msg -> + println(" - messageId: ${msg.messageId}") + } + } + + @Test + fun `showMessageList false 옵션`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - showMessageList = false (기본값) + val message = E2ETestUtils.createSmsMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] showMessageList false 테스트" + ) + + val config = SendRequestConfig( + showMessageList = false + ) + + // When + val response = messageService!!.send(message, config) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("showMessageList false 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `중복 허용과 showMessageList 조합`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 두 옵션 모두 활성화 + val messages = listOf( + E2ETestUtils.createSmsMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 조합 테스트 1/2" + ), + E2ETestUtils.createSmsMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 조합 테스트 2/2" + ) + ) + + val config = SendRequestConfig( + allowDuplicates = true, + showMessageList = true + ) + + // When + val response = messageService!!.send(messages, config) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("중복 허용 + showMessageList 발송 성공 - groupId: ${response.groupInfo?.groupId}") + println(" 발송 요청: ${messages.size}건") + println(" messageList 건수: ${response.messageList.size}") + + // 중복 허용 시 요청한 메시지 수만큼 messageList에 포함되어야 함 + if (response.messageList.isNotEmpty()) { + assertTrue( + response.messageList.size == messages.size, + "중복 허용 시 요청 메시지 수와 응답 메시지 수가 동일해야 함" + ) + } + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoAlimtalkE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoAlimtalkE2ETest.kt new file mode 100644 index 0000000..784ae14 --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoAlimtalkE2ETest.kt @@ -0,0 +1,153 @@ +package com.solapi.sdk.message.e2e + +import com.solapi.sdk.message.e2e.base.BaseE2ETest +import com.solapi.sdk.message.e2e.lib.E2ETestUtils +import com.solapi.sdk.message.model.Message +import com.solapi.sdk.message.model.MessageType +import com.solapi.sdk.message.model.kakao.KakaoOption +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * 카카오 알림톡 (ATA) E2E 테스트 + * + * 알림톡은 카카오톡으로 발송되는 정보성 메시지입니다. + * 사전에 등록된 템플릿을 사용해야 합니다. + * + * 환경변수 설정 필요: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 테스트 수신번호 + * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID + * - SOLAPI_KAKAO_TEMPLATE_ID: 카카오 알림톡 템플릿 ID + */ +class KakaoAlimtalkE2ETest : BaseE2ETest() { + + @Test + fun `알림톡 발송 - 기본 템플릿`() { + if (!assumeKakaoTemplateConfigured()) return + + // Given + val message = E2ETestUtils.createAlimtalkMessage( + from = senderNumber, + to = testPhoneNumber, + pfId = pfId!!, + templateId = templateId!! + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("알림톡 기본 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `알림톡 발송 - 변수 치환`() { + if (!assumeKakaoTemplateConfigured()) return + + // Given - 템플릿에 맞는 변수 설정 (실제 템플릿에 따라 변수명 조정 필요) + val variables = mapOf( + "name" to "테스트 사용자", + "code" to "123456" + ) + + val message = E2ETestUtils.createAlimtalkMessage( + from = senderNumber, + to = testPhoneNumber, + pfId = pfId!!, + templateId = templateId!!, + variables = variables + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("알림톡 변수 치환 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `알림톡 발송 - 잘못된 템플릿 ID`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given - 존재하지 않는 템플릿 ID + val message = Message( + type = MessageType.ATA, + from = senderNumber, + to = testPhoneNumber, + kakaoOptions = KakaoOption( + pfId = pfId, + templateId = "invalid-template-id-12345" + ) + ) + + // When & Then + var errorOccurred = false + try { + messageService!!.send(message) + } catch (e: Exception) { + errorOccurred = true + printExceptionDetails(e) + } + + assertTrue(errorOccurred, "잘못된 템플릿 ID로 알림톡 발송 시 에러가 발생해야 함") + } + + @Test + fun `알림톡 대체 발송 비활성화`() { + if (!assumeKakaoTemplateConfigured()) return + + // Given - disableSms = true + val message = E2ETestUtils.createAlimtalkMessageWithoutFallback( + from = senderNumber, + to = testPhoneNumber, + pfId = pfId!!, + templateId = templateId!! + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("알림톡 대체 발송 비활성화 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `알림톡 발송 - 변수 자동 포맷팅`() { + if (!assumeKakaoTemplateConfigured()) return + + // Given - #{} 없이 변수 키 지정 (KakaoOption에서 자동 포맷팅) + val variables = mapOf( + "name" to "자동포맷테스트", + "#{code}" to "999999" // 이미 포맷된 키도 허용 + ) + + val message = Message( + type = MessageType.ATA, + from = senderNumber, + to = testPhoneNumber, + kakaoOptions = KakaoOption( + pfId = pfId, + templateId = templateId, + variables = variables + ) + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("알림톡 변수 자동 포맷팅 성공 - groupId: ${response.groupInfo?.groupId}") + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoFriendTalkE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoFriendTalkE2ETest.kt new file mode 100644 index 0000000..24914b9 --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoFriendTalkE2ETest.kt @@ -0,0 +1,313 @@ +package com.solapi.sdk.message.e2e + +import com.solapi.sdk.message.e2e.base.BaseE2ETest +import com.solapi.sdk.message.e2e.lib.E2ETestUtils +import com.solapi.sdk.message.model.StorageType +import java.io.File +import kotlin.test.Test +import kotlin.test.assertNotNull + +/** + * 카카오 친구톡 (CTA/CTI) E2E 테스트 + * + * 친구톡은 카카오톡 채널 친구에게 발송하는 광고성 메시지입니다. + * CTA: 텍스트 친구톡 + * CTI: 이미지 친구톡 + * + * 환경변수 설정 필요: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 테스트 수신번호 + * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID + */ +class KakaoFriendTalkE2ETest : BaseE2ETest() { + + /** + * 카카오용 이미지 업로드 + */ + private fun uploadKakaoImage(filename: String = "test-image.png"): String? { + val imageUrl = javaClass.classLoader.getResource("images/$filename") + if (imageUrl == null) { + println("테스트 이미지가 없어 건너뜁니다: images/$filename") + return null + } + val file = File(imageUrl.toURI()) + return messageService?.uploadFile(file, StorageType.KAKAO) + } + + // ==================== CTA (텍스트 친구톡) 테스트 ==================== + + @Test + fun `친구톡 발송 - 텍스트만`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val message = E2ETestUtils.createFriendTalkMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 친구톡 텍스트 메시지입니다.", + pfId = pfId!! + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 텍스트 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 발송 - 웹링크 버튼`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val buttons = listOf( + E2ETestUtils.createWebLinkButton( + buttonName = "바로가기", + linkMo = "https://example.com" + ) + ) + + val message = E2ETestUtils.createFriendTalkMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 웹링크 버튼 포함 친구톡입니다.", + pfId = pfId!!, + buttons = buttons + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 웹링크 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 발송 - 앱링크 버튼`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val buttons = listOf( + E2ETestUtils.createAppLinkButton( + buttonName = "앱 열기", + linkAnd = "intent://main#Intent;scheme=example;package=com.example;end", + linkIos = "exampleapp://main" + ) + ) + + val message = E2ETestUtils.createFriendTalkMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 앱링크 버튼 포함 친구톡입니다.", + pfId = pfId!!, + buttons = buttons + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 앱링크 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 발송 - 봇키워드 버튼`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val buttons = listOf( + E2ETestUtils.createBotKeywordButton(buttonName = "문의하기") + ) + + val message = E2ETestUtils.createFriendTalkMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 봇키워드 버튼 포함 친구톡입니다.", + pfId = pfId!!, + buttons = buttons + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 봇키워드 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 발송 - 메시지전달 버튼`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val buttons = listOf( + E2ETestUtils.createMessageDeliveryButton(buttonName = "전달하기") + ) + + val message = E2ETestUtils.createFriendTalkMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 메시지전달 버튼 포함 친구톡입니다.", + pfId = pfId!!, + buttons = buttons + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 메시지전달 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 발송 - 광고 플래그`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given - adFlag = true + val message = E2ETestUtils.createFriendTalkMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 광고성 친구톡입니다.", + pfId = pfId!!, + adFlag = true + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 광고 플래그 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 발송 - 다중 버튼`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given - 여러 버튼 조합 + val buttons = listOf( + E2ETestUtils.createWebLinkButton("홈페이지", "https://example.com"), + E2ETestUtils.createBotKeywordButton("상담 요청") + ) + + val message = E2ETestUtils.createFriendTalkMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 다중 버튼 친구톡입니다.", + pfId = pfId!!, + buttons = buttons + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 다중 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + // ==================== CTI (이미지 친구톡) 테스트 ==================== + + @Test + fun `친구톡 이미지 발송`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val imageId = uploadKakaoImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val message = E2ETestUtils.createFriendTalkImageMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 이미지 친구톡입니다.", + pfId = pfId!!, + imageId = imageId + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 이미지 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 이미지 발송 - 버튼 포함`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val imageId = uploadKakaoImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val buttons = listOf( + E2ETestUtils.createWebLinkButton("자세히 보기", "https://example.com") + ) + + val message = E2ETestUtils.createFriendTalkImageMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 버튼 포함 이미지 친구톡입니다.", + pfId = pfId!!, + imageId = imageId, + buttons = buttons + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 이미지+버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 이미지 발송 - 광고 플래그`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val imageId = uploadKakaoImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val message = E2ETestUtils.createFriendTalkImageMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 광고성 이미지 친구톡입니다.", + pfId = pfId!!, + imageId = imageId, + adFlag = true + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 이미지 광고 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/LmsE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/LmsE2ETest.kt new file mode 100644 index 0000000..58881cf --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/LmsE2ETest.kt @@ -0,0 +1,114 @@ +package com.solapi.sdk.message.e2e + +import com.solapi.sdk.message.e2e.base.BaseE2ETest +import com.solapi.sdk.message.e2e.lib.E2ETestUtils +import com.solapi.sdk.message.model.Message +import com.solapi.sdk.message.model.MessageType +import kotlin.test.Test +import kotlin.test.assertNotNull + +/** + * LMS 발송 E2E 테스트 + * + * LMS는 80바이트 이상 2000바이트 미만의 장문 메시지입니다. + * + * 환경변수 설정 필요: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 테스트 수신번호 + */ +class LmsE2ETest : BaseE2ETest() { + + @Test + fun `LMS 단건 발송 - 명시적 타입 지정`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - MessageType.LMS 명시적 지정 + val message = E2ETestUtils.createLmsMessage( + from = senderNumber, + to = testPhoneNumber, + text = E2ETestUtils.generateLongText(100), + subject = "LMS 제목" + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("LMS 단건 발송 (명시적 타입) 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `LMS 자동 감지 - autoTypeDetect`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 타입 지정 없이 80바이트 초과 메시지 (자동으로 LMS 변환) + val longText = E2ETestUtils.generateLongText(100) + val message = Message( + from = senderNumber, + to = testPhoneNumber, + text = longText, + autoTypeDetect = true + ) + + println("메시지 바이트 길이: ${longText.toByteArray(Charsets.UTF_8).size}") + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("LMS 자동 감지 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `LMS 최대 길이 테스트`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 약 2000바이트에 가까운 긴 메시지 + val maxLengthText = E2ETestUtils.generateMaxLengthLmsText() + val message = Message( + type = MessageType.LMS, + from = senderNumber, + to = testPhoneNumber, + text = maxLengthText, + subject = "LMS 최대 길이 테스트" + ) + + println("메시지 바이트 길이: ${maxLengthText.toByteArray(Charsets.UTF_8).size}") + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("LMS 최대 길이 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `LMS 발송 - 제목 포함`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given + val message = Message( + type = MessageType.LMS, + from = senderNumber, + to = testPhoneNumber, + text = E2ETestUtils.generateLongText(150), + subject = "[SDK 테스트] LMS 제목입니다" + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("LMS 제목 포함 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/MessageListE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/MessageListE2ETest.kt new file mode 100644 index 0000000..da99ede --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/MessageListE2ETest.kt @@ -0,0 +1,220 @@ +package com.solapi.sdk.message.e2e + +import com.solapi.sdk.message.dto.request.MessageListRequest +import com.solapi.sdk.message.e2e.base.BaseE2ETest +import com.solapi.sdk.message.model.MessageStatusType +import java.time.LocalDateTime +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * 메시지 목록 조회 E2E 테스트 + * + * 환경변수 설정 필요: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 테스트 수신번호 + */ +class MessageListE2ETest : BaseE2ETest() { + + @Test + fun `메시지 목록 조회 - 기본`() { + if (!assumeBasicEnvironmentConfigured()) return + + // When - 파라미터 없이 조회 + val response = messageService!!.getMessageList() + + // Then + assertNotNull(response) + println("메시지 목록 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}") + } + + @Test + fun `메시지 목록 조회 - 발신번호 필터`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given + val request = MessageListRequest( + from = senderNumber + ) + + // When + val response = messageService!!.getMessageList(request) + + // Then + assertNotNull(response) + println("발신번호 필터 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}") + } + + @Test + fun `메시지 목록 조회 - 수신번호 필터`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given + val request = MessageListRequest( + to = testPhoneNumber + ) + + // When + val response = messageService!!.getMessageList(request) + + // Then + assertNotNull(response) + println("수신번호 필터 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}") + } + + @Test + fun `메시지 목록 조회 - 메시지 타입 필터`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - SMS 타입만 조회 + val request = MessageListRequest( + type = "SMS" + ) + + // When + val response = messageService!!.getMessageList(request) + + // Then + assertNotNull(response) + println("메시지 타입 필터 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}") + } + + @Test + fun `메시지 목록 조회 - 상태 필터 (완료)`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 발송 완료 메시지만 조회 + val request = MessageListRequest( + status = MessageStatusType.COMPLETE + ) + + // When + val response = messageService!!.getMessageList(request) + + // Then + assertNotNull(response) + println("상태 필터 (완료) 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}") + } + + @Test + fun `메시지 목록 조회 - 상태 필터 (대기)`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 발송 대기 메시지만 조회 + val request = MessageListRequest( + status = MessageStatusType.PENDING + ) + + // When + val response = messageService!!.getMessageList(request) + + // Then + assertNotNull(response) + println("상태 필터 (대기) 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}") + } + + @Test + fun `메시지 목록 조회 - 날짜 범위 필터`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 최근 7일간 메시지 조회 + val request = MessageListRequest() + request.setStartDateFromLocalDateTime(LocalDateTime.now().minusDays(7)) + request.setEndDateFromLocalDateTime(LocalDateTime.now()) + + // When + val response = messageService!!.getMessageList(request) + + // Then + assertNotNull(response) + println("날짜 범위 필터 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}") + } + + @Test + fun `메시지 목록 조회 - 페이지네이션`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 첫 페이지 (limit = 5) + val firstPageRequest = MessageListRequest( + limit = 5 + ) + + // When - 첫 페이지 조회 + val firstPageResponse = messageService!!.getMessageList(firstPageRequest) + + // Then + assertNotNull(firstPageResponse) + println("첫 페이지 조회 성공 - 조회 건수: ${firstPageResponse.messageList?.size ?: 0}") + + // Given - 다음 페이지 (startKey 사용) + // messageList는 Map이므로 마지막 키를 가져옴 + val lastMessageId = firstPageResponse.messageList?.keys?.lastOrNull() + if (lastMessageId != null) { + val secondPageRequest = MessageListRequest( + limit = 5, + startKey = lastMessageId + ) + + // When - 두 번째 페이지 조회 + val secondPageResponse = messageService!!.getMessageList(secondPageRequest) + + // Then + assertNotNull(secondPageResponse) + println("두 번째 페이지 조회 성공 - 조회 건수: ${secondPageResponse.messageList?.size ?: 0}") + } + } + + @Test + fun `메시지 목록 조회 - 특정 messageIds`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 먼저 메시지 목록을 조회하여 messageId 획득 + // messageList는 Map이므로 keys가 messageId + val initialResponse = messageService!!.getMessageList(MessageListRequest(limit = 3)) + val messageIds = initialResponse?.messageList?.keys?.toList() + + if (messageIds.isNullOrEmpty()) { + println("조회할 메시지가 없어 테스트를 건너뜁니다.") + return + } + + // Given - 특정 messageIds로 조회 + val request = MessageListRequest( + messageIds = messageIds + ) + + // When + val response = messageService!!.getMessageList(request) + + // Then + assertNotNull(response) + assertTrue( + (response.messageList?.size ?: 0) <= messageIds.size, + "조회된 메시지 수는 요청한 messageIds 수 이하여야 함" + ) + println("특정 messageIds 조회 성공 - 요청: ${messageIds.size}건, 조회: ${response.messageList?.size ?: 0}건") + } + + @Test + fun `메시지 목록 조회 - 복합 필터`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 발신번호 + 타입 + 날짜 범위 조합 + val request = MessageListRequest( + from = senderNumber, + type = "SMS", + limit = 10 + ) + request.setStartDateFromLocalDateTime(LocalDateTime.now().minusDays(30)) + + // When + val response = messageService!!.getMessageList(request) + + // Then + assertNotNull(response) + println("복합 필터 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}") + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/MmsE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/MmsE2ETest.kt new file mode 100644 index 0000000..c2f714b --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/MmsE2ETest.kt @@ -0,0 +1,161 @@ +package com.solapi.sdk.message.e2e + +import com.solapi.sdk.message.e2e.base.BaseE2ETest +import com.solapi.sdk.message.e2e.lib.E2ETestUtils +import com.solapi.sdk.message.exception.SolapiFileUploadException +import com.solapi.sdk.message.model.StorageType +import java.io.File +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * MMS 발송 E2E 테스트 + * + * MMS는 이미지가 포함된 문자 메시지입니다. + * 이미지는 먼저 SOLAPI 서버에 업로드해야 합니다. + * + * 환경변수 설정 필요: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 테스트 수신번호 + * + * 테스트 리소스 필요: + * - src/test/resources/images/test-image.png (MMS 규격에 맞는 이미지) + * + * MMS 이미지 규격: + * - 지원 포맷: JPG, JPEG + * - 최대 용량: 200KB + * - 권장 해상도: 1000x1000 이하 + */ +class MmsE2ETest : BaseE2ETest() { + + /** + * MMS용 이미지 업로드 + * MMS는 특정 이미지 규격(JPG, 200KB 이하)을 요구할 수 있습니다. + * 업로드 실패 시 null을 반환합니다. + */ + private fun uploadMmsImage(filename: String = "test-image.png"): String? { + val imageUrl = javaClass.classLoader.getResource("images/$filename") + if (imageUrl == null) { + println("테스트 이미지가 없어 건너뜁니다: images/$filename") + return null + } + val file = File(imageUrl.toURI()) + return try { + messageService?.uploadFile(file, StorageType.MMS) + } catch (e: SolapiFileUploadException) { + println("MMS 이미지 업로드 실패 (서버 응답): ${e.message}") + println(" MMS 이미지 규격을 확인하세요 (JPG 포맷, 200KB 이하)") + null + } + } + + @Test + fun `MMS 이미지 업로드`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given + val imageUrl = javaClass.classLoader.getResource("images/test-image.png") + if (imageUrl == null) { + println("테스트 이미지가 없어 건너뜁니다: images/test-image.png") + return + } + val file = File(imageUrl.toURI()) + + // When + val imageId = try { + messageService!!.uploadFile(file, StorageType.MMS) + } catch (e: SolapiFileUploadException) { + println("MMS 이미지 업로드 실패 (서버 응답): ${e.message}") + println(" MMS 이미지 규격을 확인하세요 (JPG 포맷, 200KB 이하)") + println(" 테스트를 건너뜁니다.") + return + } + + // Then + assertNotNull(imageId) + println("MMS 이미지 업로드 성공 - imageId: $imageId") + } + + @Test + fun `MMS 단건 발송`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 이미지 업로드 + val imageId = uploadMmsImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val message = E2ETestUtils.createMmsMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] MMS 메시지입니다.", + imageId = imageId, + subject = "MMS 제목" + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("MMS 단건 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `MMS 발송 - 유효하지 않은 imageId`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 존재하지 않는 imageId + val message = E2ETestUtils.createMmsMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 잘못된 imageId 테스트", + imageId = "invalid-image-id-12345" + ) + + // When & Then + var errorOccurred = false + try { + messageService!!.send(message) + } catch (e: Exception) { + errorOccurred = true + printExceptionDetails(e) + } + + assertTrue(errorOccurred, "유효하지 않은 imageId로 MMS 발송 시 에러가 발생해야 함") + } + + @Test + fun `MMS 발송 - 제목과 본문 포함`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given + val imageId = uploadMmsImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val message = E2ETestUtils.createMmsMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] MMS 본문입니다. 이미지와 함께 발송됩니다.", + imageId = imageId, + subject = "[SDK 테스트] MMS 제목" + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("MMS 제목/본문 포함 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/SmsE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/SmsE2ETest.kt new file mode 100644 index 0000000..1a7d661 --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/SmsE2ETest.kt @@ -0,0 +1,132 @@ +package com.solapi.sdk.message.e2e + +import com.solapi.sdk.message.e2e.base.BaseE2ETest +import com.solapi.sdk.message.e2e.lib.E2ETestUtils +import com.solapi.sdk.message.model.Message +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * SMS 발송 E2E 테스트 + * + * 환경변수 설정 필요: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 테스트 수신번호 + */ +class SmsE2ETest : BaseE2ETest() { + + @Test + fun `SMS 단건 발송 - 기본`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given + val message = E2ETestUtils.createSmsMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] SMS 단건 발송 테스트입니다." + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("SMS 단건 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `SMS 단건 발송 - 대시 포함 전화번호`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 대시가 포함된 전화번호 (Message init에서 자동 제거) + val message = Message( + from = senderNumber.chunked(3).joinToString("-").let { + if (senderNumber.length == 11) "${senderNumber.substring(0, 3)}-${senderNumber.substring(3, 7)}-${senderNumber.substring(7)}" + else senderNumber + }, + to = testPhoneNumber.let { + if (it.length == 11) "${it.substring(0, 3)}-${it.substring(3, 7)}-${it.substring(7)}" + else it + }, + text = "[SDK 테스트] 대시 포함 전화번호 테스트입니다." + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("SMS 발송 (대시 포함 전화번호) 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `SMS 배치 발송 - 다중 수신자`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 동일 수신자에게 여러 메시지 (테스트 환경에서는 동일 번호 사용) + val messages = E2ETestUtils.createBatchSmsMessages( + from = senderNumber, + toList = listOf(testPhoneNumber, testPhoneNumber), + textPrefix = "[SDK 테스트] 배치 SMS" + ) + + // When + val response = messageService!!.send(messages) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("SMS 배치 발송 성공 - groupId: ${response.groupInfo?.groupId}, count: ${response.groupInfo?.count}") + } + + @Test + fun `SMS 발송 - 잘못된 발신번호`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 등록되지 않은 발신번호 + val message = E2ETestUtils.createSmsMessage( + from = "00000000000", + to = testPhoneNumber, + text = "[SDK 테스트] 잘못된 발신번호 테스트" + ) + + // When & Then + var errorOccurred = false + try { + messageService!!.send(message) + } catch (e: Exception) { + errorOccurred = true + printExceptionDetails(e) + } + + assertTrue(errorOccurred, "등록되지 않은 발신번호로 발송 시 에러가 발생해야 함") + } + + @Test + fun `SMS 발송 - 빈 메시지`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 빈 텍스트 + val message = Message( + from = senderNumber, + to = testPhoneNumber, + text = "" + ) + + // When & Then + var errorOccurred = false + try { + messageService!!.send(message) + } catch (e: Exception) { + errorOccurred = true + printExceptionDetails(e) + } + + assertTrue(errorOccurred, "빈 메시지 발송 시 에러가 발생해야 함") + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/VoiceE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/VoiceE2ETest.kt new file mode 100644 index 0000000..72eb23e --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/VoiceE2ETest.kt @@ -0,0 +1,163 @@ +package com.solapi.sdk.message.e2e + +import com.solapi.sdk.message.e2e.base.BaseE2ETest +import com.solapi.sdk.message.e2e.lib.E2ETestUtils +import com.solapi.sdk.message.model.Message +import com.solapi.sdk.message.model.MessageType +import com.solapi.sdk.message.model.voice.VoiceOption +import com.solapi.sdk.message.model.voice.VoiceType +import kotlin.test.Test +import kotlin.test.assertNotNull + +/** + * 음성 메시지 (Voice) E2E 테스트 + * + * 음성 메시지는 TTS(Text-to-Speech)를 통해 텍스트를 음성으로 변환하여 발송합니다. + * + * 환경변수 설정 필요: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 테스트 수신번호 + */ +class VoiceE2ETest : BaseE2ETest() { + + @Test + fun `음성 메시지 발송 - 여성 음성`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given + val message = E2ETestUtils.createVoiceMessage( + from = senderNumber, + to = testPhoneNumber, + text = "안녕하세요. 테스트 음성 메시지입니다. 여성 음성으로 발송됩니다.", + voiceType = VoiceType.FEMALE + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("음성 메시지 여성 음성 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `음성 메시지 발송 - 남성 음성`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given + val message = E2ETestUtils.createVoiceMessage( + from = senderNumber, + to = testPhoneNumber, + text = "안녕하세요. 테스트 음성 메시지입니다. 남성 음성으로 발송됩니다.", + voiceType = VoiceType.MALE + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("음성 메시지 남성 음성 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `음성 메시지 발송 - 헤더 메시지`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 헤더 메시지 포함 + val message = E2ETestUtils.createVoiceMessage( + from = senderNumber, + to = testPhoneNumber, + text = "본문 메시지입니다.", + voiceType = VoiceType.FEMALE, + headerMessage = "안녕하세요. 솔라피 테스트입니다." + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("음성 메시지 헤더 포함 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `음성 메시지 발송 - 테일 메시지`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 테일 메시지 포함 + // Note: tailMessage는 headerMessage와 함께 사용해야 할 수 있음 + val message = Message( + type = MessageType.VOICE, + from = senderNumber, + to = testPhoneNumber, + text = "본문 메시지입니다.", + voiceOptions = VoiceOption( + voiceType = VoiceType.FEMALE, + headerMessage = "안녕하세요.", + tailMessage = "감사합니다. 좋은 하루 되세요." + ) + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("음성 메시지 테일 포함 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `음성 메시지 발송 - 전체 옵션`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - 모든 옵션 조합 + val message = Message( + type = MessageType.VOICE, + from = senderNumber, + to = testPhoneNumber, + text = "중요한 공지사항을 안내드립니다.", + voiceOptions = VoiceOption( + voiceType = VoiceType.FEMALE, + headerMessage = "안녕하세요. 솔라피 테스트입니다.", + tailMessage = "다시 한번 안내드립니다." + ) + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("음성 메시지 전체 옵션 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `음성 메시지 발송 - 기본값 (여성 음성)`() { + if (!assumeBasicEnvironmentConfigured()) return + + // Given - VoiceOption 기본값 사용 (FEMALE) + val message = Message( + type = MessageType.VOICE, + from = senderNumber, + to = testPhoneNumber, + text = "기본값 테스트 음성 메시지입니다.", + voiceOptions = VoiceOption() + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("음성 메시지 기본값 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } +} From b20f05ccc8d22294c0f800fd603a5bccb1e6653b Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 29 Jan 2026 11:16:50 +0900 Subject: [PATCH 22/43] refactor: use stdlib toKotlinInstant extension for LocalDateTime conversion Replace manual ISO-8601 string parsing with kotlin.time.toKotlinInstant() extension function for cleaner, more idiomatic Kotlin code. - Add kotlin.time.toKotlinInstant import - Remove unnecessary ZonedDateTime import - Simplify toKotlinInstant() implementation to single expression - Remove outdated KDoc comment about string conversion This change addresses the review comment from PR #4 by using the stdlib extension function which provides direct conversion without string serialization overhead while preserving nanosecond precision. Co-Authored-By: Claude Opus 4.5 --- .../solapi/sdk/message/lib/LocalDateTimeSupport.kt | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/solapi/sdk/message/lib/LocalDateTimeSupport.kt b/src/main/java/com/solapi/sdk/message/lib/LocalDateTimeSupport.kt index 260c9f0..abe42f7 100644 --- a/src/main/java/com/solapi/sdk/message/lib/LocalDateTimeSupport.kt +++ b/src/main/java/com/solapi/sdk/message/lib/LocalDateTimeSupport.kt @@ -1,22 +1,20 @@ package com.solapi.sdk.message.lib import kotlin.time.Instant +import kotlin.time.toKotlinInstant import java.time.LocalDateTime import java.time.ZoneId -import java.time.ZonedDateTime /** * LocalDateTime을 kotlin.time.Instant로 변환하는 유틸리티. - * + * * LocalDateTime은 시간대 정보가 없으므로, 변환 시 시간대를 지정해야 합니다. * 기본값은 시스템 기본 시간대(ZoneId.systemDefault())입니다. - * - * 나노초 정밀도를 유지하기 위해 ISO-8601 문자열 변환을 사용합니다. */ object LocalDateTimeSupport { /** * LocalDateTime을 kotlin.time.Instant로 변환합니다. - * + * * @param localDateTime 변환할 LocalDateTime * @param zoneId 적용할 시간대 (기본값: 시스템 기본 시간대) * @return 변환된 kotlin.time.Instant @@ -24,10 +22,7 @@ object LocalDateTimeSupport { @JvmStatic @JvmOverloads fun toKotlinInstant(localDateTime: LocalDateTime, zoneId: ZoneId = ZoneId.systemDefault()): Instant { - val zonedDateTime: ZonedDateTime = localDateTime.atZone(zoneId) - val javaInstant: java.time.Instant = zonedDateTime.toInstant() - // Use ISO-8601 string conversion to preserve nanosecond precision - return Instant.parse(javaInstant.toString()) + return localDateTime.atZone(zoneId).toInstant().toKotlinInstant() } } From 510b0add5f764ebd2e51230d3429b79b8b36b5e2 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 29 Jan 2026 11:27:59 +0900 Subject: [PATCH 23/43] refactor: extract uploadImage utility to BaseE2ETest Move common image upload logic to BaseE2ETest to reduce duplication across E2E test classes. Co-Authored-By: Claude Opus 4.5 --- .../solapi/sdk/message/e2e/base/BaseE2ETest.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/base/BaseE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/base/BaseE2ETest.kt index 5fe523d..309172c 100644 --- a/src/test/kotlin/com/solapi/sdk/message/e2e/base/BaseE2ETest.kt +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/base/BaseE2ETest.kt @@ -2,7 +2,9 @@ package com.solapi.sdk.message.e2e.base import com.solapi.sdk.SolapiClient import com.solapi.sdk.message.exception.SolapiMessageNotReceivedException +import com.solapi.sdk.message.model.StorageType import com.solapi.sdk.message.service.DefaultMessageService +import java.io.File /** * E2E 테스트를 위한 공통 베이스 클래스 @@ -84,4 +86,20 @@ abstract class BaseE2ETest { } } } + + /** + * 이미지 파일 업로드 + * @param storageType 스토리지 타입 + * @param filename 리소스 파일명 (images/ 디렉토리 내) + * @return 업로드된 이미지 ID, 파일이 없으면 null + */ + protected fun uploadImage(storageType: StorageType, filename: String): String? { + val imageUrl = javaClass.classLoader.getResource("images/$filename") + if (imageUrl == null) { + println("테스트 이미지가 없어 건너뜁니다: images/$filename") + return null + } + val file = File(imageUrl.toURI()) + return messageService?.uploadFile(file, storageType) + } } From 1c8f41287c7b1234ab8f6fcd700644480b6c2f24 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 29 Jan 2026 11:29:51 +0900 Subject: [PATCH 24/43] refactor: make BmsFreeE2ETest extend BaseE2ETest - Remove duplicate properties (apiKey, apiSecret, senderNumber, etc.) - Remove duplicate messageService lazy property - Replace assumeEnvironmentConfigured with assumeKakaoEnvironmentConfigured - Remove duplicate printExceptionDetails method - Replace 7 image upload helper functions with single uploadImage utility Co-Authored-By: Claude Opus 4.5 --- .../solapi/sdk/message/e2e/BmsFreeE2ETest.kt | 226 ++++-------------- 1 file changed, 41 insertions(+), 185 deletions(-) diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt index ec5063a..a8c44bd 100644 --- a/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt @@ -1,18 +1,15 @@ package com.solapi.sdk.message.e2e -import com.solapi.sdk.SolapiClient -import com.solapi.sdk.message.exception.SolapiMessageNotReceivedException +import com.solapi.sdk.message.e2e.base.BaseE2ETest import com.solapi.sdk.message.lib.BmsTestUtils import com.solapi.sdk.message.model.Message import com.solapi.sdk.message.model.MessageType import com.solapi.sdk.message.model.StorageType import com.solapi.sdk.message.model.kakao.KakaoOption import com.solapi.sdk.message.model.kakao.bms.BmsCoupon -import com.solapi.sdk.message.service.DefaultMessageService import kotlin.test.Test import kotlin.test.assertNotNull import kotlin.test.assertTrue -import java.io.File /** * BMS Free 발송 E2E 테스트 @@ -25,138 +22,7 @@ import java.io.File * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID * - SOLAPI_KAKAO_TEMPLATE_ID: 카카오 알림톡 템플릿 ID (선택) */ -class BmsFreeE2ETest { - - private val apiKey: String? = System.getenv("SOLAPI_API_KEY") - private val apiSecret: String? = System.getenv("SOLAPI_API_SECRET") - private val pfId: String? = System.getenv("SOLAPI_KAKAO_PF_ID") - private val senderNumber: String = System.getenv("SOLAPI_SENDER") ?: "01000000000" - private val testPhoneNumber: String = System.getenv("SOLAPI_RECIPIENT") ?: "01000000000" - - private val messageService: DefaultMessageService? by lazy { - if (apiKey != null && apiSecret != null) { - SolapiClient.createInstance(apiKey, apiSecret) - } else { - null - } - } - - /** - * 환경변수 설정 여부 확인 - * @return 환경변수가 설정되었으면 true, 아니면 false - */ - private fun assumeEnvironmentConfigured(): Boolean { - if (apiKey.isNullOrBlank() || apiSecret.isNullOrBlank() || pfId.isNullOrBlank()) { - println("환경변수가 설정되지 않아 테스트를 건너뜁니다. (SOLAPI_API_KEY, SOLAPI_API_SECRET, SOLAPI_KAKAO_PF_ID 필요)") - return false - } - return true - } - - /** - * 테스트 이미지 업로드 (일반 - KAKAO 타입, PREMIUM_VIDEO 썸네일용) - * @param filename 리소스 파일명 - * @return 업로드된 이미지 ID - */ - private fun uploadTestImage(filename: String = "test-image.png"): String? { - val imageUrl = javaClass.classLoader.getResource("images/$filename") - if (imageUrl == null) { - println("테스트 이미지가 없어 건너뜁니다: images/$filename") - return null - } - val file = File(imageUrl.toURI()) - return messageService?.uploadFile(file, StorageType.KAKAO) - } - - /** - * BMS 타입 이미지 업로드 (IMAGE, COMMERCE용) - * @param filename 리소스 파일명 - * @return 업로드된 이미지 ID - */ - private fun uploadBmsImage(filename: String = "test-image.png"): String? { - val imageUrl = javaClass.classLoader.getResource("images/$filename") - if (imageUrl == null) { - println("테스트 이미지가 없어 건너뜁니다: images/$filename") - return null - } - val file = File(imageUrl.toURI()) - return messageService?.uploadFile(file, StorageType.BMS) - } - - /** - * BMS WIDE 타입 이미지 업로드 (WIDE용 - 2:1 비율) - * @param filename 리소스 파일명 - * @return 업로드된 이미지 ID - */ - private fun uploadBmsWideImage(filename: String = "test-image-2to1.png"): String? { - val imageUrl = javaClass.classLoader.getResource("images/$filename") - if (imageUrl == null) { - println("테스트 이미지가 없어 건너뜁니다: images/$filename") - return null - } - val file = File(imageUrl.toURI()) - return messageService?.uploadFile(file, StorageType.BMS_WIDE) - } - - /** - * BMS WIDE_ITEM_LIST 메인 이미지 업로드 (2:1 비율) - * @param filename 리소스 파일명 - * @return 업로드된 이미지 ID - */ - private fun uploadBmsWideMainItemImage(filename: String = "test-image-2to1.png"): String? { - val imageUrl = javaClass.classLoader.getResource("images/$filename") - if (imageUrl == null) { - println("테스트 이미지가 없어 건너뜁니다: images/$filename") - return null - } - val file = File(imageUrl.toURI()) - return messageService?.uploadFile(file, StorageType.BMS_WIDE_MAIN_ITEM_LIST) - } - - /** - * BMS WIDE_ITEM_LIST 서브 이미지 업로드 (1:1 비율) - * @param filename 리소스 파일명 - * @return 업로드된 이미지 ID - */ - private fun uploadBmsWideSubItemImage(filename: String = "test-image.png"): String? { - val imageUrl = javaClass.classLoader.getResource("images/$filename") - if (imageUrl == null) { - println("테스트 이미지가 없어 건너뜁니다: images/$filename") - return null - } - val file = File(imageUrl.toURI()) - return messageService?.uploadFile(file, StorageType.BMS_WIDE_SUB_ITEM_LIST) - } - - /** - * BMS CAROUSEL_FEED 이미지 업로드 (2:1 비율) - * @param filename 리소스 파일명 - * @return 업로드된 이미지 ID - */ - private fun uploadBmsCarouselFeedImage(filename: String = "test-image-2to1.png"): String? { - val imageUrl = javaClass.classLoader.getResource("images/$filename") - if (imageUrl == null) { - println("테스트 이미지가 없어 건너뜁니다: images/$filename") - return null - } - val file = File(imageUrl.toURI()) - return messageService?.uploadFile(file, StorageType.BMS_CAROUSEL_FEED_LIST) - } - - /** - * BMS CAROUSEL_COMMERCE 이미지 업로드 (2:1 비율) - * @param filename 리소스 파일명 - * @return 업로드된 이미지 ID - */ - private fun uploadBmsCarouselCommerceImage(filename: String = "test-image-2to1.png"): String? { - val imageUrl = javaClass.classLoader.getResource("images/$filename") - if (imageUrl == null) { - println("테스트 이미지가 없어 건너뜁니다: images/$filename") - return null - } - val file = File(imageUrl.toURI()) - return messageService?.uploadFile(file, StorageType.BMS_CAROUSEL_COMMERCE_LIST) - } +class BmsFreeE2ETest : BaseE2ETest() { private fun createBmsFreeMessage(kakaoOption: KakaoOption, text: String? = null): Message = Message( type = MessageType.BMS_FREE, @@ -166,21 +32,11 @@ class BmsFreeE2ETest { kakaoOptions = kakaoOption ) - private fun printExceptionDetails(e: Exception) { - println("예상된 에러 발생: ${e.message}") - if (e is SolapiMessageNotReceivedException) { - println(" 실패한 메시지 목록 (${e.failedMessageList.size}건):") - e.failedMessageList.forEachIndexed { index, failed -> - println(" [${index + 1}] to: ${failed.to}, statusCode: ${failed.statusCode}, statusMessage: ${failed.statusMessage}") - } - } - } - // ==================== TEXT 타입 테스트 ==================== @Test fun `TEXT 타입 - 최소 구조`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return val bmsOption = BmsTestUtils.createTextBmsOption( content = "BMS Free TEXT 최소 구조 테스트" @@ -201,7 +57,7 @@ class BmsFreeE2ETest { @Test fun `TEXT 타입 - 전체 필드`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return val buttons = listOf( BmsTestUtils.createWebLinkButton("바로가기", "https://example.com"), @@ -234,9 +90,9 @@ class BmsFreeE2ETest { @Test fun `IMAGE 타입 - 최소 구조`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val imageId = uploadBmsImage() + val imageId = uploadImage(StorageType.BMS, "test-image.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -262,9 +118,9 @@ class BmsFreeE2ETest { @Test fun `IMAGE 타입 - 전체 필드`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val imageId = uploadBmsImage() + val imageId = uploadImage(StorageType.BMS, "test-image.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -302,9 +158,9 @@ class BmsFreeE2ETest { @Test fun `WIDE 타입 - 최소 구조`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val imageId = uploadBmsWideImage() + val imageId = uploadImage(StorageType.BMS_WIDE, "test-image-2to1.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -330,9 +186,9 @@ class BmsFreeE2ETest { @Test fun `WIDE 타입 - 전체 필드`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val imageId = uploadBmsWideImage() + val imageId = uploadImage(StorageType.BMS_WIDE, "test-image-2to1.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -370,10 +226,10 @@ class BmsFreeE2ETest { @Test fun `WIDE_ITEM_LIST 타입 - 최소 구조`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val mainImageId = uploadBmsWideMainItemImage() - val subImageId = uploadBmsWideSubItemImage() + val mainImageId = uploadImage(StorageType.BMS_WIDE_MAIN_ITEM_LIST, "test-image-2to1.png") + val subImageId = uploadImage(StorageType.BMS_WIDE_SUB_ITEM_LIST, "test-image.png") if (mainImageId == null || subImageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -411,10 +267,10 @@ class BmsFreeE2ETest { @Test fun `WIDE_ITEM_LIST 타입 - 전체 필드`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val mainImageId = uploadBmsWideMainItemImage() - val subImageId = uploadBmsWideSubItemImage() + val mainImageId = uploadImage(StorageType.BMS_WIDE_MAIN_ITEM_LIST, "test-image-2to1.png") + val subImageId = uploadImage(StorageType.BMS_WIDE_SUB_ITEM_LIST, "test-image.png") if (mainImageId == null || subImageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -464,9 +320,9 @@ class BmsFreeE2ETest { @Test fun `COMMERCE 타입 - 최소 구조`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val imageId = uploadBmsImage("test-image-2to1.png") + val imageId = uploadImage(StorageType.BMS, "test-image-2to1.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -502,9 +358,9 @@ class BmsFreeE2ETest { @Test fun `COMMERCE 타입 - 전체 필드`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val imageId = uploadBmsImage("test-image-2to1.png") + val imageId = uploadImage(StorageType.BMS, "test-image-2to1.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -551,9 +407,9 @@ class BmsFreeE2ETest { @Test fun `CAROUSEL_FEED 타입 - 최소 구조`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val imageId = uploadBmsCarouselFeedImage() + val imageId = uploadImage(StorageType.BMS_CAROUSEL_FEED_LIST, "test-image-2to1.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -587,9 +443,9 @@ class BmsFreeE2ETest { @Test fun `CAROUSEL_FEED 타입 - 전체 필드`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val imageId = uploadBmsCarouselFeedImage() + val imageId = uploadImage(StorageType.BMS_CAROUSEL_FEED_LIST, "test-image-2to1.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -652,9 +508,9 @@ class BmsFreeE2ETest { @Test fun `CAROUSEL_COMMERCE 타입 - 최소 구조`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val imageId = uploadBmsCarouselCommerceImage() + val imageId = uploadImage(StorageType.BMS_CAROUSEL_COMMERCE_LIST, "test-image-2to1.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -696,9 +552,9 @@ class BmsFreeE2ETest { @Test fun `CAROUSEL_COMMERCE 타입 - 전체 필드`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val imageId = uploadBmsCarouselCommerceImage() + val imageId = uploadImage(StorageType.BMS_CAROUSEL_COMMERCE_LIST, "test-image-2to1.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -769,9 +625,9 @@ class BmsFreeE2ETest { @Test fun `PREMIUM_VIDEO 타입 - 최소 구조`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val imageId = uploadTestImage() + val imageId = uploadImage(StorageType.KAKAO, "test-image.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -802,9 +658,9 @@ class BmsFreeE2ETest { @Test fun `PREMIUM_VIDEO 타입 - 전체 필드`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val imageId = uploadTestImage() + val imageId = uploadImage(StorageType.KAKAO, "test-image.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -846,7 +702,7 @@ class BmsFreeE2ETest { @Test fun `IMAGE without imageId - 필수 필드 누락`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return val bmsOption = BmsTestUtils.createImageBmsOption( imageId = "", // 빈 이미지 ID @@ -873,9 +729,9 @@ class BmsFreeE2ETest { @Test fun `COMMERCE without buttons - 버튼 없이 발송 허용`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val imageId = uploadBmsImage("test-image-2to1.png") + val imageId = uploadImage(StorageType.BMS, "test-image-2to1.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -904,9 +760,9 @@ class BmsFreeE2ETest { @Test fun `PREMIUM_VIDEO with invalid videoUrl`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return - val imageId = uploadTestImage() + val imageId = uploadImage(StorageType.KAKAO, "test-image.png") if (imageId == null) { println("이미지 업로드 실패로 테스트 건너뜀") return @@ -942,7 +798,7 @@ class BmsFreeE2ETest { @Test fun `Invalid coupon title format`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return val invalidCoupon = BmsCoupon( title = "잘못된 쿠폰 제목", @@ -977,7 +833,7 @@ class BmsFreeE2ETest { @Test fun `CAROUSEL_FEED without carousel`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeKakaoEnvironmentConfigured()) return val bmsOption = BmsTestUtils.createCarouselFeedBmsOption( carouselItems = emptyList() // 빈 캐러셀 From c170e1e2ab294949524791249cce8b9928405cd8 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 29 Jan 2026 11:30:33 +0900 Subject: [PATCH 25/43] refactor: make ScheduledMessageE2ETest extend BaseE2ETest - Remove duplicate properties (apiKey, apiSecret, senderNumber, etc.) - Remove duplicate messageService lazy property - Replace assumeEnvironmentConfigured with assumeBasicEnvironmentConfigured - Remove duplicate printExceptionDetails method Co-Authored-By: Claude Opus 4.5 --- .../message/e2e/ScheduledMessageE2ETest.kt | 53 ++++--------------- 1 file changed, 10 insertions(+), 43 deletions(-) diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/ScheduledMessageE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/ScheduledMessageE2ETest.kt index 0b2754b..d9cc788 100644 --- a/src/test/kotlin/com/solapi/sdk/message/e2e/ScheduledMessageE2ETest.kt +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/ScheduledMessageE2ETest.kt @@ -1,10 +1,8 @@ package com.solapi.sdk.message.e2e -import com.solapi.sdk.SolapiClient import com.solapi.sdk.message.dto.request.SendRequestConfig -import com.solapi.sdk.message.exception.SolapiMessageNotReceivedException +import com.solapi.sdk.message.e2e.base.BaseE2ETest import com.solapi.sdk.message.model.Message -import com.solapi.sdk.message.service.DefaultMessageService import java.time.LocalDateTime import java.time.ZoneId import kotlin.test.Test @@ -21,44 +19,13 @@ import kotlin.time.Instant * - SOLAPI_SENDER: 등록된 발신번호 * - SOLAPI_RECIPIENT: 테스트 수신번호 */ -class ScheduledMessageE2ETest { - - private val apiKey: String? = System.getenv("SOLAPI_API_KEY") - private val apiSecret: String? = System.getenv("SOLAPI_API_SECRET") - private val senderNumber: String = System.getenv("SOLAPI_SENDER") ?: "01000000000" - private val testPhoneNumber: String = System.getenv("SOLAPI_RECIPIENT") ?: "01000000000" - - private val messageService: DefaultMessageService? by lazy { - if (apiKey != null && apiSecret != null) { - SolapiClient.createInstance(apiKey, apiSecret) - } else { - null - } - } - - private fun assumeEnvironmentConfigured(): Boolean { - if (apiKey.isNullOrBlank() || apiSecret.isNullOrBlank()) { - println("환경변수가 설정되지 않아 테스트를 건너뜁니다. (SOLAPI_API_KEY, SOLAPI_API_SECRET 필요)") - return false - } - return true - } - - private fun printExceptionDetails(e: Exception) { - println("예상된 에러 발생: ${e.message}") - if (e is SolapiMessageNotReceivedException) { - println(" 실패한 메시지 목록 (${e.failedMessageList.size}건):") - e.failedMessageList.forEachIndexed { index, failed -> - println(" [${index + 1}] to: ${failed.to}, statusCode: ${failed.statusCode}, statusMessage: ${failed.statusMessage}") - } - } - } +class ScheduledMessageE2ETest : BaseE2ETest() { // ==================== LocalDateTime 예약 발송 테스트 ==================== @Test fun `예약 발송 - LocalDateTime 시스템 기본 타임존 사용`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeBasicEnvironmentConfigured()) return // Given val message = Message( @@ -86,7 +53,7 @@ class ScheduledMessageE2ETest { @Test fun `예약 발송 - LocalDateTime 명시적 타임존 사용 (Asia Seoul)`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeBasicEnvironmentConfigured()) return // Given val message = Message( @@ -115,7 +82,7 @@ class ScheduledMessageE2ETest { @Test fun `예약 발송 - 기존 Instant API 하위호환성 확인`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeBasicEnvironmentConfigured()) return // Given val message = Message( @@ -143,7 +110,7 @@ class ScheduledMessageE2ETest { @Test fun `예약 발송 - 다중 메시지 LocalDateTime 사용`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeBasicEnvironmentConfigured()) return // Given val messages = listOf( @@ -181,7 +148,7 @@ class ScheduledMessageE2ETest { @Test fun `예약 발송 - 과거 시간 지정시 즉시 발송 처리`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeBasicEnvironmentConfigured()) return // Given val message = Message( @@ -207,7 +174,7 @@ class ScheduledMessageE2ETest { @Test fun `예약 발송 - 6개월 이내 미래 시간은 성공`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeBasicEnvironmentConfigured()) return // Given val message = Message( @@ -234,7 +201,7 @@ class ScheduledMessageE2ETest { @Test fun `예약 발송 - 6개월 초과 미래 시간 지정시 에러`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeBasicEnvironmentConfigured()) return // Given val message = Message( @@ -265,7 +232,7 @@ class ScheduledMessageE2ETest { @Test fun `예약 발송 - 나노초 정밀도 유지 확인`() { - if (!assumeEnvironmentConfigured()) return + if (!assumeBasicEnvironmentConfigured()) return // Given val message = Message( From 6e1937aaa9b67adcee2718f46eef1d489ee0e549 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 29 Jan 2026 12:54:52 +0900 Subject: [PATCH 26/43] refactor: add multi-module auto-discovery to settings.gradle.kts Example modules starting with 'solapi-kotlin-example' are now automatically discovered and included in the build. Co-Authored-By: Claude Opus 4.5 --- settings.gradle.kts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/settings.gradle.kts b/settings.gradle.kts index 8807792..d4172b6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,7 @@ rootProject.name = "solapi.sdk" + +// Example modules auto-discovery +rootDir.listFiles() + ?.filter { it.isDirectory && it.name.startsWith("solapi-kotlin-example") } + ?.filter { File(it, "build.gradle.kts").exists() } + ?.forEach { include(":${it.name}") } From 31a836972f0ea680abc354b5ad7bf881db65d46c Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 29 Jan 2026 12:55:00 +0900 Subject: [PATCH 27/43] feat: add Java and Kotlin example modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive SDK usage examples in both Java and Kotlin: Examples included: - SendSms: SMS 단건 발송 - SendMms: MMS 이미지 첨부 발송 - SendBatch: 대량 메시지 발송 - SendScheduled: 예약 발송 - SendVoice: 음성 메시지 발송 - GetBalance: 잔액 조회 - GetMessageList: 발송 내역 조회 - KakaoAlimtalk: 알림톡 발송 - KakaoBrandMessage: 브랜드 메시지 발송 Usage: ./gradlew :solapi-kotlin-example-java:run -Pexample=SendSms ./gradlew :solapi-kotlin-example-kotlin:run -Pexample=GetBalance Co-Authored-By: Claude Opus 4.5 --- solapi-kotlin-example-java/build.gradle.kts | 34 +++ .../com/solapi/example/GetBalanceExample.java | 50 +++++ .../solapi/example/GetMessageListExample.java | 114 ++++++++++ .../solapi/example/KakaoAlimtalkExample.java | 86 ++++++++ .../example/KakaoBrandMessageExample.java | 198 ++++++++++++++++++ .../java/com/solapi/example/MainExample.java | 80 +++++++ .../com/solapi/example/SendBatchExample.java | 77 +++++++ .../com/solapi/example/SendMmsExample.java | 92 ++++++++ .../solapi/example/SendScheduledExample.java | 75 +++++++ .../com/solapi/example/SendSmsExample.java | 57 +++++ .../com/solapi/example/SendVoiceExample.java | 70 +++++++ solapi-kotlin-example-kotlin/build.gradle.kts | 32 +++ .../com/solapi/example/GetBalanceExample.kt | 41 ++++ .../solapi/example/GetMessageListExample.kt | 91 ++++++++ .../solapi/example/KakaoAlimtalkExample.kt | 71 +++++++ .../example/KakaoBrandMessageExample.kt | 188 +++++++++++++++++ .../kotlin/com/solapi/example/MainExample.kt | 60 ++++++ .../com/solapi/example/SendBatchExample.kt | 62 ++++++ .../com/solapi/example/SendMmsExample.kt | 77 +++++++ .../solapi/example/SendScheduledExample.kt | 66 ++++++ .../com/solapi/example/SendSmsExample.kt | 48 +++++ .../com/solapi/example/SendVoiceExample.kt | 62 ++++++ 22 files changed, 1731 insertions(+) create mode 100644 solapi-kotlin-example-java/build.gradle.kts create mode 100644 solapi-kotlin-example-java/src/main/java/com/solapi/example/GetBalanceExample.java create mode 100644 solapi-kotlin-example-java/src/main/java/com/solapi/example/GetMessageListExample.java create mode 100644 solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoAlimtalkExample.java create mode 100644 solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoBrandMessageExample.java create mode 100644 solapi-kotlin-example-java/src/main/java/com/solapi/example/MainExample.java create mode 100644 solapi-kotlin-example-java/src/main/java/com/solapi/example/SendBatchExample.java create mode 100644 solapi-kotlin-example-java/src/main/java/com/solapi/example/SendMmsExample.java create mode 100644 solapi-kotlin-example-java/src/main/java/com/solapi/example/SendScheduledExample.java create mode 100644 solapi-kotlin-example-java/src/main/java/com/solapi/example/SendSmsExample.java create mode 100644 solapi-kotlin-example-java/src/main/java/com/solapi/example/SendVoiceExample.java create mode 100644 solapi-kotlin-example-kotlin/build.gradle.kts create mode 100644 solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetBalanceExample.kt create mode 100644 solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetMessageListExample.kt create mode 100644 solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoAlimtalkExample.kt create mode 100644 solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoBrandMessageExample.kt create mode 100644 solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/MainExample.kt create mode 100644 solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendBatchExample.kt create mode 100644 solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendMmsExample.kt create mode 100644 solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendScheduledExample.kt create mode 100644 solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendSmsExample.kt create mode 100644 solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendVoiceExample.kt diff --git a/solapi-kotlin-example-java/build.gradle.kts b/solapi-kotlin-example-java/build.gradle.kts new file mode 100644 index 0000000..95b1dff --- /dev/null +++ b/solapi-kotlin-example-java/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + java + application +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(rootProject) +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(8)) + } +} + +application { + val example = project.findProperty("example") as String? ?: "Main" + mainClass.set("com.solapi.example.${example}Example") +} + +tasks.named("run") { + standardInput = System.`in` +} + +// Disable distTar/distZip/installDist/startScripts from regular builds +// These are only needed when explicitly building distributions +tasks.named("distTar") { enabled = false } +tasks.named("distZip") { enabled = false } +tasks.named("installDist") { enabled = false } +tasks.named("startScripts") { enabled = false } diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetBalanceExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetBalanceExample.java new file mode 100644 index 0000000..419ef5f --- /dev/null +++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetBalanceExample.java @@ -0,0 +1,50 @@ +package com.solapi.example; + +import com.solapi.sdk.SolapiClient; +import com.solapi.sdk.message.model.Balance; +import com.solapi.sdk.message.model.Quota; +import com.solapi.sdk.message.service.DefaultMessageService; + +/** + * 잔액 조회 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + */ +public class GetBalanceExample { + + public static void main(String[] args) { + // 환경변수에서 설정 로드 + String apiKey = System.getenv("SOLAPI_API_KEY"); + String apiSecret = System.getenv("SOLAPI_API_SECRET"); + + if (apiKey == null || apiSecret == null) { + System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set"); + System.exit(1); + } + + // SDK 클라이언트 생성 + DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret); + + try { + // 잔액 조회 + Balance balance = messageService.getBalance(); + + System.out.println("=== 잔액 정보 ==="); + System.out.println("Balance: " + balance.getBalance()); + System.out.println("Point: " + balance.getPoint()); + + // 일일 발송량 한도 조회 + Quota quota = messageService.getQuota(); + + System.out.println(); + System.out.println("=== 일일 발송량 한도 ==="); + System.out.println("Quota: " + quota); + + } catch (Exception e) { + System.err.println("조회 실패: " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetMessageListExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetMessageListExample.java new file mode 100644 index 0000000..a5ae2c8 --- /dev/null +++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetMessageListExample.java @@ -0,0 +1,114 @@ +package com.solapi.example; + +import com.solapi.sdk.SolapiClient; +import com.solapi.sdk.message.dto.request.MessageListRequest; +import com.solapi.sdk.message.dto.response.MessageListResponse; +import com.solapi.sdk.message.model.Message; +import com.solapi.sdk.message.model.MessageStatusType; +import com.solapi.sdk.message.service.DefaultMessageService; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 발송 내역 조회 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * + * 다양한 필터 옵션: + * - from: 발신번호 + * - to: 수신번호 + * - type: 메시지 타입 (SMS, LMS, MMS, ATA, CTA 등) + * - status: 메시지 상태 (PENDING, SENDING, COMPLETE, FAILED) + * - startDate/endDate: 날짜 범위 + */ +public class GetMessageListExample { + + public static void main(String[] args) { + // 환경변수에서 설정 로드 + String apiKey = System.getenv("SOLAPI_API_KEY"); + String apiSecret = System.getenv("SOLAPI_API_SECRET"); + + if (apiKey == null || apiSecret == null) { + System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set"); + System.exit(1); + } + + // SDK 클라이언트 생성 + DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret); + + try { + // 1. 기본 조회 (최근 메시지) + System.out.println("=== 기본 메시지 목록 (최근 10건) ==="); + MessageListRequest basicRequest = new MessageListRequest(); + basicRequest.setLimit(10); + + MessageListResponse basicResponse = messageService.getMessageList(basicRequest); + printMessageList(basicResponse); + + // 2. 발송 완료 메시지 조회 + System.out.println("\n=== 발송 완료 메시지 ==="); + MessageListRequest completedRequest = new MessageListRequest(); + completedRequest.setStatus(MessageStatusType.COMPLETE); + completedRequest.setLimit(5); + + MessageListResponse completedResponse = messageService.getMessageList(completedRequest); + printMessageList(completedResponse); + + // 3. 날짜 범위 조회 (최근 7일) + System.out.println("\n=== 최근 7일간 메시지 ==="); + MessageListRequest dateRequest = new MessageListRequest(); + dateRequest.setStartDateFromLocalDateTime(LocalDateTime.now().minusDays(7)); + dateRequest.setEndDateFromLocalDateTime(LocalDateTime.now()); + dateRequest.setLimit(5); + + MessageListResponse dateResponse = messageService.getMessageList(dateRequest); + printMessageList(dateResponse); + + // 4. SMS 타입만 조회 + System.out.println("\n=== SMS 타입 메시지 ==="); + MessageListRequest smsRequest = new MessageListRequest(); + smsRequest.setType("SMS"); + smsRequest.setLimit(5); + + MessageListResponse smsResponse = messageService.getMessageList(smsRequest); + printMessageList(smsResponse); + + } catch (Exception e) { + System.err.println("메시지 목록 조회 실패: " + e.getMessage()); + e.printStackTrace(); + } + } + + private static void printMessageList(MessageListResponse response) { + if (response == null || response.getMessageList() == null) { + System.out.println(" (조회 결과 없음)"); + return; + } + + Map messageList = response.getMessageList(); + System.out.println(" 조회 건수: " + messageList.size()); + + int count = 0; + for (Map.Entry entry : messageList.entrySet()) { + if (count >= 3) { + System.out.println(" ... (외 " + (messageList.size() - 3) + "건)"); + break; + } + + Message msg = entry.getValue(); + System.out.println(" - ID: " + entry.getKey()); + System.out.println(" Type: " + msg.getType() + ", To: " + msg.getTo()); + System.out.println(" Status: " + msg.getStatusCode() + ", Text: " + truncate(msg.getText(), 30)); + count++; + } + } + + private static String truncate(String str, int maxLength) { + if (str == null) return "(null)"; + if (str.length() <= maxLength) return str; + return str.substring(0, maxLength) + "..."; + } +} diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoAlimtalkExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoAlimtalkExample.java new file mode 100644 index 0000000..3452bb9 --- /dev/null +++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoAlimtalkExample.java @@ -0,0 +1,86 @@ +package com.solapi.example; + +import com.solapi.sdk.SolapiClient; +import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse; +import com.solapi.sdk.message.model.Message; +import com.solapi.sdk.message.model.MessageType; +import com.solapi.sdk.message.model.kakao.KakaoOption; +import com.solapi.sdk.message.service.DefaultMessageService; + +import java.util.HashMap; +import java.util.Map; + +/** + * 카카오 알림톡 발송 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 수신번호 + * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID + * - SOLAPI_KAKAO_TEMPLATE_ID: 카카오 알림톡 템플릿 ID + * + * 알림톡 특징: + * - 사전에 검수 승인된 템플릿만 사용 가능 + * - 정보성 메시지 전용 (광고 불가) + * - 변수 치환을 통해 동적 내용 전달 가능 + */ +public class KakaoAlimtalkExample { + + public static void main(String[] args) { + // 환경변수에서 설정 로드 + String apiKey = System.getenv("SOLAPI_API_KEY"); + String apiSecret = System.getenv("SOLAPI_API_SECRET"); + String sender = System.getenv("SOLAPI_SENDER"); + String recipient = System.getenv("SOLAPI_RECIPIENT"); + String pfId = System.getenv("SOLAPI_KAKAO_PF_ID"); + String templateId = System.getenv("SOLAPI_KAKAO_TEMPLATE_ID"); + + if (apiKey == null || apiSecret == null) { + System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set"); + System.exit(1); + } + if (sender == null || recipient == null) { + System.err.println("SOLAPI_SENDER and SOLAPI_RECIPIENT must be set"); + System.exit(1); + } + if (pfId == null || templateId == null) { + System.err.println("SOLAPI_KAKAO_PF_ID and SOLAPI_KAKAO_TEMPLATE_ID must be set for Kakao Alimtalk"); + System.exit(1); + } + + // SDK 클라이언트 생성 + DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret); + + // 템플릿 변수 설정 (템플릿에 맞게 조정 필요) + Map variables = new HashMap<>(); + variables.put("name", "홍길동"); + variables.put("code", "123456"); + + // 카카오 옵션 설정 + KakaoOption kakaoOption = new KakaoOption(); + kakaoOption.setPfId(pfId); + kakaoOption.setTemplateId(templateId); + kakaoOption.setVariables(variables); + + // 알림톡 메시지 생성 + Message message = new Message(); + message.setType(MessageType.ATA); + message.setFrom(sender); + message.setTo(recipient); + message.setKakaoOptions(kakaoOption); + + try { + // 알림톡 발송 + MultipleDetailMessageSentResponse response = messageService.send(message, null); + + System.out.println("알림톡 발송 성공!"); + System.out.println("Group ID: " + response.getGroupInfo().getGroupId()); + + } catch (Exception e) { + System.err.println("알림톡 발송 실패: " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoBrandMessageExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoBrandMessageExample.java new file mode 100644 index 0000000..b08c04a --- /dev/null +++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoBrandMessageExample.java @@ -0,0 +1,198 @@ +package com.solapi.example; + +import com.solapi.sdk.SolapiClient; +import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse; +import com.solapi.sdk.message.model.Message; +import com.solapi.sdk.message.model.MessageType; +import com.solapi.sdk.message.model.StorageType; +import com.solapi.sdk.message.model.kakao.KakaoBmsOption; +import com.solapi.sdk.message.model.kakao.KakaoOption; +import com.solapi.sdk.message.model.kakao.bms.BmsButton; +import com.solapi.sdk.message.model.kakao.bms.BmsButtonType; +import com.solapi.sdk.message.model.kakao.bms.BmsChatBubbleType; +import com.solapi.sdk.message.model.kakao.bms.BmsCoupon; +import com.solapi.sdk.message.service.DefaultMessageService; + +import java.io.File; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.Arrays; +import java.util.List; + +/** + * 카카오 브랜드 메시지 (BMS_FREE) 발송 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 수신번호 + * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID + * + * 브랜드 메시지 특징: + * - 다양한 템플릿 형태 지원 (TEXT, IMAGE, WIDE, COMMERCE 등) + * - 쿠폰, 버튼 등 다양한 구성요소 포함 가능 + * - 캐러셀 형태의 메시지 지원 + */ +public class KakaoBrandMessageExample { + + public static void main(String[] args) { + // 환경변수에서 설정 로드 + String apiKey = System.getenv("SOLAPI_API_KEY"); + String apiSecret = System.getenv("SOLAPI_API_SECRET"); + String sender = System.getenv("SOLAPI_SENDER"); + String recipient = System.getenv("SOLAPI_RECIPIENT"); + String pfId = System.getenv("SOLAPI_KAKAO_PF_ID"); + + if (apiKey == null || apiSecret == null) { + System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set"); + System.exit(1); + } + if (sender == null || recipient == null) { + System.err.println("SOLAPI_SENDER and SOLAPI_RECIPIENT must be set"); + System.exit(1); + } + if (pfId == null) { + System.err.println("SOLAPI_KAKAO_PF_ID must be set for Kakao Brand Message"); + System.exit(1); + } + + // SDK 클라이언트 생성 + DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret); + + // 예제 1: TEXT 타입 브랜드 메시지 + sendTextBrandMessage(messageService, sender, recipient, pfId); + + // 예제 2: IMAGE 타입 브랜드 메시지 (이미지 파일 필요) + // sendImageBrandMessage(messageService, sender, recipient, pfId); + } + + /** + * TEXT 타입 브랜드 메시지 발송 + */ + private static void sendTextBrandMessage( + DefaultMessageService messageService, + String sender, + String recipient, + String pfId + ) { + System.out.println("\n=== TEXT 타입 브랜드 메시지 발송 ==="); + + // BMS 버튼 생성 + BmsButton webLinkButton = new BmsButton(); + webLinkButton.setLinkType(BmsButtonType.WL); + webLinkButton.setName("바로가기"); + webLinkButton.setLinkMobile("https://example.com"); + webLinkButton.setLinkPc("https://example.com"); + + BmsButton channelAddButton = new BmsButton(); + channelAddButton.setLinkType(BmsButtonType.AC); + channelAddButton.setName("채널 추가"); + + List buttons = Arrays.asList(webLinkButton, channelAddButton); + + // 쿠폰 생성 (선택사항) + BmsCoupon coupon = new BmsCoupon(); + coupon.setTitle("10% 할인쿠폰"); + coupon.setDescription("첫 구매 고객 전용"); + + // BMS 옵션 설정 + KakaoBmsOption bmsOption = new KakaoBmsOption(); + bmsOption.setChatBubbleType(BmsChatBubbleType.TEXT); + bmsOption.setContent("브랜드 메시지 TEXT 타입 테스트입니다."); + bmsOption.setButtons(buttons); + bmsOption.setCoupon(coupon); + bmsOption.setAdult(false); + + // 카카오 옵션 설정 + KakaoOption kakaoOption = new KakaoOption(); + kakaoOption.setPfId(pfId); + kakaoOption.setBms(bmsOption); + + // 브랜드 메시지 생성 + Message message = new Message(); + message.setType(MessageType.BMS_FREE); + message.setFrom(sender); + message.setTo(recipient); + message.setKakaoOptions(kakaoOption); + + try { + MultipleDetailMessageSentResponse response = messageService.send(message, null); + + System.out.println("TEXT 브랜드 메시지 발송 성공!"); + System.out.println("Group ID: " + response.getGroupInfo().getGroupId()); + + } catch (Exception e) { + System.err.println("TEXT 브랜드 메시지 발송 실패: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * IMAGE 타입 브랜드 메시지 발송 (이미지 파일 필요) + */ + private static void sendImageBrandMessage( + DefaultMessageService messageService, + String sender, + String recipient, + String pfId + ) { + System.out.println("\n=== IMAGE 타입 브랜드 메시지 발송 ==="); + + try { + // 이미지 파일 로드 + URL imageUrl = KakaoBrandMessageExample.class.getClassLoader().getResource("images/sample.jpg"); + if (imageUrl == null) { + System.err.println("Sample image not found. Skipping IMAGE type example."); + return; + } + + File tempFile = File.createTempFile("bms-image", ".jpg"); + tempFile.deleteOnExit(); + try (InputStream is = imageUrl.openStream()) { + Files.copy(is, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + + // 이미지 업로드 (BMS 스토리지 타입) + String imageId = messageService.uploadFile(tempFile, StorageType.BMS, null); + System.out.println("이미지 업로드 완료 - imageId: " + imageId); + + // BMS 버튼 생성 + BmsButton webLinkButton = new BmsButton(); + webLinkButton.setLinkType(BmsButtonType.WL); + webLinkButton.setName("자세히 보기"); + webLinkButton.setLinkMobile("https://example.com"); + webLinkButton.setLinkPc("https://example.com"); + + // BMS 옵션 설정 + KakaoBmsOption bmsOption = new KakaoBmsOption(); + bmsOption.setChatBubbleType(BmsChatBubbleType.IMAGE); + bmsOption.setImageId(imageId); + bmsOption.setImageLink("https://example.com/image"); + bmsOption.setContent("IMAGE 타입 브랜드 메시지입니다."); + bmsOption.setButtons(Arrays.asList(webLinkButton)); + bmsOption.setAdult(false); + + KakaoOption kakaoOption = new KakaoOption(); + kakaoOption.setPfId(pfId); + kakaoOption.setBms(bmsOption); + + Message message = new Message(); + message.setType(MessageType.BMS_FREE); + message.setFrom(sender); + message.setTo(recipient); + message.setKakaoOptions(kakaoOption); + + MultipleDetailMessageSentResponse response = messageService.send(message, null); + + System.out.println("IMAGE 브랜드 메시지 발송 성공!"); + System.out.println("Group ID: " + response.getGroupInfo().getGroupId()); + + } catch (Exception e) { + System.err.println("IMAGE 브랜드 메시지 발송 실패: " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/MainExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/MainExample.java new file mode 100644 index 0000000..cf6926e --- /dev/null +++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/MainExample.java @@ -0,0 +1,80 @@ +package com.solapi.example; + +/** + * SOLAPI SDK 예제 메인 클래스 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 테스트 수신번호 + * + * 개별 예제 실행: + * ./gradlew :solapi-kotlin-example-java:run -Pexample=SendSms + * ./gradlew :solapi-kotlin-example-java:run -Pexample=GetBalance + */ +public class MainExample { + + public static void main(String[] args) { + printLine(60); + System.out.println("SOLAPI SDK Java Examples"); + printLine(60); + System.out.println(); + System.out.println("Available examples:"); + System.out.println(); + System.out.println(" SMS/LMS/MMS:"); + System.out.println(" SendSms - SMS 단건 발송"); + System.out.println(" SendMms - MMS 이미지 첨부 발송"); + System.out.println(" SendBatch - 대량 메시지 발송"); + System.out.println(" SendScheduled - 예약 발송"); + System.out.println(" SendVoice - 음성 메시지 발송"); + System.out.println(); + System.out.println(" Account:"); + System.out.println(" GetBalance - 잔액 조회"); + System.out.println(" GetMessageList - 발송 내역 조회"); + System.out.println(); + System.out.println(" Kakao:"); + System.out.println(" KakaoAlimtalk - 알림톡 발송"); + System.out.println(" KakaoFriendTalk - 친구톡 발송"); + System.out.println(" KakaoImageFriend - 이미지 친구톡 발송"); + System.out.println(" KakaoBrandMessage - 브랜드 메시지 발송"); + System.out.println(); + System.out.println("Usage:"); + System.out.println(" ./gradlew :solapi-kotlin-example-java:run -Pexample="); + System.out.println(); + System.out.println("Example:"); + System.out.println(" ./gradlew :solapi-kotlin-example-java:run -Pexample=SendSms"); + System.out.println(" ./gradlew :solapi-kotlin-example-java:run -Pexample=GetBalance"); + System.out.println(); + System.out.println("Environment variables:"); + System.out.println(" SOLAPI_API_KEY - " + maskValue(System.getenv("SOLAPI_API_KEY"))); + System.out.println(" SOLAPI_API_SECRET - " + maskValue(System.getenv("SOLAPI_API_SECRET"))); + System.out.println(" SOLAPI_SENDER - " + getEnvOrDefault("SOLAPI_SENDER", "(not set)")); + System.out.println(" SOLAPI_RECIPIENT - " + getEnvOrDefault("SOLAPI_RECIPIENT", "(not set)")); + System.out.println(" SOLAPI_KAKAO_PF_ID - " + getEnvOrDefault("SOLAPI_KAKAO_PF_ID", "(not set)")); + System.out.println(); + } + + private static void printLine(int count) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < count; i++) { + sb.append("="); + } + System.out.println(sb.toString()); + } + + private static String maskValue(String value) { + if (value == null || value.isEmpty()) { + return "(not set)"; + } + if (value.length() <= 8) { + return "****"; + } + return value.substring(0, 4) + "****" + value.substring(value.length() - 4); + } + + private static String getEnvOrDefault(String key, String defaultValue) { + String value = System.getenv(key); + return (value == null || value.isEmpty()) ? defaultValue : value; + } +} diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendBatchExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendBatchExample.java new file mode 100644 index 0000000..516ea01 --- /dev/null +++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendBatchExample.java @@ -0,0 +1,77 @@ +package com.solapi.example; + +import com.solapi.sdk.SolapiClient; +import com.solapi.sdk.message.dto.request.SendRequestConfig; +import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse; +import com.solapi.sdk.message.model.Message; +import com.solapi.sdk.message.service.DefaultMessageService; + +import java.util.ArrayList; +import java.util.List; + +/** + * 대량 메시지 발송 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 수신번호 (테스트용으로 동일 번호 사용) + * + * 참고: + * - 한 번에 최대 10,000건까지 발송 가능 + * - allowDuplicates 옵션으로 중복 수신번호 허용 가능 + */ +public class SendBatchExample { + + public static void main(String[] args) { + // 환경변수에서 설정 로드 + String apiKey = System.getenv("SOLAPI_API_KEY"); + String apiSecret = System.getenv("SOLAPI_API_SECRET"); + String sender = System.getenv("SOLAPI_SENDER"); + String recipient = System.getenv("SOLAPI_RECIPIENT"); + + if (apiKey == null || apiSecret == null) { + System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set"); + System.exit(1); + } + if (sender == null || recipient == null) { + System.err.println("SOLAPI_SENDER and SOLAPI_RECIPIENT must be set"); + System.exit(1); + } + + // SDK 클라이언트 생성 + DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret); + + // 여러 메시지 생성 (테스트를 위해 동일 수신자에게 발송) + List messages = new ArrayList<>(); + for (int i = 1; i <= 3; i++) { + Message message = new Message(); + message.setFrom(sender); + message.setTo(recipient); + message.setText("대량 발송 테스트 메시지 " + i + "/3"); + messages.add(message); + } + + // 발송 설정 (중복 수신번호 허용) + SendRequestConfig config = new SendRequestConfig(); + config.setAllowDuplicates(true); + + try { + // 대량 메시지 발송 + MultipleDetailMessageSentResponse response = messageService.send(messages, config); + + System.out.println("대량 메시지 발송 성공!"); + System.out.println("Group ID: " + response.getGroupInfo().getGroupId()); + System.out.println("Total Count: " + response.getGroupInfo().getCount()); + if (response.getGroupInfo().getCount() != null) { + System.out.println(" - Total: " + response.getGroupInfo().getCount().getTotal()); + System.out.println(" - Sent Total: " + response.getGroupInfo().getCount().getSentTotal()); + } + + } catch (Exception e) { + System.err.println("대량 메시지 발송 실패: " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendMmsExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendMmsExample.java new file mode 100644 index 0000000..030b305 --- /dev/null +++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendMmsExample.java @@ -0,0 +1,92 @@ +package com.solapi.example; + +import com.solapi.sdk.SolapiClient; +import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse; +import com.solapi.sdk.message.model.Message; +import com.solapi.sdk.message.model.MessageType; +import com.solapi.sdk.message.model.StorageType; +import com.solapi.sdk.message.service.DefaultMessageService; + +import java.io.File; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; + +/** + * MMS 이미지 첨부 발송 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 수신번호 + * + * 참고: MMS 이미지 규격 + * - 지원 포맷: JPG, JPEG + * - 최대 용량: 200KB + * - 권장 해상도: 1000x1000 이하 + */ +public class SendMmsExample { + + public static void main(String[] args) { + // 환경변수에서 설정 로드 + String apiKey = System.getenv("SOLAPI_API_KEY"); + String apiSecret = System.getenv("SOLAPI_API_SECRET"); + String sender = System.getenv("SOLAPI_SENDER"); + String recipient = System.getenv("SOLAPI_RECIPIENT"); + + if (apiKey == null || apiSecret == null) { + System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set"); + System.exit(1); + } + if (sender == null || recipient == null) { + System.err.println("SOLAPI_SENDER and SOLAPI_RECIPIENT must be set"); + System.exit(1); + } + + // SDK 클라이언트 생성 + DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret); + + try { + // 이미지 파일 로드 (리소스에서) + URL imageUrl = SendMmsExample.class.getClassLoader().getResource("images/sample.jpg"); + if (imageUrl == null) { + System.err.println("Sample image not found in resources/images/sample.jpg"); + System.err.println("Please add a JPG image file (max 200KB) to run this example."); + System.exit(1); + } + + // 임시 파일로 복사 (URL에서 File로 변환) + File tempFile = File.createTempFile("mms-image", ".jpg"); + tempFile.deleteOnExit(); + try (InputStream is = imageUrl.openStream()) { + Files.copy(is, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + + // 이미지 업로드 + System.out.println("이미지 업로드 중..."); + String imageId = messageService.uploadFile(tempFile, StorageType.MMS, null); + System.out.println("이미지 업로드 완료 - imageId: " + imageId); + + // MMS 메시지 생성 + Message message = new Message(); + message.setType(MessageType.MMS); + message.setFrom(sender); + message.setTo(recipient); + message.setText("안녕하세요. MMS 이미지 첨부 메시지입니다."); + message.setSubject("MMS 제목"); + message.setImageId(imageId); + + // 메시지 발송 + MultipleDetailMessageSentResponse response = messageService.send(message, null); + + System.out.println("MMS 발송 성공!"); + System.out.println("Group ID: " + response.getGroupInfo().getGroupId()); + + } catch (Exception e) { + System.err.println("MMS 발송 실패: " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendScheduledExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendScheduledExample.java new file mode 100644 index 0000000..01de4b2 --- /dev/null +++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendScheduledExample.java @@ -0,0 +1,75 @@ +package com.solapi.example; + +import com.solapi.sdk.SolapiClient; +import com.solapi.sdk.message.dto.request.SendRequestConfig; +import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse; +import com.solapi.sdk.message.model.Message; +import com.solapi.sdk.message.service.DefaultMessageService; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** + * 예약 발송 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 수신번호 + * + * 참고: + * - 예약 시간은 현재 시간으로부터 최소 10분 이후여야 함 + * - 최대 6개월 이내로 예약 가능 + * - 과거 시간 지정 시 즉시 발송 처리됨 + */ +public class SendScheduledExample { + + public static void main(String[] args) { + // 환경변수에서 설정 로드 + String apiKey = System.getenv("SOLAPI_API_KEY"); + String apiSecret = System.getenv("SOLAPI_API_SECRET"); + String sender = System.getenv("SOLAPI_SENDER"); + String recipient = System.getenv("SOLAPI_RECIPIENT"); + + if (apiKey == null || apiSecret == null) { + System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set"); + System.exit(1); + } + if (sender == null || recipient == null) { + System.err.println("SOLAPI_SENDER and SOLAPI_RECIPIENT must be set"); + System.exit(1); + } + + // SDK 클라이언트 생성 + DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret); + + // 메시지 생성 + Message message = new Message(); + message.setFrom(sender); + message.setTo(recipient); + message.setText("안녕하세요. 예약 발송 테스트 메시지입니다."); + + // 10분 후 예약 발송 설정 + LocalDateTime scheduledTime = LocalDateTime.now().plusMinutes(10); + ZoneId seoulZone = ZoneId.of("Asia/Seoul"); + + SendRequestConfig config = new SendRequestConfig(); + config.setScheduledDateFromLocalDateTime(scheduledTime, seoulZone); + + System.out.println("예약 시간: " + scheduledTime); + + try { + // 예약 메시지 발송 + MultipleDetailMessageSentResponse response = messageService.send(message, config); + + System.out.println("예약 발송 성공!"); + System.out.println("Group ID: " + response.getGroupInfo().getGroupId()); + System.out.println("Scheduled Date: " + response.getGroupInfo().getScheduledDate()); + + } catch (Exception e) { + System.err.println("예약 발송 실패: " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendSmsExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendSmsExample.java new file mode 100644 index 0000000..1a3e124 --- /dev/null +++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendSmsExample.java @@ -0,0 +1,57 @@ +package com.solapi.example; + +import com.solapi.sdk.SolapiClient; +import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse; +import com.solapi.sdk.message.model.Message; +import com.solapi.sdk.message.service.DefaultMessageService; + +/** + * SMS 단건 발송 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 수신번호 + */ +public class SendSmsExample { + + public static void main(String[] args) { + // 환경변수에서 설정 로드 + String apiKey = System.getenv("SOLAPI_API_KEY"); + String apiSecret = System.getenv("SOLAPI_API_SECRET"); + String sender = System.getenv("SOLAPI_SENDER"); + String recipient = System.getenv("SOLAPI_RECIPIENT"); + + if (apiKey == null || apiSecret == null) { + System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set"); + System.exit(1); + } + if (sender == null || recipient == null) { + System.err.println("SOLAPI_SENDER and SOLAPI_RECIPIENT must be set"); + System.exit(1); + } + + // SDK 클라이언트 생성 + DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret); + + // 메시지 생성 + Message message = new Message(); + message.setFrom(sender); + message.setTo(recipient); + message.setText("안녕하세요. SOLAPI SDK Java 예제입니다."); + + try { + // 메시지 발송 + MultipleDetailMessageSentResponse response = messageService.send(message, null); + + System.out.println("SMS 발송 성공!"); + System.out.println("Group ID: " + response.getGroupInfo().getGroupId()); + System.out.println("Message Count: " + response.getGroupInfo().getCount()); + + } catch (Exception e) { + System.err.println("SMS 발송 실패: " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendVoiceExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendVoiceExample.java new file mode 100644 index 0000000..0ec94c1 --- /dev/null +++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendVoiceExample.java @@ -0,0 +1,70 @@ +package com.solapi.example; + +import com.solapi.sdk.SolapiClient; +import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse; +import com.solapi.sdk.message.model.Message; +import com.solapi.sdk.message.model.MessageType; +import com.solapi.sdk.message.model.voice.VoiceOption; +import com.solapi.sdk.message.model.voice.VoiceType; +import com.solapi.sdk.message.service.DefaultMessageService; + +/** + * 음성 메시지 발송 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 수신번호 + * + * 음성 메시지는 TTS(Text-to-Speech)를 통해 텍스트를 음성으로 변환하여 발송합니다. + * VoiceType: FEMALE(여성), MALE(남성) + */ +public class SendVoiceExample { + + public static void main(String[] args) { + // 환경변수에서 설정 로드 + String apiKey = System.getenv("SOLAPI_API_KEY"); + String apiSecret = System.getenv("SOLAPI_API_SECRET"); + String sender = System.getenv("SOLAPI_SENDER"); + String recipient = System.getenv("SOLAPI_RECIPIENT"); + + if (apiKey == null || apiSecret == null) { + System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set"); + System.exit(1); + } + if (sender == null || recipient == null) { + System.err.println("SOLAPI_SENDER and SOLAPI_RECIPIENT must be set"); + System.exit(1); + } + + // SDK 클라이언트 생성 + DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret); + + // 음성 옵션 설정 + VoiceOption voiceOption = new VoiceOption(); + voiceOption.setVoiceType(VoiceType.FEMALE); // 여성 음성 + voiceOption.setHeaderMessage("안녕하세요."); // 헤더 메시지 + voiceOption.setTailMessage("감사합니다."); // 테일 메시지 + + // 음성 메시지 생성 + Message message = new Message(); + message.setType(MessageType.VOICE); + message.setFrom(sender); + message.setTo(recipient); + message.setText("음성 메시지 본문입니다. 이 메시지는 TTS로 변환되어 발송됩니다."); + message.setVoiceOptions(voiceOption); + + try { + // 음성 메시지 발송 + MultipleDetailMessageSentResponse response = messageService.send(message, null); + + System.out.println("음성 메시지 발송 성공!"); + System.out.println("Group ID: " + response.getGroupInfo().getGroupId()); + + } catch (Exception e) { + System.err.println("음성 메시지 발송 실패: " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/solapi-kotlin-example-kotlin/build.gradle.kts b/solapi-kotlin-example-kotlin/build.gradle.kts new file mode 100644 index 0000000..9e51bae --- /dev/null +++ b/solapi-kotlin-example-kotlin/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + kotlin("jvm") version "2.3.0" + application +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(rootProject) +} + +kotlin { + jvmToolchain(8) +} + +application { + val example = project.findProperty("example") as String? ?: "Main" + mainClass.set("com.solapi.example.${example}ExampleKt") +} + +tasks.named("run") { + standardInput = System.`in` +} + +// Disable distTar/distZip/installDist/startScripts from regular builds +// These are only needed when explicitly building distributions +tasks.named("distTar") { enabled = false } +tasks.named("distZip") { enabled = false } +tasks.named("installDist") { enabled = false } +tasks.named("startScripts") { enabled = false } diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetBalanceExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetBalanceExample.kt new file mode 100644 index 0000000..b993f7c --- /dev/null +++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetBalanceExample.kt @@ -0,0 +1,41 @@ +package com.solapi.example + +import com.solapi.sdk.SolapiClient + +/** + * 잔액 조회 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + */ +fun main() { + // 환경변수에서 설정 로드 + val apiKey = System.getenv("SOLAPI_API_KEY") + ?: error("SOLAPI_API_KEY must be set") + val apiSecret = System.getenv("SOLAPI_API_SECRET") + ?: error("SOLAPI_API_SECRET must be set") + + // SDK 클라이언트 생성 + val messageService = SolapiClient.createInstance(apiKey, apiSecret) + + try { + // 잔액 조회 + val balance = messageService.getBalance() + + println("=== 잔액 정보 ===") + println("Balance: ${balance.balance}") + println("Point: ${balance.point}") + + // 일일 발송량 한도 조회 + val quota = messageService.getQuota() + + println() + println("=== 일일 발송량 한도 ===") + println("Quota: $quota") + + } catch (e: Exception) { + System.err.println("조회 실패: ${e.message}") + e.printStackTrace() + } +} diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetMessageListExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetMessageListExample.kt new file mode 100644 index 0000000..a736762 --- /dev/null +++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetMessageListExample.kt @@ -0,0 +1,91 @@ +package com.solapi.example + +import com.solapi.sdk.SolapiClient +import com.solapi.sdk.message.dto.request.MessageListRequest +import com.solapi.sdk.message.dto.response.MessageListResponse +import com.solapi.sdk.message.model.MessageStatusType +import java.time.LocalDateTime + +/** + * 발송 내역 조회 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * + * 다양한 필터 옵션: + * - from: 발신번호 + * - to: 수신번호 + * - type: 메시지 타입 (SMS, LMS, MMS, ATA, CTA 등) + * - status: 메시지 상태 (PENDING, SENDING, COMPLETE, FAILED) + * - startDate/endDate: 날짜 범위 + */ +fun main() { + // 환경변수에서 설정 로드 + val apiKey = System.getenv("SOLAPI_API_KEY") + ?: error("SOLAPI_API_KEY must be set") + val apiSecret = System.getenv("SOLAPI_API_SECRET") + ?: error("SOLAPI_API_SECRET must be set") + + // SDK 클라이언트 생성 + val messageService = SolapiClient.createInstance(apiKey, apiSecret) + + try { + // 1. 기본 조회 (최근 메시지) + println("=== 기본 메시지 목록 (최근 10건) ===") + val basicResponse = messageService.getMessageList( + MessageListRequest(limit = 10) + ) + printMessageList(basicResponse) + + // 2. 발송 완료 메시지 조회 + println("\n=== 발송 완료 메시지 ===") + val completedResponse = messageService.getMessageList( + MessageListRequest( + status = MessageStatusType.COMPLETE, + limit = 5 + ) + ) + printMessageList(completedResponse) + + // 3. 날짜 범위 조회 (최근 7일) + println("\n=== 최근 7일간 메시지 ===") + val dateRequest = MessageListRequest(limit = 5).apply { + setStartDateFromLocalDateTime(LocalDateTime.now().minusDays(7)) + setEndDateFromLocalDateTime(LocalDateTime.now()) + } + val dateResponse = messageService.getMessageList(dateRequest) + printMessageList(dateResponse) + + // 4. SMS 타입만 조회 + println("\n=== SMS 타입 메시지 ===") + val smsResponse = messageService.getMessageList( + MessageListRequest(type = "SMS", limit = 5) + ) + printMessageList(smsResponse) + + } catch (e: Exception) { + System.err.println("메시지 목록 조회 실패: ${e.message}") + e.printStackTrace() + } +} + +private fun printMessageList(response: MessageListResponse?) { + val messageList = response?.messageList + if (messageList.isNullOrEmpty()) { + println(" (조회 결과 없음)") + return + } + + println(" 조회 건수: ${messageList.size}") + + messageList.entries.take(3).forEach { (id, msg) -> + println(" - ID: $id") + println(" Type: ${msg.type}, To: ${msg.to}") + println(" Status: ${msg.statusCode}, Text: ${msg.text?.take(30)}${if ((msg.text?.length ?: 0) > 30) "..." else ""}") + } + + if (messageList.size > 3) { + println(" ... (외 ${messageList.size - 3}건)") + } +} diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoAlimtalkExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoAlimtalkExample.kt new file mode 100644 index 0000000..9e8e313 --- /dev/null +++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoAlimtalkExample.kt @@ -0,0 +1,71 @@ +package com.solapi.example + +import com.solapi.sdk.SolapiClient +import com.solapi.sdk.message.model.Message +import com.solapi.sdk.message.model.MessageType +import com.solapi.sdk.message.model.kakao.KakaoOption + +/** + * 카카오 알림톡 발송 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 수신번호 + * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID + * - SOLAPI_KAKAO_TEMPLATE_ID: 카카오 알림톡 템플릿 ID + * + * 알림톡 특징: + * - 사전에 검수 승인된 템플릿만 사용 가능 + * - 정보성 메시지 전용 (광고 불가) + * - 변수 치환을 통해 동적 내용 전달 가능 + */ +fun main() { + // 환경변수에서 설정 로드 + val apiKey = System.getenv("SOLAPI_API_KEY") + ?: error("SOLAPI_API_KEY must be set") + val apiSecret = System.getenv("SOLAPI_API_SECRET") + ?: error("SOLAPI_API_SECRET must be set") + val sender = System.getenv("SOLAPI_SENDER") + ?: error("SOLAPI_SENDER must be set") + val recipient = System.getenv("SOLAPI_RECIPIENT") + ?: error("SOLAPI_RECIPIENT must be set") + val pfId = System.getenv("SOLAPI_KAKAO_PF_ID") + ?: error("SOLAPI_KAKAO_PF_ID must be set for Kakao Alimtalk") + val templateId = System.getenv("SOLAPI_KAKAO_TEMPLATE_ID") + ?: error("SOLAPI_KAKAO_TEMPLATE_ID must be set for Kakao Alimtalk") + + // SDK 클라이언트 생성 + val messageService = SolapiClient.createInstance(apiKey, apiSecret) + + // 템플릿 변수 설정 (템플릿에 맞게 조정 필요) + val variables = mapOf( + "name" to "홍길동", + "code" to "123456" + ) + + // 알림톡 메시지 생성 + val message = Message( + type = MessageType.ATA, + from = sender, + to = recipient, + kakaoOptions = KakaoOption( + pfId = pfId, + templateId = templateId, + variables = variables + ) + ) + + try { + // 알림톡 발송 + val response = messageService.send(message) + + println("알림톡 발송 성공!") + println("Group ID: ${response.groupInfo?.groupId}") + + } catch (e: Exception) { + System.err.println("알림톡 발송 실패: ${e.message}") + e.printStackTrace() + } +} diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoBrandMessageExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoBrandMessageExample.kt new file mode 100644 index 0000000..e6e0d95 --- /dev/null +++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoBrandMessageExample.kt @@ -0,0 +1,188 @@ +package com.solapi.example + +import com.solapi.sdk.SolapiClient +import com.solapi.sdk.message.model.Message +import com.solapi.sdk.message.model.MessageType +import com.solapi.sdk.message.model.StorageType +import com.solapi.sdk.message.model.kakao.KakaoBmsOption +import com.solapi.sdk.message.model.kakao.KakaoOption +import com.solapi.sdk.message.model.kakao.bms.BmsButton +import com.solapi.sdk.message.model.kakao.bms.BmsButtonType +import com.solapi.sdk.message.model.kakao.bms.BmsChatBubbleType +import com.solapi.sdk.message.model.kakao.bms.BmsCoupon +import com.solapi.sdk.message.service.DefaultMessageService +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** + * 카카오 브랜드 메시지 (BMS_FREE) 발송 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 수신번호 + * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID + * + * 브랜드 메시지 특징: + * - 다양한 템플릿 형태 지원 (TEXT, IMAGE, WIDE, COMMERCE 등) + * - 쿠폰, 버튼 등 다양한 구성요소 포함 가능 + * - 캐러셀 형태의 메시지 지원 + */ +fun main() { + // 환경변수에서 설정 로드 + val apiKey = System.getenv("SOLAPI_API_KEY") + ?: error("SOLAPI_API_KEY must be set") + val apiSecret = System.getenv("SOLAPI_API_SECRET") + ?: error("SOLAPI_API_SECRET must be set") + val sender = System.getenv("SOLAPI_SENDER") + ?: error("SOLAPI_SENDER must be set") + val recipient = System.getenv("SOLAPI_RECIPIENT") + ?: error("SOLAPI_RECIPIENT must be set") + val pfId = System.getenv("SOLAPI_KAKAO_PF_ID") + ?: error("SOLAPI_KAKAO_PF_ID must be set for Kakao Brand Message") + + // SDK 클라이언트 생성 + val messageService = SolapiClient.createInstance(apiKey, apiSecret) + + // 예제 1: TEXT 타입 브랜드 메시지 + sendTextBrandMessage(messageService, sender, recipient, pfId) + + // 예제 2: IMAGE 타입 브랜드 메시지 (이미지 파일 필요) + // sendImageBrandMessage(messageService, sender, recipient, pfId) +} + +/** + * TEXT 타입 브랜드 메시지 발송 + */ +private fun sendTextBrandMessage( + messageService: DefaultMessageService, + sender: String, + recipient: String, + pfId: String +) { + println("\n=== TEXT 타입 브랜드 메시지 발송 ===") + + // BMS 버튼 생성 + val buttons = listOf( + BmsButton( + linkType = BmsButtonType.WL, + name = "바로가기", + linkMobile = "https://example.com", + linkPc = "https://example.com" + ), + BmsButton( + linkType = BmsButtonType.AC, // Add Channel + name = "채널 추가" + ) + ) + + // 쿠폰 생성 (선택사항) + val coupon = BmsCoupon( + title = "10% 할인쿠폰", + description = "첫 구매 고객 전용" + ) + + // BMS 옵션 설정 + val bmsOption = KakaoBmsOption( + chatBubbleType = BmsChatBubbleType.TEXT, + content = "브랜드 메시지 TEXT 타입 테스트입니다.", + buttons = buttons, + coupon = coupon, + adult = false + ) + + // 브랜드 메시지 생성 + val message = Message( + type = MessageType.BMS_FREE, + from = sender, + to = recipient, + kakaoOptions = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + ) + + try { + val response = messageService.send(message) + + println("TEXT 브랜드 메시지 발송 성공!") + println("Group ID: ${response.groupInfo?.groupId}") + + } catch (e: Exception) { + System.err.println("TEXT 브랜드 메시지 발송 실패: ${e.message}") + e.printStackTrace() + } +} + +/** + * IMAGE 타입 브랜드 메시지 발송 (이미지 파일 필요) + */ +private fun sendImageBrandMessage( + messageService: DefaultMessageService, + sender: String, + recipient: String, + pfId: String +) { + println("\n=== IMAGE 타입 브랜드 메시지 발송 ===") + + try { + // 이미지 파일 로드 + val imageUrl = object {}.javaClass.classLoader.getResource("images/sample.jpg") + if (imageUrl == null) { + println("Sample image not found. Skipping IMAGE type example.") + return + } + + val tempFile = File.createTempFile("bms-image", ".jpg").apply { + deleteOnExit() + } + imageUrl.openStream().use { input -> + Files.copy(input, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + + // 이미지 업로드 (BMS 스토리지 타입) + val imageId = messageService.uploadFile(tempFile, StorageType.BMS) + println("이미지 업로드 완료 - imageId: $imageId") + + // BMS 버튼 생성 + val buttons = listOf( + BmsButton( + linkType = BmsButtonType.WL, + name = "자세히 보기", + linkMobile = "https://example.com", + linkPc = "https://example.com" + ) + ) + + // BMS 옵션 설정 + val bmsOption = KakaoBmsOption( + chatBubbleType = BmsChatBubbleType.IMAGE, + imageId = imageId, + imageLink = "https://example.com/image", + content = "IMAGE 타입 브랜드 메시지입니다.", + buttons = buttons, + adult = false + ) + + val message = Message( + type = MessageType.BMS_FREE, + from = sender, + to = recipient, + kakaoOptions = KakaoOption( + pfId = pfId, + bms = bmsOption + ) + ) + + val response = messageService.send(message) + + println("IMAGE 브랜드 메시지 발송 성공!") + println("Group ID: ${response.groupInfo?.groupId}") + + } catch (e: Exception) { + System.err.println("IMAGE 브랜드 메시지 발송 실패: ${e.message}") + e.printStackTrace() + } +} diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/MainExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/MainExample.kt new file mode 100644 index 0000000..5c1178d --- /dev/null +++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/MainExample.kt @@ -0,0 +1,60 @@ +package com.solapi.example + +/** + * SOLAPI SDK 예제 메인 클래스 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 테스트 수신번호 + * + * 개별 예제 실행: + * ./gradlew :solapi-kotlin-example-kotlin:run -Pexample=SendSms + * ./gradlew :solapi-kotlin-example-kotlin:run -Pexample=GetBalance + */ +fun main() { + println("=".repeat(60)) + println("SOLAPI SDK Kotlin Examples") + println("=".repeat(60)) + println() + println("Available examples:") + println() + println(" SMS/LMS/MMS:") + println(" SendSms - SMS 단건 발송") + println(" SendMms - MMS 이미지 첨부 발송") + println(" SendBatch - 대량 메시지 발송") + println(" SendScheduled - 예약 발송") + println(" SendVoice - 음성 메시지 발송") + println() + println(" Account:") + println(" GetBalance - 잔액 조회") + println(" GetMessageList - 발송 내역 조회") + println() + println(" Kakao:") + println(" KakaoAlimtalk - 알림톡 발송") + println(" KakaoFriendTalk - 친구톡 발송") + println(" KakaoImageFriend - 이미지 친구톡 발송") + println(" KakaoBrandMessage - 브랜드 메시지 발송") + println() + println("Usage:") + println(" ./gradlew :solapi-kotlin-example-kotlin:run -Pexample=") + println() + println("Example:") + println(" ./gradlew :solapi-kotlin-example-kotlin:run -Pexample=SendSms") + println(" ./gradlew :solapi-kotlin-example-kotlin:run -Pexample=GetBalance") + println() + println("Environment variables:") + println(" SOLAPI_API_KEY - ${maskValue(System.getenv("SOLAPI_API_KEY"))}") + println(" SOLAPI_API_SECRET - ${maskValue(System.getenv("SOLAPI_API_SECRET"))}") + println(" SOLAPI_SENDER - ${System.getenv("SOLAPI_SENDER") ?: "(not set)"}") + println(" SOLAPI_RECIPIENT - ${System.getenv("SOLAPI_RECIPIENT") ?: "(not set)"}") + println(" SOLAPI_KAKAO_PF_ID - ${System.getenv("SOLAPI_KAKAO_PF_ID") ?: "(not set)"}") + println() +} + +private fun maskValue(value: String?): String { + if (value.isNullOrEmpty()) return "(not set)" + if (value.length <= 8) return "****" + return "${value.take(4)}****${value.takeLast(4)}" +} diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendBatchExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendBatchExample.kt new file mode 100644 index 0000000..331401d --- /dev/null +++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendBatchExample.kt @@ -0,0 +1,62 @@ +package com.solapi.example + +import com.solapi.sdk.SolapiClient +import com.solapi.sdk.message.dto.request.SendRequestConfig +import com.solapi.sdk.message.model.Message + +/** + * 대량 메시지 발송 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 수신번호 (테스트용으로 동일 번호 사용) + * + * 참고: + * - 한 번에 최대 10,000건까지 발송 가능 + * - allowDuplicates 옵션으로 중복 수신번호 허용 가능 + */ +fun main() { + // 환경변수에서 설정 로드 + val apiKey = System.getenv("SOLAPI_API_KEY") + ?: error("SOLAPI_API_KEY must be set") + val apiSecret = System.getenv("SOLAPI_API_SECRET") + ?: error("SOLAPI_API_SECRET must be set") + val sender = System.getenv("SOLAPI_SENDER") + ?: error("SOLAPI_SENDER must be set") + val recipient = System.getenv("SOLAPI_RECIPIENT") + ?: error("SOLAPI_RECIPIENT must be set") + + // SDK 클라이언트 생성 + val messageService = SolapiClient.createInstance(apiKey, apiSecret) + + // 여러 메시지 생성 (테스트를 위해 동일 수신자에게 발송) + val messages = (1..3).map { i -> + Message( + from = sender, + to = recipient, + text = "대량 발송 테스트 메시지 $i/3" + ) + } + + // 발송 설정 (중복 수신번호 허용) + val config = SendRequestConfig(allowDuplicates = true) + + try { + // 대량 메시지 발송 + val response = messageService.send(messages, config) + + println("대량 메시지 발송 성공!") + println("Group ID: ${response.groupInfo?.groupId}") + println("Total Count: ${response.groupInfo?.count}") + response.groupInfo?.count?.let { count -> + println(" - Total: ${count.total}") + println(" - Sent Total: ${count.sentTotal}") + } + + } catch (e: Exception) { + System.err.println("대량 메시지 발송 실패: ${e.message}") + e.printStackTrace() + } +} diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendMmsExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendMmsExample.kt new file mode 100644 index 0000000..db09300 --- /dev/null +++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendMmsExample.kt @@ -0,0 +1,77 @@ +package com.solapi.example + +import com.solapi.sdk.SolapiClient +import com.solapi.sdk.message.model.Message +import com.solapi.sdk.message.model.MessageType +import com.solapi.sdk.message.model.StorageType +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** + * MMS 이미지 첨부 발송 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 수신번호 + * + * 참고: MMS 이미지 규격 + * - 지원 포맷: JPG, JPEG + * - 최대 용량: 200KB + * - 권장 해상도: 1000x1000 이하 + */ +fun main() { + // 환경변수에서 설정 로드 + val apiKey = System.getenv("SOLAPI_API_KEY") + ?: error("SOLAPI_API_KEY must be set") + val apiSecret = System.getenv("SOLAPI_API_SECRET") + ?: error("SOLAPI_API_SECRET must be set") + val sender = System.getenv("SOLAPI_SENDER") + ?: error("SOLAPI_SENDER must be set") + val recipient = System.getenv("SOLAPI_RECIPIENT") + ?: error("SOLAPI_RECIPIENT must be set") + + // SDK 클라이언트 생성 + val messageService = SolapiClient.createInstance(apiKey, apiSecret) + + try { + // 이미지 파일 로드 (리소스에서) + val imageUrl = object {}.javaClass.classLoader.getResource("images/sample.jpg") + ?: error("Sample image not found in resources/images/sample.jpg. Please add a JPG image file (max 200KB).") + + // 임시 파일로 복사 + val tempFile = File.createTempFile("mms-image", ".jpg").apply { + deleteOnExit() + } + imageUrl.openStream().use { input -> + Files.copy(input, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + + // 이미지 업로드 + println("이미지 업로드 중...") + val imageId = messageService.uploadFile(tempFile, StorageType.MMS) + println("이미지 업로드 완료 - imageId: $imageId") + + // MMS 메시지 생성 + val message = Message( + type = MessageType.MMS, + from = sender, + to = recipient, + text = "안녕하세요. MMS 이미지 첨부 메시지입니다.", + subject = "MMS 제목", + imageId = imageId + ) + + // 메시지 발송 + val response = messageService.send(message) + + println("MMS 발송 성공!") + println("Group ID: ${response.groupInfo?.groupId}") + + } catch (e: Exception) { + System.err.println("MMS 발송 실패: ${e.message}") + e.printStackTrace() + } +} diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendScheduledExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendScheduledExample.kt new file mode 100644 index 0000000..9b5a9fd --- /dev/null +++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendScheduledExample.kt @@ -0,0 +1,66 @@ +package com.solapi.example + +import com.solapi.sdk.SolapiClient +import com.solapi.sdk.message.dto.request.SendRequestConfig +import com.solapi.sdk.message.model.Message +import java.time.LocalDateTime +import java.time.ZoneId + +/** + * 예약 발송 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 수신번호 + * + * 참고: + * - 예약 시간은 현재 시간으로부터 최소 10분 이후여야 함 + * - 최대 6개월 이내로 예약 가능 + * - 과거 시간 지정 시 즉시 발송 처리됨 + */ +fun main() { + // 환경변수에서 설정 로드 + val apiKey = System.getenv("SOLAPI_API_KEY") + ?: error("SOLAPI_API_KEY must be set") + val apiSecret = System.getenv("SOLAPI_API_SECRET") + ?: error("SOLAPI_API_SECRET must be set") + val sender = System.getenv("SOLAPI_SENDER") + ?: error("SOLAPI_SENDER must be set") + val recipient = System.getenv("SOLAPI_RECIPIENT") + ?: error("SOLAPI_RECIPIENT must be set") + + // SDK 클라이언트 생성 + val messageService = SolapiClient.createInstance(apiKey, apiSecret) + + // 메시지 생성 + val message = Message( + from = sender, + to = recipient, + text = "안녕하세요. 예약 발송 테스트 메시지입니다." + ) + + // 10분 후 예약 발송 설정 + val scheduledTime = LocalDateTime.now().plusMinutes(10) + val seoulZone = ZoneId.of("Asia/Seoul") + + val config = SendRequestConfig().apply { + setScheduledDateFromLocalDateTime(scheduledTime, seoulZone) + } + + println("예약 시간: $scheduledTime") + + try { + // 예약 메시지 발송 + val response = messageService.send(message, config) + + println("예약 발송 성공!") + println("Group ID: ${response.groupInfo?.groupId}") + println("Scheduled Date: ${response.groupInfo?.scheduledDate}") + + } catch (e: Exception) { + System.err.println("예약 발송 실패: ${e.message}") + e.printStackTrace() + } +} diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendSmsExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendSmsExample.kt new file mode 100644 index 0000000..aa99368 --- /dev/null +++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendSmsExample.kt @@ -0,0 +1,48 @@ +package com.solapi.example + +import com.solapi.sdk.SolapiClient +import com.solapi.sdk.message.model.Message + +/** + * SMS 단건 발송 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 수신번호 + */ +fun main() { + // 환경변수에서 설정 로드 + val apiKey = System.getenv("SOLAPI_API_KEY") + ?: error("SOLAPI_API_KEY must be set") + val apiSecret = System.getenv("SOLAPI_API_SECRET") + ?: error("SOLAPI_API_SECRET must be set") + val sender = System.getenv("SOLAPI_SENDER") + ?: error("SOLAPI_SENDER must be set") + val recipient = System.getenv("SOLAPI_RECIPIENT") + ?: error("SOLAPI_RECIPIENT must be set") + + // SDK 클라이언트 생성 + val messageService = SolapiClient.createInstance(apiKey, apiSecret) + + // 메시지 생성 (Kotlin data class) + val message = Message( + from = sender, + to = recipient, + text = "안녕하세요. SOLAPI SDK Kotlin 예제입니다." + ) + + try { + // 메시지 발송 + val response = messageService.send(message) + + println("SMS 발송 성공!") + println("Group ID: ${response.groupInfo?.groupId}") + println("Message Count: ${response.groupInfo?.count}") + + } catch (e: Exception) { + System.err.println("SMS 발송 실패: ${e.message}") + e.printStackTrace() + } +} diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendVoiceExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendVoiceExample.kt new file mode 100644 index 0000000..bec826f --- /dev/null +++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendVoiceExample.kt @@ -0,0 +1,62 @@ +package com.solapi.example + +import com.solapi.sdk.SolapiClient +import com.solapi.sdk.message.model.Message +import com.solapi.sdk.message.model.MessageType +import com.solapi.sdk.message.model.voice.VoiceOption +import com.solapi.sdk.message.model.voice.VoiceType + +/** + * 음성 메시지 발송 예제 + * + * 환경변수 설정: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 수신번호 + * + * 음성 메시지는 TTS(Text-to-Speech)를 통해 텍스트를 음성으로 변환하여 발송합니다. + * VoiceType: FEMALE(여성), MALE(남성) + */ +fun main() { + // 환경변수에서 설정 로드 + val apiKey = System.getenv("SOLAPI_API_KEY") + ?: error("SOLAPI_API_KEY must be set") + val apiSecret = System.getenv("SOLAPI_API_SECRET") + ?: error("SOLAPI_API_SECRET must be set") + val sender = System.getenv("SOLAPI_SENDER") + ?: error("SOLAPI_SENDER must be set") + val recipient = System.getenv("SOLAPI_RECIPIENT") + ?: error("SOLAPI_RECIPIENT must be set") + + // SDK 클라이언트 생성 + val messageService = SolapiClient.createInstance(apiKey, apiSecret) + + // 음성 옵션 설정 + val voiceOption = VoiceOption( + voiceType = VoiceType.FEMALE, // 여성 음성 + headerMessage = "안녕하세요.", // 헤더 메시지 + tailMessage = "감사합니다." // 테일 메시지 + ) + + // 음성 메시지 생성 + val message = Message( + type = MessageType.VOICE, + from = sender, + to = recipient, + text = "음성 메시지 본문입니다. 이 메시지는 TTS로 변환되어 발송됩니다.", + voiceOptions = voiceOption + ) + + try { + // 음성 메시지 발송 + val response = messageService.send(message) + + println("음성 메시지 발송 성공!") + println("Group ID: ${response.groupInfo?.groupId}") + + } catch (e: Exception) { + System.err.println("음성 메시지 발송 실패: ${e.message}") + e.printStackTrace() + } +} From 3b1ce5dc95ec8c119721abb2cd13a9a5ffa21aa0 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 10:03:34 +0900 Subject: [PATCH 28/43] docs: rewrite README with comprehensive SDK documentation Add installation instructions for Gradle and Maven, quick start examples for both Kotlin and Java, environment variables table, message types reference, main features (MMS, batch, scheduled, Kakao), API reference tables, and error handling guide. Co-Authored-By: Claude Opus 4.5 --- README.md | 275 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 259 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 35406bb..6b4fcb0 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,273 @@ -# SOLAPI Kotlin(includes Java) SDK +# SOLAPI Kotlin SDK -### 실행방법 +[![Maven Central](https://img.shields.io/maven-central/v/com.solapi/sdk)](https://central.sonatype.com/artifact/com.solapi/sdk) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) ---- +Kotlin과 Java에서 SOLAPI 메시지 발송 서비스를 사용할 수 있는 공식 SDK입니다. -### 메시지 발송 +## 설치 + +### Gradle (Kotlin DSL) + +```kotlin +dependencies { + implementation("com.solapi:sdk:1.1.0") +} +``` + +### Gradle (Groovy) + +```groovy +dependencies { + implementation 'com.solapi:sdk:1.1.0' +} +``` + +### Maven + +```xml + + com.solapi + sdk + 1.1.0 + +``` + +## 빠른 시작 + +### Kotlin + +```kotlin +import com.solapi.sdk.SolapiClient +import com.solapi.sdk.message.model.Message + +fun main() { + val messageService = SolapiClient.createInstance("API_KEY", "API_SECRET") + + val message = Message( + from = "발신번호", + to = "수신번호", + text = "안녕하세요. SOLAPI SDK 테스트입니다." + ) + + val response = messageService.send(message) + println("Group ID: ${response.groupInfo?.groupId}") +} +``` + +### Java -#### 자바 기준 ```java -DefaultMessageService solapiClient = SolapiClient.INSTANCE.createInstance("ENTER_YOUR_API_KEY", "ENTER_YOUR_API_SECRET_KEY"); -Message message = new Message(); +import com.solapi.sdk.SolapiClient; +import com.solapi.sdk.message.model.Message; +import com.solapi.sdk.message.service.DefaultMessageService; + +public class Main { + public static void main(String[] args) { + DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance("API_KEY", "API_SECRET"); + + Message message = new Message(); + message.setFrom("발신번호"); + message.setTo("수신번호"); + message.setText("안녕하세요. SOLAPI SDK 테스트입니다."); + + var response = messageService.send(message, null); + System.out.println("Group ID: " + response.getGroupInfo().getGroupId()); + } +} +``` + +## 예제 실행 + +### 환경변수 설정 + +| 환경변수 | 설명 | 필수 | +|----------|------|:----:| +| `SOLAPI_API_KEY` | SOLAPI API 키 | O | +| `SOLAPI_API_SECRET` | SOLAPI API 시크릿 | O | +| `SOLAPI_SENDER` | 등록된 발신번호 | O | +| `SOLAPI_RECIPIENT` | 수신번호 | O | +| `SOLAPI_KAKAO_PF_ID` | 카카오 비즈니스 채널 ID | 카카오 발송 시 | +| `SOLAPI_KAKAO_TEMPLATE_ID` | 카카오 알림톡 템플릿 ID | 알림톡 발송 시 | + +### 실행 명령어 + +```bash +# Kotlin 예제 실행 +./gradlew :solapi-kotlin-example-kotlin:run -Pexample=SendSms + +# Java 예제 실행 +./gradlew :solapi-kotlin-example-java:run -Pexample=SendSms +``` + +### 예제 목록 + +| 예제 | 설명 | +|------|------| +| `SendSms` | SMS 단건 발송 | +| `SendMms` | MMS 이미지 첨부 발송 | +| `SendBatch` | 대량 메시지 발송 | +| `SendScheduled` | 예약 발송 | +| `SendVoice` | 음성 메시지 발송 | +| `KakaoAlimtalk` | 카카오 알림톡 발송 | +| `KakaoBrandMessage` | 카카오 브랜드 메시지 발송 | +| `GetBalance` | 잔액 조회 | +| `GetMessageList` | 발송 내역 조회 | + +## 지원 메시지 타입 -message.setFrom("발신번호"); -message.setTo("수신번호"); -message.setText("메시지 내용"); +| 타입 | 설명 | +|------|------| +| `SMS` | 단문문자 (80 byte 미만) | +| `LMS` | 장문문자 (80 byte 이상, 2,000 byte 미만) | +| `MMS` | 이미지 포함 문자 (200KB 이내 이미지 1장) | +| `ATA` | 카카오 알림톡 | +| `CTA` / `CTI` | 카카오 친구톡 / 이미지 친구톡 | +| `BMS_*` | 카카오 브랜드 메시지 (TEXT, IMAGE, WIDE, WIDE_ITEM_LIST, FREE) | +| `RCS_*` | RCS 문자 (SMS, LMS, MMS, TPL) | +| `NSA` | 네이버 스마트 알림 | +| `FAX` | 팩스 | +| `VOICE` | 음성 메시지 | -System.out.println(solapiClient.send(message)); +## 주요 기능 + +### MMS 이미지 첨부 발송 + +```kotlin +// 이미지 업로드 +val imageId = messageService.uploadFile(imageFile, StorageType.MMS) + +// MMS 발송 +val message = Message( + type = MessageType.MMS, + from = "발신번호", + to = "수신번호", + text = "MMS 메시지 내용", + subject = "MMS 제목", + imageId = imageId +) +messageService.send(message) ``` -#### 코틀린 기준 +**이미지 규격:** JPG/JPEG, 최대 200KB, 권장 해상도 1000x1000 이하 + +### 대량 메시지 발송 + ```kotlin -val solapiClient = SolapiClient.createInstance("ENTER_YOUR_API_KEY", "ENTER_YOUR_API_SECRET_KEY") +val messages = (1..100).map { i -> + Message(from = sender, to = "010XXXX000$i", text = "메시지 $i") +} + +// 중복 수신번호 허용 옵션 +val config = SendRequestConfig(allowDuplicates = true) +messageService.send(messages, config) +``` + +- 한 번에 최대 **10,000건** 발송 가능 +- `allowDuplicates = true`로 동일 수신번호 중복 발송 허용 -val message = Message(from = "발신번호", to = "수신번호", text = "메시지 내용") +### 예약 발송 -println(solapiClient.send(message)) +```kotlin +val config = SendRequestConfig().apply { + setScheduledDateFromLocalDateTime( + LocalDateTime.now().plusMinutes(10), + ZoneId.of("Asia/Seoul") + ) +} +messageService.send(message, config) +``` + +- 최소 **10분 후**부터 최대 **6개월 이내** 예약 가능 +- 과거 시간 지정 시 즉시 발송 처리 + +### 카카오 알림톡 + +```kotlin +val message = Message( + type = MessageType.ATA, + from = "발신번호", + to = "수신번호", + kakaoOptions = KakaoOption( + pfId = "카카오채널ID", + templateId = "템플릿ID", + variables = mapOf("name" to "홍길동", "code" to "123456") + ) +) +messageService.send(message) ``` -더 자세한 사용 방법 및 예제는 [SOLAPI SDK 예제 레포지터리](https://github.com/solapi/solapi-java-examples)를 참고 해주세요. +- 검수 승인된 템플릿만 사용 가능 +- 정보성 메시지 전용 (광고 불가) + +## API 레퍼런스 + +### 메시지 발송 + +| 메서드 | 설명 | +|--------|------| +| `send(message)` | 단건 메시지 발송 | +| `send(messages)` | 다건 메시지 발송 (최대 10,000건) | +| `send(message, config)` | 설정과 함께 발송 (예약, 중복 허용 등) | +| `uploadFile(file, type)` | 파일 업로드 (MMS, FAX 등) | + +### 조회 + +| 메서드 | 설명 | +|--------|------| +| `getBalance()` | 잔액 조회 | +| `getQuota()` | 일일 발송량 한도 조회 | +| `getMessageList(request)` | 메시지 발송 내역 조회 | + +### 카카오 템플릿 관리 + +| 메서드 | 설명 | +|--------|------| +| `getKakaoAlimtalkTemplates()` | 알림톡 템플릿 목록 조회 | +| `getKakaoAlimtalkTemplate(id)` | 알림톡 템플릿 상세 조회 | +| `createKakaoAlimtalkTemplate(request)` | 알림톡 템플릿 생성 | +| `getSendableKakaoAlimtalkTemplates()` | 발송 가능한 템플릿 조회 | +| `getKakaoBrandMessageTemplates()` | 브랜드 메시지 템플릿 조회 | + +## 에러 처리 + +```kotlin +try { + messageService.send(message) +} catch (e: SolapiBadRequestException) { + println("잘못된 요청: ${e.message}") +} catch (e: SolapiInvalidApiKeyException) { + println("잘못된 API 키: ${e.message}") +} catch (e: SolapiMessageNotReceivedException) { + println("발송 실패: ${e.message}") +} catch (e: SolapiException) { + println("기타 오류: ${e.message}") +} +``` + +| 예외 클래스 | 설명 | +|-------------|------| +| `SolapiBadRequestException` | 잘못된 요청 파라미터 | +| `SolapiInvalidApiKeyException` | 유효하지 않은 API 키 | +| `SolapiApiKeyException` | API 키 관련 오류 | +| `SolapiFileUploadException` | 파일 업로드 실패 | +| `SolapiMessageNotReceivedException` | 메시지 수신 실패 | +| `SolapiEmptyResponseException` | 빈 응답 수신 | +| `SolapiUnknownException` | 알 수 없는 오류 | + +## 요구 사항 + +- **Java 8** 이상 +- **Kotlin 1.8** 이상 (Kotlin 사용 시) + +## 관련 링크 + +- [SOLAPI 공식 문서](https://docs.solapi.com/) +- [API 키 발급](https://console.solapi.com/) +- [발신번호 등록](https://console.solapi.com/senderids) +- [GitHub Issues](https://github.com/solapi/solapi-kotlin/issues) +- [API Reference (Dokka)](https://solapi.github.io/solapi-kotlin/) + +## 라이선스 + +MIT License - 자세한 내용은 [LICENSE](LICENSE) 파일을 참조하세요. From c21dbb8fe3e0f58c50f933d1bac7faaeed95730b Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 10:08:08 +0900 Subject: [PATCH 29/43] refactor: remove FriendTalk (CTA/CTI) support FriendTalk service was discontinued as of 2025/12/31. - Remove CTA/CTI from README message types table - Deprecate CTA/CTI enum values in MessageType - Remove FriendTalk menu items from example MainExample - Update comments to remove FriendTalk references - Delete KakaoFriendTalkE2ETest.kt - Remove FriendTalk helper functions from E2ETestUtils Co-Authored-By: Claude Opus 4.5 --- README.md | 21 +- .../solapi/example/GetMessageListExample.java | 2 +- .../java/com/solapi/example/MainExample.java | 2 - .../solapi/example/GetMessageListExample.kt | 2 +- .../kotlin/com/solapi/example/MainExample.kt | 2 - .../com/solapi/sdk/message/model/Message.kt | 2 +- .../solapi/sdk/message/model/MessageType.kt | 4 + .../sdk/message/model/kakao/KakaoOption.kt | 2 +- .../sdk/message/e2e/KakaoFriendTalkE2ETest.kt | 313 ------------------ .../sdk/message/e2e/lib/E2ETestUtils.kt | 46 --- 10 files changed, 18 insertions(+), 378 deletions(-) delete mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/KakaoFriendTalkE2ETest.kt diff --git a/README.md b/README.md index 6b4fcb0..df41653 100644 --- a/README.md +++ b/README.md @@ -116,18 +116,17 @@ public class Main { ## 지원 메시지 타입 -| 타입 | 설명 | -|------|------| -| `SMS` | 단문문자 (80 byte 미만) | +| 타입 | 설명 | +|------|---------------------------------| +| `SMS` | 단문문자 (80 byte 미만) | | `LMS` | 장문문자 (80 byte 이상, 2,000 byte 미만) | -| `MMS` | 이미지 포함 문자 (200KB 이내 이미지 1장) | -| `ATA` | 카카오 알림톡 | -| `CTA` / `CTI` | 카카오 친구톡 / 이미지 친구톡 | -| `BMS_*` | 카카오 브랜드 메시지 (TEXT, IMAGE, WIDE, WIDE_ITEM_LIST, FREE) | -| `RCS_*` | RCS 문자 (SMS, LMS, MMS, TPL) | -| `NSA` | 네이버 스마트 알림 | -| `FAX` | 팩스 | -| `VOICE` | 음성 메시지 | +| `MMS` | 이미지 포함 문자 (200KB 이내 이미지 1장) | +| `ATA` | 카카오 알림톡 | +| `BMS_*` | 카카오 브랜드 메시지 (템플릿, 자유형) | +| `RCS_*` | RCS 문자 (SMS, LMS, MMS, TPL) | +| `NSA` | 네이버 스마트 알림 | +| `FAX` | 팩스 | +| `VOICE` | 음성 메시지 | ## 주요 기능 diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetMessageListExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetMessageListExample.java index a5ae2c8..6faf63f 100644 --- a/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetMessageListExample.java +++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetMessageListExample.java @@ -20,7 +20,7 @@ * 다양한 필터 옵션: * - from: 발신번호 * - to: 수신번호 - * - type: 메시지 타입 (SMS, LMS, MMS, ATA, CTA 등) + * - type: 메시지 타입 (SMS, LMS, MMS, ATA 등) * - status: 메시지 상태 (PENDING, SENDING, COMPLETE, FAILED) * - startDate/endDate: 날짜 범위 */ diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/MainExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/MainExample.java index cf6926e..a6eb8a2 100644 --- a/solapi-kotlin-example-java/src/main/java/com/solapi/example/MainExample.java +++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/MainExample.java @@ -35,8 +35,6 @@ public static void main(String[] args) { System.out.println(); System.out.println(" Kakao:"); System.out.println(" KakaoAlimtalk - 알림톡 발송"); - System.out.println(" KakaoFriendTalk - 친구톡 발송"); - System.out.println(" KakaoImageFriend - 이미지 친구톡 발송"); System.out.println(" KakaoBrandMessage - 브랜드 메시지 발송"); System.out.println(); System.out.println("Usage:"); diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetMessageListExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetMessageListExample.kt index a736762..eba8ca1 100644 --- a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetMessageListExample.kt +++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetMessageListExample.kt @@ -16,7 +16,7 @@ import java.time.LocalDateTime * 다양한 필터 옵션: * - from: 발신번호 * - to: 수신번호 - * - type: 메시지 타입 (SMS, LMS, MMS, ATA, CTA 등) + * - type: 메시지 타입 (SMS, LMS, MMS, ATA 등) * - status: 메시지 상태 (PENDING, SENDING, COMPLETE, FAILED) * - startDate/endDate: 날짜 범위 */ diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/MainExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/MainExample.kt index 5c1178d..1cf922b 100644 --- a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/MainExample.kt +++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/MainExample.kt @@ -33,8 +33,6 @@ fun main() { println() println(" Kakao:") println(" KakaoAlimtalk - 알림톡 발송") - println(" KakaoFriendTalk - 친구톡 발송") - println(" KakaoImageFriend - 이미지 친구톡 발송") println(" KakaoBrandMessage - 브랜드 메시지 발송") println() println("Usage:") diff --git a/src/main/java/com/solapi/sdk/message/model/Message.kt b/src/main/java/com/solapi/sdk/message/model/Message.kt index 3b764ed..1ea423d 100644 --- a/src/main/java/com/solapi/sdk/message/model/Message.kt +++ b/src/main/java/com/solapi/sdk/message/model/Message.kt @@ -10,7 +10,7 @@ import com.solapi.sdk.message.model.voice.VoiceOption @Serializable data class Message ( /** - * 카카오 알림톡, 친구톡 발송을 위한 파라미터 + * 카카오 알림톡 발송을 위한 파라미터 */ var kakaoOptions: KakaoOption? = null, diff --git a/src/main/java/com/solapi/sdk/message/model/MessageType.kt b/src/main/java/com/solapi/sdk/message/model/MessageType.kt index 0392c70..4be880e 100644 --- a/src/main/java/com/solapi/sdk/message/model/MessageType.kt +++ b/src/main/java/com/solapi/sdk/message/model/MessageType.kt @@ -23,12 +23,16 @@ enum class MessageType { /** * 카카오 친구톡 + * @deprecated 2025/12/31 부로 지원 종료됨 */ + @Deprecated("2025/12/31 부로 지원 종료됨", level = DeprecationLevel.WARNING) CTA, /** * 이미지가 포함된 카카오 친구톡(이미지 1장 업로드 가능) + * @deprecated 2025/12/31 부로 지원 종료됨 */ + @Deprecated("2025/12/31 부로 지원 종료됨", level = DeprecationLevel.WARNING) CTI, /** diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/KakaoOption.kt b/src/main/java/com/solapi/sdk/message/model/kakao/KakaoOption.kt index ce83127..991bef2 100644 --- a/src/main/java/com/solapi/sdk/message/model/kakao/KakaoOption.kt +++ b/src/main/java/com/solapi/sdk/message/model/kakao/KakaoOption.kt @@ -39,7 +39,7 @@ data class KakaoOption( var adFlag: Boolean = false, /** - * 친구톡 버튼 + * 카카오 버튼 */ var buttons: List? = null, diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoFriendTalkE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoFriendTalkE2ETest.kt deleted file mode 100644 index 24914b9..0000000 --- a/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoFriendTalkE2ETest.kt +++ /dev/null @@ -1,313 +0,0 @@ -package com.solapi.sdk.message.e2e - -import com.solapi.sdk.message.e2e.base.BaseE2ETest -import com.solapi.sdk.message.e2e.lib.E2ETestUtils -import com.solapi.sdk.message.model.StorageType -import java.io.File -import kotlin.test.Test -import kotlin.test.assertNotNull - -/** - * 카카오 친구톡 (CTA/CTI) E2E 테스트 - * - * 친구톡은 카카오톡 채널 친구에게 발송하는 광고성 메시지입니다. - * CTA: 텍스트 친구톡 - * CTI: 이미지 친구톡 - * - * 환경변수 설정 필요: - * - SOLAPI_API_KEY: SOLAPI API 키 - * - SOLAPI_API_SECRET: SOLAPI API 시크릿 - * - SOLAPI_SENDER: 등록된 발신번호 - * - SOLAPI_RECIPIENT: 테스트 수신번호 - * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID - */ -class KakaoFriendTalkE2ETest : BaseE2ETest() { - - /** - * 카카오용 이미지 업로드 - */ - private fun uploadKakaoImage(filename: String = "test-image.png"): String? { - val imageUrl = javaClass.classLoader.getResource("images/$filename") - if (imageUrl == null) { - println("테스트 이미지가 없어 건너뜁니다: images/$filename") - return null - } - val file = File(imageUrl.toURI()) - return messageService?.uploadFile(file, StorageType.KAKAO) - } - - // ==================== CTA (텍스트 친구톡) 테스트 ==================== - - @Test - fun `친구톡 발송 - 텍스트만`() { - if (!assumeKakaoEnvironmentConfigured()) return - - // Given - val message = E2ETestUtils.createFriendTalkMessage( - from = senderNumber, - to = testPhoneNumber, - text = "[SDK 테스트] 친구톡 텍스트 메시지입니다.", - pfId = pfId!! - ) - - // When - val response = messageService!!.send(message) - - // Then - assertNotNull(response) - assertNotNull(response.groupInfo?.groupId) - println("친구톡 텍스트 발송 성공 - groupId: ${response.groupInfo?.groupId}") - } - - @Test - fun `친구톡 발송 - 웹링크 버튼`() { - if (!assumeKakaoEnvironmentConfigured()) return - - // Given - val buttons = listOf( - E2ETestUtils.createWebLinkButton( - buttonName = "바로가기", - linkMo = "https://example.com" - ) - ) - - val message = E2ETestUtils.createFriendTalkMessage( - from = senderNumber, - to = testPhoneNumber, - text = "[SDK 테스트] 웹링크 버튼 포함 친구톡입니다.", - pfId = pfId!!, - buttons = buttons - ) - - // When - val response = messageService!!.send(message) - - // Then - assertNotNull(response) - assertNotNull(response.groupInfo?.groupId) - println("친구톡 웹링크 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") - } - - @Test - fun `친구톡 발송 - 앱링크 버튼`() { - if (!assumeKakaoEnvironmentConfigured()) return - - // Given - val buttons = listOf( - E2ETestUtils.createAppLinkButton( - buttonName = "앱 열기", - linkAnd = "intent://main#Intent;scheme=example;package=com.example;end", - linkIos = "exampleapp://main" - ) - ) - - val message = E2ETestUtils.createFriendTalkMessage( - from = senderNumber, - to = testPhoneNumber, - text = "[SDK 테스트] 앱링크 버튼 포함 친구톡입니다.", - pfId = pfId!!, - buttons = buttons - ) - - // When - val response = messageService!!.send(message) - - // Then - assertNotNull(response) - assertNotNull(response.groupInfo?.groupId) - println("친구톡 앱링크 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") - } - - @Test - fun `친구톡 발송 - 봇키워드 버튼`() { - if (!assumeKakaoEnvironmentConfigured()) return - - // Given - val buttons = listOf( - E2ETestUtils.createBotKeywordButton(buttonName = "문의하기") - ) - - val message = E2ETestUtils.createFriendTalkMessage( - from = senderNumber, - to = testPhoneNumber, - text = "[SDK 테스트] 봇키워드 버튼 포함 친구톡입니다.", - pfId = pfId!!, - buttons = buttons - ) - - // When - val response = messageService!!.send(message) - - // Then - assertNotNull(response) - assertNotNull(response.groupInfo?.groupId) - println("친구톡 봇키워드 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") - } - - @Test - fun `친구톡 발송 - 메시지전달 버튼`() { - if (!assumeKakaoEnvironmentConfigured()) return - - // Given - val buttons = listOf( - E2ETestUtils.createMessageDeliveryButton(buttonName = "전달하기") - ) - - val message = E2ETestUtils.createFriendTalkMessage( - from = senderNumber, - to = testPhoneNumber, - text = "[SDK 테스트] 메시지전달 버튼 포함 친구톡입니다.", - pfId = pfId!!, - buttons = buttons - ) - - // When - val response = messageService!!.send(message) - - // Then - assertNotNull(response) - assertNotNull(response.groupInfo?.groupId) - println("친구톡 메시지전달 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") - } - - @Test - fun `친구톡 발송 - 광고 플래그`() { - if (!assumeKakaoEnvironmentConfigured()) return - - // Given - adFlag = true - val message = E2ETestUtils.createFriendTalkMessage( - from = senderNumber, - to = testPhoneNumber, - text = "[SDK 테스트] 광고성 친구톡입니다.", - pfId = pfId!!, - adFlag = true - ) - - // When - val response = messageService!!.send(message) - - // Then - assertNotNull(response) - assertNotNull(response.groupInfo?.groupId) - println("친구톡 광고 플래그 발송 성공 - groupId: ${response.groupInfo?.groupId}") - } - - @Test - fun `친구톡 발송 - 다중 버튼`() { - if (!assumeKakaoEnvironmentConfigured()) return - - // Given - 여러 버튼 조합 - val buttons = listOf( - E2ETestUtils.createWebLinkButton("홈페이지", "https://example.com"), - E2ETestUtils.createBotKeywordButton("상담 요청") - ) - - val message = E2ETestUtils.createFriendTalkMessage( - from = senderNumber, - to = testPhoneNumber, - text = "[SDK 테스트] 다중 버튼 친구톡입니다.", - pfId = pfId!!, - buttons = buttons - ) - - // When - val response = messageService!!.send(message) - - // Then - assertNotNull(response) - assertNotNull(response.groupInfo?.groupId) - println("친구톡 다중 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") - } - - // ==================== CTI (이미지 친구톡) 테스트 ==================== - - @Test - fun `친구톡 이미지 발송`() { - if (!assumeKakaoEnvironmentConfigured()) return - - // Given - val imageId = uploadKakaoImage() - if (imageId == null) { - println("이미지 업로드 실패로 테스트 건너뜀") - return - } - - val message = E2ETestUtils.createFriendTalkImageMessage( - from = senderNumber, - to = testPhoneNumber, - text = "[SDK 테스트] 이미지 친구톡입니다.", - pfId = pfId!!, - imageId = imageId - ) - - // When - val response = messageService!!.send(message) - - // Then - assertNotNull(response) - assertNotNull(response.groupInfo?.groupId) - println("친구톡 이미지 발송 성공 - groupId: ${response.groupInfo?.groupId}") - } - - @Test - fun `친구톡 이미지 발송 - 버튼 포함`() { - if (!assumeKakaoEnvironmentConfigured()) return - - // Given - val imageId = uploadKakaoImage() - if (imageId == null) { - println("이미지 업로드 실패로 테스트 건너뜀") - return - } - - val buttons = listOf( - E2ETestUtils.createWebLinkButton("자세히 보기", "https://example.com") - ) - - val message = E2ETestUtils.createFriendTalkImageMessage( - from = senderNumber, - to = testPhoneNumber, - text = "[SDK 테스트] 버튼 포함 이미지 친구톡입니다.", - pfId = pfId!!, - imageId = imageId, - buttons = buttons - ) - - // When - val response = messageService!!.send(message) - - // Then - assertNotNull(response) - assertNotNull(response.groupInfo?.groupId) - println("친구톡 이미지+버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") - } - - @Test - fun `친구톡 이미지 발송 - 광고 플래그`() { - if (!assumeKakaoEnvironmentConfigured()) return - - // Given - val imageId = uploadKakaoImage() - if (imageId == null) { - println("이미지 업로드 실패로 테스트 건너뜀") - return - } - - val message = E2ETestUtils.createFriendTalkImageMessage( - from = senderNumber, - to = testPhoneNumber, - text = "[SDK 테스트] 광고성 이미지 친구톡입니다.", - pfId = pfId!!, - imageId = imageId, - adFlag = true - ) - - // When - val response = messageService!!.send(message) - - // Then - assertNotNull(response) - assertNotNull(response.groupInfo?.groupId) - println("친구톡 이미지 광고 발송 성공 - groupId: ${response.groupInfo?.groupId}") - } -} diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/lib/E2ETestUtils.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/lib/E2ETestUtils.kt index 5ee8541..2f5237d 100644 --- a/src/test/kotlin/com/solapi/sdk/message/e2e/lib/E2ETestUtils.kt +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/lib/E2ETestUtils.kt @@ -105,52 +105,6 @@ object E2ETestUtils { ) ) - /** - * 친구톡 메시지 생성 (텍스트만) - */ - fun createFriendTalkMessage( - from: String, - to: String, - text: String = "[SDK 테스트] 친구톡 메시지입니다.", - pfId: String, - buttons: List? = null, - adFlag: Boolean = false - ): Message = Message( - type = MessageType.CTA, - from = from, - to = to, - text = text, - kakaoOptions = KakaoOption( - pfId = pfId, - buttons = buttons, - adFlag = adFlag - ) - ) - - /** - * 친구톡 이미지 메시지 생성 - */ - fun createFriendTalkImageMessage( - from: String, - to: String, - text: String = "[SDK 테스트] 친구톡 이미지 메시지입니다.", - pfId: String, - imageId: String, - buttons: List? = null, - adFlag: Boolean = false - ): Message = Message( - type = MessageType.CTI, - from = from, - to = to, - text = text, - kakaoOptions = KakaoOption( - pfId = pfId, - imageId = imageId, - buttons = buttons, - adFlag = adFlag - ) - ) - // ==================== 음성 메시지 팩토리 ==================== /** From f3650635b8bf3bb8e432fbfb58906027ee14586c Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 10:10:02 +0900 Subject: [PATCH 30/43] test: restore FriendTalk E2E tests for backward compatibility Keep E2E tests to verify backward compatibility for existing users who may still use the deprecated CTA/CTI message types. Co-Authored-By: Claude Opus 4.5 --- .../sdk/message/e2e/KakaoFriendTalkE2ETest.kt | 313 ++++++++++++++++++ .../sdk/message/e2e/lib/E2ETestUtils.kt | 46 +++ 2 files changed, 359 insertions(+) create mode 100644 src/test/kotlin/com/solapi/sdk/message/e2e/KakaoFriendTalkE2ETest.kt diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoFriendTalkE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoFriendTalkE2ETest.kt new file mode 100644 index 0000000..24914b9 --- /dev/null +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoFriendTalkE2ETest.kt @@ -0,0 +1,313 @@ +package com.solapi.sdk.message.e2e + +import com.solapi.sdk.message.e2e.base.BaseE2ETest +import com.solapi.sdk.message.e2e.lib.E2ETestUtils +import com.solapi.sdk.message.model.StorageType +import java.io.File +import kotlin.test.Test +import kotlin.test.assertNotNull + +/** + * 카카오 친구톡 (CTA/CTI) E2E 테스트 + * + * 친구톡은 카카오톡 채널 친구에게 발송하는 광고성 메시지입니다. + * CTA: 텍스트 친구톡 + * CTI: 이미지 친구톡 + * + * 환경변수 설정 필요: + * - SOLAPI_API_KEY: SOLAPI API 키 + * - SOLAPI_API_SECRET: SOLAPI API 시크릿 + * - SOLAPI_SENDER: 등록된 발신번호 + * - SOLAPI_RECIPIENT: 테스트 수신번호 + * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID + */ +class KakaoFriendTalkE2ETest : BaseE2ETest() { + + /** + * 카카오용 이미지 업로드 + */ + private fun uploadKakaoImage(filename: String = "test-image.png"): String? { + val imageUrl = javaClass.classLoader.getResource("images/$filename") + if (imageUrl == null) { + println("테스트 이미지가 없어 건너뜁니다: images/$filename") + return null + } + val file = File(imageUrl.toURI()) + return messageService?.uploadFile(file, StorageType.KAKAO) + } + + // ==================== CTA (텍스트 친구톡) 테스트 ==================== + + @Test + fun `친구톡 발송 - 텍스트만`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val message = E2ETestUtils.createFriendTalkMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 친구톡 텍스트 메시지입니다.", + pfId = pfId!! + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 텍스트 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 발송 - 웹링크 버튼`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val buttons = listOf( + E2ETestUtils.createWebLinkButton( + buttonName = "바로가기", + linkMo = "https://example.com" + ) + ) + + val message = E2ETestUtils.createFriendTalkMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 웹링크 버튼 포함 친구톡입니다.", + pfId = pfId!!, + buttons = buttons + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 웹링크 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 발송 - 앱링크 버튼`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val buttons = listOf( + E2ETestUtils.createAppLinkButton( + buttonName = "앱 열기", + linkAnd = "intent://main#Intent;scheme=example;package=com.example;end", + linkIos = "exampleapp://main" + ) + ) + + val message = E2ETestUtils.createFriendTalkMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 앱링크 버튼 포함 친구톡입니다.", + pfId = pfId!!, + buttons = buttons + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 앱링크 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 발송 - 봇키워드 버튼`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val buttons = listOf( + E2ETestUtils.createBotKeywordButton(buttonName = "문의하기") + ) + + val message = E2ETestUtils.createFriendTalkMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 봇키워드 버튼 포함 친구톡입니다.", + pfId = pfId!!, + buttons = buttons + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 봇키워드 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 발송 - 메시지전달 버튼`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val buttons = listOf( + E2ETestUtils.createMessageDeliveryButton(buttonName = "전달하기") + ) + + val message = E2ETestUtils.createFriendTalkMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 메시지전달 버튼 포함 친구톡입니다.", + pfId = pfId!!, + buttons = buttons + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 메시지전달 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 발송 - 광고 플래그`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given - adFlag = true + val message = E2ETestUtils.createFriendTalkMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 광고성 친구톡입니다.", + pfId = pfId!!, + adFlag = true + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 광고 플래그 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 발송 - 다중 버튼`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given - 여러 버튼 조합 + val buttons = listOf( + E2ETestUtils.createWebLinkButton("홈페이지", "https://example.com"), + E2ETestUtils.createBotKeywordButton("상담 요청") + ) + + val message = E2ETestUtils.createFriendTalkMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 다중 버튼 친구톡입니다.", + pfId = pfId!!, + buttons = buttons + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 다중 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + // ==================== CTI (이미지 친구톡) 테스트 ==================== + + @Test + fun `친구톡 이미지 발송`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val imageId = uploadKakaoImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val message = E2ETestUtils.createFriendTalkImageMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 이미지 친구톡입니다.", + pfId = pfId!!, + imageId = imageId + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 이미지 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 이미지 발송 - 버튼 포함`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val imageId = uploadKakaoImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val buttons = listOf( + E2ETestUtils.createWebLinkButton("자세히 보기", "https://example.com") + ) + + val message = E2ETestUtils.createFriendTalkImageMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 버튼 포함 이미지 친구톡입니다.", + pfId = pfId!!, + imageId = imageId, + buttons = buttons + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 이미지+버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } + + @Test + fun `친구톡 이미지 발송 - 광고 플래그`() { + if (!assumeKakaoEnvironmentConfigured()) return + + // Given + val imageId = uploadKakaoImage() + if (imageId == null) { + println("이미지 업로드 실패로 테스트 건너뜀") + return + } + + val message = E2ETestUtils.createFriendTalkImageMessage( + from = senderNumber, + to = testPhoneNumber, + text = "[SDK 테스트] 광고성 이미지 친구톡입니다.", + pfId = pfId!!, + imageId = imageId, + adFlag = true + ) + + // When + val response = messageService!!.send(message) + + // Then + assertNotNull(response) + assertNotNull(response.groupInfo?.groupId) + println("친구톡 이미지 광고 발송 성공 - groupId: ${response.groupInfo?.groupId}") + } +} diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/lib/E2ETestUtils.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/lib/E2ETestUtils.kt index 2f5237d..5ee8541 100644 --- a/src/test/kotlin/com/solapi/sdk/message/e2e/lib/E2ETestUtils.kt +++ b/src/test/kotlin/com/solapi/sdk/message/e2e/lib/E2ETestUtils.kt @@ -105,6 +105,52 @@ object E2ETestUtils { ) ) + /** + * 친구톡 메시지 생성 (텍스트만) + */ + fun createFriendTalkMessage( + from: String, + to: String, + text: String = "[SDK 테스트] 친구톡 메시지입니다.", + pfId: String, + buttons: List? = null, + adFlag: Boolean = false + ): Message = Message( + type = MessageType.CTA, + from = from, + to = to, + text = text, + kakaoOptions = KakaoOption( + pfId = pfId, + buttons = buttons, + adFlag = adFlag + ) + ) + + /** + * 친구톡 이미지 메시지 생성 + */ + fun createFriendTalkImageMessage( + from: String, + to: String, + text: String = "[SDK 테스트] 친구톡 이미지 메시지입니다.", + pfId: String, + imageId: String, + buttons: List? = null, + adFlag: Boolean = false + ): Message = Message( + type = MessageType.CTI, + from = from, + to = to, + text = text, + kakaoOptions = KakaoOption( + pfId = pfId, + imageId = imageId, + buttons = buttons, + adFlag = adFlag + ) + ) + // ==================== 음성 메시지 팩토리 ==================== /** From 514528e5cdd0ebef2f7dec22682eefb7a1e7f415 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 10:41:00 +0900 Subject: [PATCH 31/43] docs: prioritize Java examples in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대부분의 SDK 사용자가 Java를 사용하므로 README의 모든 코드 예제를 Java 우선으로 변경: - 빠른 시작 섹션: Java 예제를 Kotlin보다 먼저 배치 - 실행 명령어: Java 예제 명령어를 먼저 배치 - 주요 기능 섹션: 모든 Kotlin 예제를 Java로 변환 - MMS 이미지 첨부 발송 - 대량 메시지 발송 - 예약 발송 - 카카오 알림톡 - 에러 처리 섹션: Kotlin 예제를 Java로 변환 Co-Authored-By: Claude Opus 4.5 --- README.md | 155 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 81 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index df41653..6ace22c 100644 --- a/README.md +++ b/README.md @@ -35,26 +35,6 @@ dependencies { ## 빠른 시작 -### Kotlin - -```kotlin -import com.solapi.sdk.SolapiClient -import com.solapi.sdk.message.model.Message - -fun main() { - val messageService = SolapiClient.createInstance("API_KEY", "API_SECRET") - - val message = Message( - from = "발신번호", - to = "수신번호", - text = "안녕하세요. SOLAPI SDK 테스트입니다." - ) - - val response = messageService.send(message) - println("Group ID: ${response.groupInfo?.groupId}") -} -``` - ### Java ```java @@ -77,6 +57,26 @@ public class Main { } ``` +### Kotlin + +```kotlin +import com.solapi.sdk.SolapiClient +import com.solapi.sdk.message.model.Message + +fun main() { + val messageService = SolapiClient.createInstance("API_KEY", "API_SECRET") + + val message = Message( + from = "발신번호", + to = "수신번호", + text = "안녕하세요. SOLAPI SDK 테스트입니다." + ) + + val response = messageService.send(message) + println("Group ID: ${response.groupInfo?.groupId}") +} +``` + ## 예제 실행 ### 환경변수 설정 @@ -93,11 +93,11 @@ public class Main { ### 실행 명령어 ```bash -# Kotlin 예제 실행 -./gradlew :solapi-kotlin-example-kotlin:run -Pexample=SendSms - # Java 예제 실행 ./gradlew :solapi-kotlin-example-java:run -Pexample=SendSms + +# Kotlin 예제 실행 +./gradlew :solapi-kotlin-example-kotlin:run -Pexample=SendSms ``` ### 예제 목록 @@ -132,34 +132,39 @@ public class Main { ### MMS 이미지 첨부 발송 -```kotlin +```java // 이미지 업로드 -val imageId = messageService.uploadFile(imageFile, StorageType.MMS) - -// MMS 발송 -val message = Message( - type = MessageType.MMS, - from = "발신번호", - to = "수신번호", - text = "MMS 메시지 내용", - subject = "MMS 제목", - imageId = imageId -) -messageService.send(message) +String imageId = messageService.uploadFile(imageFile, StorageType.MMS, null); + +// MMS 메시지 생성 및 발송 +Message message = new Message(); +message.setType(MessageType.MMS); +message.setFrom("발신번호"); +message.setTo("수신번호"); +message.setText("MMS 메시지 내용"); +message.setSubject("MMS 제목"); +message.setImageId(imageId); +messageService.send(message, null); ``` **이미지 규격:** JPG/JPEG, 최대 200KB, 권장 해상도 1000x1000 이하 ### 대량 메시지 발송 -```kotlin -val messages = (1..100).map { i -> - Message(from = sender, to = "010XXXX000$i", text = "메시지 $i") +```java +List messages = new ArrayList<>(); +for (int i = 1; i <= 100; i++) { + Message msg = new Message(); + msg.setFrom(sender); + msg.setTo("010XXXX000" + i); + msg.setText("메시지 " + i); + messages.add(msg); } // 중복 수신번호 허용 옵션 -val config = SendRequestConfig(allowDuplicates = true) -messageService.send(messages, config) +SendRequestConfig config = new SendRequestConfig(); +config.setAllowDuplicates(true); +messageService.send(messages, config); ``` - 한 번에 최대 **10,000건** 발송 가능 @@ -167,14 +172,13 @@ messageService.send(messages, config) ### 예약 발송 -```kotlin -val config = SendRequestConfig().apply { - setScheduledDateFromLocalDateTime( - LocalDateTime.now().plusMinutes(10), - ZoneId.of("Asia/Seoul") - ) -} -messageService.send(message, config) +```java +SendRequestConfig config = new SendRequestConfig(); +config.setScheduledDateFromLocalDateTime( + LocalDateTime.now().plusMinutes(10), + ZoneId.of("Asia/Seoul") +); +messageService.send(message, config); ``` - 최소 **10분 후**부터 최대 **6개월 이내** 예약 가능 @@ -182,18 +186,22 @@ messageService.send(message, config) ### 카카오 알림톡 -```kotlin -val message = Message( - type = MessageType.ATA, - from = "발신번호", - to = "수신번호", - kakaoOptions = KakaoOption( - pfId = "카카오채널ID", - templateId = "템플릿ID", - variables = mapOf("name" to "홍길동", "code" to "123456") - ) -) -messageService.send(message) +```java +Map variables = new HashMap<>(); +variables.put("name", "홍길동"); +variables.put("code", "123456"); + +KakaoOption kakaoOption = new KakaoOption(); +kakaoOption.setPfId("카카오채널ID"); +kakaoOption.setTemplateId("템플릿ID"); +kakaoOption.setVariables(variables); + +Message message = new Message(); +message.setType(MessageType.ATA); +message.setFrom("발신번호"); +message.setTo("수신번호"); +message.setKakaoOptions(kakaoOption); +messageService.send(message, null); ``` - 검수 승인된 템플릿만 사용 가능 @@ -230,17 +238,17 @@ messageService.send(message) ## 에러 처리 -```kotlin +```java try { - messageService.send(message) -} catch (e: SolapiBadRequestException) { - println("잘못된 요청: ${e.message}") -} catch (e: SolapiInvalidApiKeyException) { - println("잘못된 API 키: ${e.message}") -} catch (e: SolapiMessageNotReceivedException) { - println("발송 실패: ${e.message}") -} catch (e: SolapiException) { - println("기타 오류: ${e.message}") + messageService.send(message, null); +} catch (SolapiBadRequestException e) { + System.out.println("잘못된 요청: " + e.getMessage()); +} catch (SolapiInvalidApiKeyException e) { + System.out.println("잘못된 API 키: " + e.getMessage()); +} catch (SolapiMessageNotReceivedException e) { + System.out.println("발송 실패: " + e.getMessage()); +} catch (SolapiException e) { + System.out.println("기타 오류: " + e.getMessage()); } ``` @@ -261,10 +269,9 @@ try { ## 관련 링크 -- [SOLAPI 공식 문서](https://docs.solapi.com/) -- [API 키 발급](https://console.solapi.com/) +- [SOLAPI 공식 문서](https://developers.solapi.com/) +- [API 키 발급](https://console.solapi.com/credentials) - [발신번호 등록](https://console.solapi.com/senderids) -- [GitHub Issues](https://github.com/solapi/solapi-kotlin/issues) - [API Reference (Dokka)](https://solapi.github.io/solapi-kotlin/) ## 라이선스 From b260cca6e1179239b2fbbccb28e91e4d8cc778e3 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 10:44:40 +0900 Subject: [PATCH 32/43] docs: add Java to README title Co-Authored-By: Claude Opus 4.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ace22c..5ddad21 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# SOLAPI Kotlin SDK +# SOLAPI Kotlin/Java SDK [![Maven Central](https://img.shields.io/maven-central/v/com.solapi/sdk)](https://central.sonatype.com/artifact/com.solapi/sdk) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) From 13cd5932844a0f4cdaa9b10e85e10eb199d3be08 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 10:46:43 +0900 Subject: [PATCH 33/43] docs: update environment variables table formatting and description Improve table column alignment and clarify that SOLAPI_KAKAO_PF_ID is required for all Kakao message types, not just specific ones. Co-Authored-By: Claude Opus 4.5 --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5ddad21..74f9ad1 100644 --- a/README.md +++ b/README.md @@ -81,14 +81,14 @@ fun main() { ### 환경변수 설정 -| 환경변수 | 설명 | 필수 | -|----------|------|:----:| -| `SOLAPI_API_KEY` | SOLAPI API 키 | O | -| `SOLAPI_API_SECRET` | SOLAPI API 시크릿 | O | -| `SOLAPI_SENDER` | 등록된 발신번호 | O | -| `SOLAPI_RECIPIENT` | 수신번호 | O | -| `SOLAPI_KAKAO_PF_ID` | 카카오 비즈니스 채널 ID | 카카오 발송 시 | -| `SOLAPI_KAKAO_TEMPLATE_ID` | 카카오 알림톡 템플릿 ID | 알림톡 발송 시 | +| 환경변수 | 설명 | 필수 | +|----------|------|:---------------:| +| `SOLAPI_API_KEY` | SOLAPI API 키 | O | +| `SOLAPI_API_SECRET` | SOLAPI API 시크릿 | O | +| `SOLAPI_SENDER` | 등록된 발신번호 | O | +| `SOLAPI_RECIPIENT` | 수신번호 | O | +| `SOLAPI_KAKAO_PF_ID` | 카카오 비즈니스 채널 ID | 카카오 계열 메시지 발송 시 | +| `SOLAPI_KAKAO_TEMPLATE_ID` | 카카오 알림톡 템플릿 ID | 알림톡 발송 시 | ### 실행 명령어 From 9cddef54d5e1272abc46f6d8cd7bed1f4a2edc5c Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 10:56:48 +0900 Subject: [PATCH 34/43] docs: add JDK 8 user guide and improve code examples - Add comprehensive JDK 8 user guide section explaining SDK vs example runtime requirements - Add explicit import statement for MultipleDetailMessageSentResponse - Replace var with explicit type declaration for Java 8 compatibility - Add "Java:" labels to advanced usage code examples for clarity Co-Authored-By: Claude Opus 4.5 --- README.md | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 74f9ad1..95ac25f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ dependencies { ```java import com.solapi.sdk.SolapiClient; +import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse; import com.solapi.sdk.message.model.Message; import com.solapi.sdk.message.service.DefaultMessageService; @@ -51,7 +52,7 @@ public class Main { message.setTo("수신번호"); message.setText("안녕하세요. SOLAPI SDK 테스트입니다."); - var response = messageService.send(message, null); + MultipleDetailMessageSentResponse response = messageService.send(message, null); System.out.println("Group ID: " + response.getGroupInfo().getGroupId()); } } @@ -100,6 +101,74 @@ fun main() { ./gradlew :solapi-kotlin-example-kotlin:run -Pexample=SendSms ``` +### JDK 8 사용자 안내 + +**SDK 사용 시**: 본 SDK는 JDK 8 이상에서 정상 동작합니다. Maven Central에서 의존성을 추가하면 JDK 8 환경에서 바로 사용 가능합니다. + +**예제 실행 시**: 이 저장소의 예제를 직접 실행하려면 Gradle 9.x가 필요하며, 이는 **JDK 21 이상**을 요구합니다. + +JDK 8만 설치된 환경에서 예제를 실행하려면: + +#### 방법 1: 별도의 JDK 21+ 설치 (권장) + +Gradle Toolchain이 자동으로 JDK 8을 다운로드하여 예제를 컴파일합니다. Gradle 실행용으로만 JDK 21 이상이 필요합니다. + +```bash +# macOS (Homebrew) +brew install openjdk@21 + +# Ubuntu/Debian +sudo apt install openjdk-21-jdk + +# SDKMAN (권장) +sdk install java 21.0.2-tem +``` + +#### 방법 2: 자신의 프로젝트에서 직접 테스트 + +JDK 8 환경의 자체 프로젝트에서 SDK를 추가하고 테스트할 수 있습니다: + +```java +// build.gradle (Groovy) +plugins { + id 'java' +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + implementation 'com.solapi:sdk:1.1.0' +} +``` + +```java +// src/main/java/MyTest.java +import com.solapi.sdk.SolapiClient; +import com.solapi.sdk.message.model.Message; +import com.solapi.sdk.message.service.DefaultMessageService; + +public class MyTest { + public static void main(String[] args) { + DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance( + "YOUR_API_KEY", + "YOUR_API_SECRET" + ); + + Message message = new Message(); + message.setFrom("발신번호"); + message.setTo("수신번호"); + message.setText("테스트 메시지"); + + messageService.send(message, null); + } +} +``` + +> **참고**: Gradle 7.x (JDK 11+) 또는 Gradle 6.x (JDK 8+)를 사용하는 프로젝트에서는 위 설정으로 바로 사용 가능합니다. + ### 예제 목록 | 예제 | 설명 | @@ -132,6 +201,7 @@ fun main() { ### MMS 이미지 첨부 발송 +**Java:** ```java // 이미지 업로드 String imageId = messageService.uploadFile(imageFile, StorageType.MMS, null); @@ -151,6 +221,7 @@ messageService.send(message, null); ### 대량 메시지 발송 +**Java:** ```java List messages = new ArrayList<>(); for (int i = 1; i <= 100; i++) { @@ -172,6 +243,7 @@ messageService.send(messages, config); ### 예약 발송 +**Java:** ```java SendRequestConfig config = new SendRequestConfig(); config.setScheduledDateFromLocalDateTime( @@ -186,6 +258,7 @@ messageService.send(message, config); ### 카카오 알림톡 +**Java:** ```java Map variables = new HashMap<>(); variables.put("name", "홍길동"); From 83d6ce880c1fa4177b0e232346830e6e2eeef5be Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 11:34:43 +0900 Subject: [PATCH 35/43] docs: add AI quick start guide section to README Add a new section for AI coding tool users (Claude Code, Cursor, Windsurf) with a copy-paste prompt and examples of tasks AI can help with. Co-Authored-By: Claude Opus 4.5 --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 95ac25f..da6264d 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,23 @@ Kotlin과 Java에서 SOLAPI 메시지 발송 서비스를 사용할 수 있는 공식 SDK입니다. +## AI와 함께 시작하기 + +Claude Code, Cursor, Windsurf 등 AI 코딩 도구를 사용하신다면, 아래 프롬프트를 AI에게 붙여넣기하세요. + +> SOLAPI SDK(`com.solapi:sdk:1.1.0`)로 문자 메시지를 발송하는 기능을 구현해줘. +> 참고 문서: https://developers.solapi.com/ + +### AI가 도와줄 수 있는 작업 + +| 요청 예시 | 설명 | +|-----------|------| +| "SMS 발송 코드 작성해줘" | 단문 문자 발송 | +| "카카오 알림톡 발송 구현해줘" | 알림톡 템플릿 발송 | +| "대량 문자 발송 기능 추가해줘" | 최대 10,000건 일괄 발송 | +| "예약 발송 구현해줘" | 특정 시간에 발송 예약 | +| "발송 결과 조회 코드 작성해줘" | 메시지 상태 확인 | + ## 설치 ### Gradle (Kotlin DSL) From ce638aa8a806d338b434061182a8925afd55be30 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 11:41:15 +0900 Subject: [PATCH 36/43] Update README.md --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index da6264d..8caab35 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,10 @@ Kotlin과 Java에서 SOLAPI 메시지 발송 서비스를 사용할 수 있는 Claude Code, Cursor, Windsurf 등 AI 코딩 도구를 사용하신다면, 아래 프롬프트를 AI에게 붙여넣기하세요. -> SOLAPI SDK(`com.solapi:sdk:1.1.0`)로 문자 메시지를 발송하는 기능을 구현해줘. -> 참고 문서: https://developers.solapi.com/ +```text +SOLAPI SDK(`com.solapi:sdk:1.1.0`)로 문자 메시지를 발송하는 기능을 구현해줘. +참고 문서: https://developers.solapi.com/ +``` ### AI가 도와줄 수 있는 작업 From f263440d378fcd97684780f47496161b5118bfc5 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 11:42:23 +0900 Subject: [PATCH 37/43] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8caab35..bb323d4 100644 --- a/README.md +++ b/README.md @@ -361,7 +361,7 @@ try { ## 관련 링크 -- [SOLAPI 공식 문서](https://developers.solapi.com/) +- [SOLAPI 개발연동 문서](https://developers.solapi.com) - [API 키 발급](https://console.solapi.com/credentials) - [발신번호 등록](https://console.solapi.com/senderids) - [API Reference (Dokka)](https://solapi.github.io/solapi-kotlin/) From c5aa9f8e08218bf88910e7653305690fb21f96c5 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 12:47:50 +0900 Subject: [PATCH 38/43] docs: add LLM guide and version sync hook - Create LLM_GUIDE.md for AI agents with SDK quick reference - Update README AI section with raw GitHub URL for direct fetch - Add Claude hook to auto-sync version from build.gradle.kts to docs Co-Authored-By: Claude Opus 4.5 --- .claude/scripts/sync-version.sh | 43 +++++++ LLM_GUIDE.md | 207 ++++++++++++++++++++++++++++++++ README.md | 25 ++-- 3 files changed, 264 insertions(+), 11 deletions(-) create mode 100755 .claude/scripts/sync-version.sh create mode 100644 LLM_GUIDE.md diff --git a/.claude/scripts/sync-version.sh b/.claude/scripts/sync-version.sh new file mode 100755 index 0000000..733c1e2 --- /dev/null +++ b/.claude/scripts/sync-version.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# 버전 동기화 스크립트 +# build.gradle.kts의 버전이 변경되면 README.md와 LLM_GUIDE.md에 반영 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +GRADLE_FILE="$PROJECT_ROOT/build.gradle.kts" +README_FILE="$PROJECT_ROOT/README.md" +LLM_GUIDE_FILE="$PROJECT_ROOT/LLM_GUIDE.md" + +# build.gradle.kts가 수정된 경우에만 실행 +if [[ -n "$CLAUDE_FILE_PATHS" ]]; then + if ! echo "$CLAUDE_FILE_PATHS" | grep -q "build.gradle.kts"; then + exit 0 + fi +fi + +# build.gradle.kts에서 버전 추출 (예: version = "1.1.0" -> 1.1.0) +VERSION=$(grep -E '^version\s*=' "$GRADLE_FILE" | head -1 | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/') + +if [[ -z "$VERSION" ]] || [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "유효한 버전을 찾을 수 없습니다: $VERSION" + exit 1 +fi + +# README.md 버전 업데이트 +if [[ -f "$README_FILE" ]]; then + # com.solapi:sdk:X.X.X 패턴 업데이트 + sed -i '' -E "s/(com\.solapi:sdk:)[0-9]+\.[0-9]+\.[0-9]+/\1$VERSION/g" "$README_FILE" + # X.X.X 패턴 + sed -i '' -E "s|()[0-9]+\.[0-9]+\.[0-9]+()|\1$VERSION\2|g" "$README_FILE" +fi + +# LLM_GUIDE.md 버전 업데이트 +if [[ -f "$LLM_GUIDE_FILE" ]]; then + sed -i '' -E "s/(com\.solapi:sdk:)[0-9]+\.[0-9]+\.[0-9]+/\1$VERSION/g" "$LLM_GUIDE_FILE" + sed -i '' -E "s|()[0-9]+\.[0-9]+\.[0-9]+()|\1$VERSION\2|g" "$LLM_GUIDE_FILE" +fi + +echo "버전 $VERSION 동기화 완료" diff --git a/LLM_GUIDE.md b/LLM_GUIDE.md new file mode 100644 index 0000000..bfd6e5a --- /dev/null +++ b/LLM_GUIDE.md @@ -0,0 +1,207 @@ +# SOLAPI Kotlin SDK - LLM Guide + +SDK를 사용하여 코드를 작성하는 LLM 에이전트용 기술 가이드. + +## Quick Reference + +| 항목 | 값 | +|------|-----| +| SDK | `com.solapi:sdk:1.1.0` | +| Docs | https://developers.solapi.com/llms.txt | +| API Ref | https://solapi.github.io/solapi-kotlin/ | +| Java | 8+ | +| Kotlin | 1.8+ | + +## Setup + +**Gradle (Kotlin DSL):** +```kotlin +implementation("com.solapi:sdk:1.1.0") +``` + +**Maven:** +```xml + + com.solapi + sdk + 1.1.0 + +``` + +## Core Pattern + +**Kotlin:** +```kotlin +val service = SolapiClient.createInstance("API_KEY", "API_SECRET") +val message = Message(from = "발신번호", to = "수신번호", text = "내용") +val response = service.send(message) +``` + +**Java:** +```java +DefaultMessageService service = SolapiClient.INSTANCE.createInstance("API_KEY", "API_SECRET"); +Message message = new Message(); +message.setFrom("발신번호"); +message.setTo("수신번호"); +message.setText("내용"); +service.send(message, null); +``` + +## Message Types + +| Type | Field | Notes | +|------|-------|-------| +| SMS | text | < 80 bytes | +| LMS | text | 80-2000 bytes | +| MMS | imageId | uploadFile() 후 사용, JPG/JPEG, max 200KB | +| ATA | kakaoOptions | 카카오 알림톡, 템플릿 필수 | +| BMS_* | kakaoOptions | 카카오 브랜드 메시지 | +| RCS_* | rcsOptions | RCS 문자 | +| NSA | naverOptions | 네이버 스마트 알림 | +| FAX | fileId | uploadFile() 후 사용 | +| VOICE | voiceOptions | 음성 메시지 | + +## KakaoOption + +```kotlin +KakaoOption( + pfId = "카카오채널ID", + templateId = "템플릿ID", + variables = mapOf("name" to "홍길동", "code" to "123456") +) +``` + +```java +KakaoOption kakaoOption = new KakaoOption(); +kakaoOption.setPfId("카카오채널ID"); +kakaoOption.setTemplateId("템플릿ID"); +kakaoOption.setVariables(Map.of("name", "홍길동", "code", "123456")); +``` + +## File Upload (MMS) + +```kotlin +val imageId = service.uploadFile(file, StorageType.MMS) +val message = Message( + type = MessageType.MMS, + from = "발신번호", + to = "수신번호", + text = "MMS 내용", + subject = "제목", + imageId = imageId +) +``` + +## Batch Send + +```kotlin +val messages = recipients.map { recipient -> + Message(from = sender, to = recipient, text = "메시지") +} +service.send(messages) // max 10,000 +``` + +중복 번호 허용: +```kotlin +val config = SendRequestConfig().apply { allowDuplicates = true } +service.send(messages, config) +``` + +## Scheduled Send + +```kotlin +val config = SendRequestConfig().apply { + setScheduledDateFromLocalDateTime( + LocalDateTime.now().plusMinutes(10), + ZoneId.of("Asia/Seoul") + ) +} +service.send(message, config) +``` + +제약: 최소 10분 후 ~ 최대 6개월 이내 + +## API Methods + +**발송:** +| Method | Description | +|--------|-------------| +| `send(message)` | 단건 발송 | +| `send(messages)` | 다건 발송 (max 10,000) | +| `send(message, config)` | 설정과 함께 발송 | +| `uploadFile(file, type)` | 파일 업로드 | + +**조회:** +| Method | Description | +|--------|-------------| +| `getBalance()` | 잔액 조회 | +| `getQuota()` | 일일 한도 조회 | +| `getMessageList(request)` | 발송 내역 조회 | + +**카카오 템플릿:** +| Method | Description | +|--------|-------------| +| `getKakaoAlimtalkTemplates()` | 알림톡 템플릿 목록 | +| `getKakaoAlimtalkTemplate(id)` | 템플릿 상세 | +| `getSendableKakaoAlimtalkTemplates()` | 발송 가능 템플릿 | + +## Exceptions + +| Exception | Cause | +|-----------|-------| +| `SolapiBadRequestException` | 잘못된 파라미터 | +| `SolapiInvalidApiKeyException` | API 키 오류 | +| `SolapiApiKeyException` | API 키 관련 오류 | +| `SolapiFileUploadException` | 파일 업로드 실패 | +| `SolapiMessageNotReceivedException` | 발송 실패 | +| `SolapiEmptyResponseException` | 빈 응답 | +| `SolapiUnknownException` | 알 수 없는 오류 | + +## Error Handling Pattern + +```kotlin +try { + service.send(message) +} catch (e: SolapiBadRequestException) { + // 잘못된 요청 +} catch (e: SolapiInvalidApiKeyException) { + // API 키 오류 +} catch (e: SolapiMessageNotReceivedException) { + // 발송 실패 +} catch (e: SolapiException) { + // 기타 오류 +} +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `SOLAPI_API_KEY` | API 키 | +| `SOLAPI_API_SECRET` | API 시크릿 | +| `SOLAPI_SENDER` | 발신번호 | +| `SOLAPI_KAKAO_PF_ID` | 카카오 채널 ID | +| `SOLAPI_KAKAO_TEMPLATE_ID` | 알림톡 템플릿 ID | + +## Imports + +**Kotlin:** +```kotlin +import com.solapi.sdk.SolapiClient +import com.solapi.sdk.message.model.Message +import com.solapi.sdk.message.model.MessageType +import com.solapi.sdk.message.model.StorageType +import com.solapi.sdk.message.model.SendRequestConfig +import com.solapi.sdk.message.model.kakao.KakaoOption +``` + +**Java:** +```java +import com.solapi.sdk.SolapiClient; +import com.solapi.sdk.message.model.Message; +import com.solapi.sdk.message.model.MessageType; +import com.solapi.sdk.message.model.StorageType; +import com.solapi.sdk.message.model.SendRequestConfig; +import com.solapi.sdk.message.model.kakao.KakaoOption; +import com.solapi.sdk.message.service.DefaultMessageService; +``` diff --git a/README.md b/README.md index bb323d4..9633753 100644 --- a/README.md +++ b/README.md @@ -7,22 +7,25 @@ Kotlin과 Java에서 SOLAPI 메시지 발송 서비스를 사용할 수 있는 ## AI와 함께 시작하기 -Claude Code, Cursor, Windsurf 등 AI 코딩 도구를 사용하신다면, 아래 프롬프트를 AI에게 붙여넣기하세요. +Claude Code, Cursor, Windsurf 등 AI 코딩 도구에 아래 프롬프트를 붙여넣기하세요: ```text -SOLAPI SDK(`com.solapi:sdk:1.1.0`)로 문자 메시지를 발송하는 기능을 구현해줘. -참고 문서: https://developers.solapi.com/ +SOLAPI SDK로 문자 메시지 발송 기능을 구현해줘. +가이드: https://raw.githubusercontent.com/solapi/solapi-kotlin/main/LLM_GUIDE.md ``` -### AI가 도와줄 수 있는 작업 +터미널에서 가이드 확인: +```bash +curl -s https://raw.githubusercontent.com/solapi/solapi-kotlin/main/LLM_GUIDE.md +``` -| 요청 예시 | 설명 | -|-----------|------| -| "SMS 발송 코드 작성해줘" | 단문 문자 발송 | -| "카카오 알림톡 발송 구현해줘" | 알림톡 템플릿 발송 | -| "대량 문자 발송 기능 추가해줘" | 최대 10,000건 일괄 발송 | -| "예약 발송 구현해줘" | 특정 시간에 발송 예약 | -| "발송 결과 조회 코드 작성해줘" | 메시지 상태 확인 | +| 상황 | 프롬프트 | +|------|----------| +| SMS/MMS 발송 | "SOLAPI SDK로 SMS 발송 코드 작성해줘" | +| 카카오 알림톡 | "SOLAPI SDK로 카카오 알림톡 발송 구현해줘" | +| 대량 발송 | "SOLAPI SDK로 대량 문자 발송 기능 추가해줘" | +| 예약 발송 | "SOLAPI SDK로 예약 발송 구현해줘" | +| 발송 조회 | "SOLAPI SDK로 발송 결과 조회 코드 작성해줘" | ## 설치 From 82abfa181341b19b9deda98bf5309e804b529dee Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 14:02:14 +0900 Subject: [PATCH 39/43] docs: add JDK 8 compatible Java examples to LLM guide - Change title to "Kotlin/Java SDK" - Add JDK 8 Note section explaining Map.of()/List.of() limitations - Fix KakaoOption Java example to use HashMap instead of Map.of() - Add Java examples for File Upload, Batch Send, Scheduled Send - Add Java example for Error Handling pattern Co-Authored-By: Claude Opus 4.5 --- LLM_GUIDE.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/LLM_GUIDE.md b/LLM_GUIDE.md index bfd6e5a..cc2877f 100644 --- a/LLM_GUIDE.md +++ b/LLM_GUIDE.md @@ -1,4 +1,4 @@ -# SOLAPI Kotlin SDK - LLM Guide +# SOLAPI Kotlin/Java SDK - LLM Guide SDK를 사용하여 코드를 작성하는 LLM 에이전트용 기술 가이드. @@ -12,6 +12,14 @@ SDK를 사용하여 코드를 작성하는 LLM 에이전트용 기술 가이드. | Java | 8+ | | Kotlin | 1.8+ | +## JDK 8 Note + +JDK 8에서는 `Map.of()`, `List.of()` 사용 불가. HashMap, ArrayList 사용: +```java +Map variables = new HashMap<>(); +variables.put("name", "홍길동"); +``` + ## Setup **Gradle (Kotlin DSL):** @@ -75,11 +83,15 @@ KakaoOption( KakaoOption kakaoOption = new KakaoOption(); kakaoOption.setPfId("카카오채널ID"); kakaoOption.setTemplateId("템플릿ID"); -kakaoOption.setVariables(Map.of("name", "홍길동", "code", "123456")); +Map variables = new HashMap<>(); +variables.put("name", "홍길동"); +variables.put("code", "123456"); +kakaoOption.setVariables(variables); ``` ## File Upload (MMS) +**Kotlin:** ```kotlin val imageId = service.uploadFile(file, StorageType.MMS) val message = Message( @@ -90,25 +102,57 @@ val message = Message( subject = "제목", imageId = imageId ) +service.send(message) +``` + +**Java:** +```java +String imageId = service.uploadFile(file, StorageType.MMS, null); +Message message = new Message(); +message.setType(MessageType.MMS); +message.setFrom("발신번호"); +message.setTo("수신번호"); +message.setText("MMS 내용"); +message.setSubject("제목"); +message.setImageId(imageId); +service.send(message, null); ``` ## Batch Send +**Kotlin:** ```kotlin val messages = recipients.map { recipient -> Message(from = sender, to = recipient, text = "메시지") } service.send(messages) // max 10,000 -``` -중복 번호 허용: -```kotlin +// 중복 번호 허용 val config = SendRequestConfig().apply { allowDuplicates = true } service.send(messages, config) ``` +**Java:** +```java +List messages = new ArrayList<>(); +for (String recipient : recipients) { + Message msg = new Message(); + msg.setFrom(sender); + msg.setTo(recipient); + msg.setText("메시지"); + messages.add(msg); +} +service.send(messages, null); // max 10,000 + +// 중복 번호 허용 +SendRequestConfig config = new SendRequestConfig(); +config.setAllowDuplicates(true); +service.send(messages, config); +``` + ## Scheduled Send +**Kotlin:** ```kotlin val config = SendRequestConfig().apply { setScheduledDateFromLocalDateTime( @@ -119,6 +163,16 @@ val config = SendRequestConfig().apply { service.send(message, config) ``` +**Java:** +```java +SendRequestConfig config = new SendRequestConfig(); +config.setScheduledDateFromLocalDateTime( + LocalDateTime.now().plusMinutes(10), + ZoneId.of("Asia/Seoul") +); +service.send(message, config); +``` + 제약: 최소 10분 후 ~ 최대 6개월 이내 ## API Methods @@ -159,6 +213,7 @@ service.send(message, config) ## Error Handling Pattern +**Kotlin:** ```kotlin try { service.send(message) @@ -173,6 +228,21 @@ try { } ``` +**Java:** +```java +try { + service.send(message, null); +} catch (SolapiBadRequestException e) { + // 잘못된 요청 +} catch (SolapiInvalidApiKeyException e) { + // API 키 오류 +} catch (SolapiMessageNotReceivedException e) { + // 발송 실패 +} catch (SolapiException e) { + // 기타 오류 +} +``` + ## Environment Variables | Variable | Description | From 1fcf0929c03a76a6496411ac4d36e1d7976cbd78 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 14:18:50 +0900 Subject: [PATCH 40/43] refactor: remove explicit ATA type from Kakao Alimtalk examples Server automatically determines message type from kakaoOptions, so explicit MessageType.ATA is unnecessary. Also add note about brand message template compatibility. Co-Authored-By: Claude Opus 4.5 --- .../src/main/java/com/solapi/example/KakaoAlimtalkExample.java | 3 ++- .../src/main/kotlin/com/solapi/example/KakaoAlimtalkExample.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoAlimtalkExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoAlimtalkExample.java index 3452bb9..362f62e 100644 --- a/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoAlimtalkExample.java +++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoAlimtalkExample.java @@ -25,6 +25,8 @@ * - 사전에 검수 승인된 템플릿만 사용 가능 * - 정보성 메시지 전용 (광고 불가) * - 변수 치환을 통해 동적 내용 전달 가능 + * 번외: + * 브랜드 메시지 템플릿과 동일한 형태의 코드로 발송하실 수 있습니다! */ public class KakaoAlimtalkExample { @@ -66,7 +68,6 @@ public static void main(String[] args) { // 알림톡 메시지 생성 Message message = new Message(); - message.setType(MessageType.ATA); message.setFrom(sender); message.setTo(recipient); message.setKakaoOptions(kakaoOption); diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoAlimtalkExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoAlimtalkExample.kt index 9e8e313..6d32281 100644 --- a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoAlimtalkExample.kt +++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoAlimtalkExample.kt @@ -20,6 +20,8 @@ import com.solapi.sdk.message.model.kakao.KakaoOption * - 사전에 검수 승인된 템플릿만 사용 가능 * - 정보성 메시지 전용 (광고 불가) * - 변수 치환을 통해 동적 내용 전달 가능 + * 번외: + * 브랜드 메시지 템플릿과 동일한 형태의 코드로 발송하실 수 있습니다! */ fun main() { // 환경변수에서 설정 로드 @@ -47,7 +49,6 @@ fun main() { // 알림톡 메시지 생성 val message = Message( - type = MessageType.ATA, from = sender, to = recipient, kakaoOptions = KakaoOption( From 6614de87a7bb025e3a8b7dcdc623a54703cbaeb6 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 14:59:04 +0900 Subject: [PATCH 41/43] docs: add Agent Workflow section and translate LLM guide to English - Add Agent Workflow section with AskUserQuestion guidance for LLM agents - Include required questions for message type, language, and send mode - Translate all documentation text to English for consistency - Keep code block placeholders in Korean for practical examples - Update README prompt to English for broader accessibility Co-Authored-By: Claude Opus 4.5 --- LLM_GUIDE.md | 102 ++++++++++++++++++++++++++++++++------------------- README.md | 9 +++-- 2 files changed, 71 insertions(+), 40 deletions(-) diff --git a/LLM_GUIDE.md b/LLM_GUIDE.md index cc2877f..13ce5c5 100644 --- a/LLM_GUIDE.md +++ b/LLM_GUIDE.md @@ -1,11 +1,39 @@ # SOLAPI Kotlin/Java SDK - LLM Guide -SDK를 사용하여 코드를 작성하는 LLM 에이전트용 기술 가이드. +Technical guide for LLM agents writing code using the SDK. + +## Agent Workflow + +LLM agents should use the AskUserQuestion tool to ask users about their messaging needs before writing code. + +### Required Questions + +Ask the user the following questions using AskUserQuestion: + +1. **Message Type** - Which type of message do you want to send? + - SMS/LMS (Text message) + - MMS (Image message) + - Kakao Alimtalk (Notification) + - Kakao Brand Message (Freeform/Template) + - RCS + - Voice + - FAX + +2. **Programming Language** - Which language are you using? + - Kotlin + - Java + +3. **Send Mode** - How do you want to send messages? + - Single message + - Batch send (multiple recipients) + - Scheduled send + +After gathering requirements, proceed to the relevant sections in this guide. ## Quick Reference -| 항목 | 값 | -|------|-----| +| Item | Value | +|------|-------| | SDK | `com.solapi:sdk:1.1.0` | | Docs | https://developers.solapi.com/llms.txt | | API Ref | https://solapi.github.io/solapi-kotlin/ | @@ -14,7 +42,7 @@ SDK를 사용하여 코드를 작성하는 LLM 에이전트용 기술 가이드. ## JDK 8 Note -JDK 8에서는 `Map.of()`, `List.of()` 사용 불가. HashMap, ArrayList 사용: +JDK 8 does not support `Map.of()`, `List.of()`. Use HashMap, ArrayList instead: ```java Map variables = new HashMap<>(); variables.put("name", "홍길동"); @@ -61,13 +89,13 @@ service.send(message, null); |------|-------|-------| | SMS | text | < 80 bytes | | LMS | text | 80-2000 bytes | -| MMS | imageId | uploadFile() 후 사용, JPG/JPEG, max 200KB | -| ATA | kakaoOptions | 카카오 알림톡, 템플릿 필수 | -| BMS_* | kakaoOptions | 카카오 브랜드 메시지 | -| RCS_* | rcsOptions | RCS 문자 | -| NSA | naverOptions | 네이버 스마트 알림 | -| FAX | fileId | uploadFile() 후 사용 | -| VOICE | voiceOptions | 음성 메시지 | +| MMS | imageId | Use after uploadFile(), JPG/JPEG, max 200KB | +| ATA | kakaoOptions | Kakao Alimtalk, template required | +| BMS_* | kakaoOptions | Kakao Brand Message | +| RCS_* | rcsOptions | RCS message | +| NSA | naverOptions | Naver Smart Notification | +| FAX | fileId | Use after uploadFile() | +| VOICE | voiceOptions | Voice message | ## KakaoOption @@ -173,43 +201,43 @@ config.setScheduledDateFromLocalDateTime( service.send(message, config); ``` -제약: 최소 10분 후 ~ 최대 6개월 이내 +Constraints: Minimum 10 minutes, maximum 6 months in advance ## API Methods -**발송:** +**Send:** | Method | Description | |--------|-------------| -| `send(message)` | 단건 발송 | -| `send(messages)` | 다건 발송 (max 10,000) | -| `send(message, config)` | 설정과 함께 발송 | -| `uploadFile(file, type)` | 파일 업로드 | +| `send(message)` | Send single message | +| `send(messages)` | Send multiple messages (max 10,000) | +| `send(message, config)` | Send with configuration | +| `uploadFile(file, type)` | Upload file | -**조회:** +**Query:** | Method | Description | |--------|-------------| -| `getBalance()` | 잔액 조회 | -| `getQuota()` | 일일 한도 조회 | -| `getMessageList(request)` | 발송 내역 조회 | +| `getBalance()` | Get balance | +| `getQuota()` | Get daily quota | +| `getMessageList(request)` | Get message history | -**카카오 템플릿:** +**Kakao Templates:** | Method | Description | |--------|-------------| -| `getKakaoAlimtalkTemplates()` | 알림톡 템플릿 목록 | -| `getKakaoAlimtalkTemplate(id)` | 템플릿 상세 | -| `getSendableKakaoAlimtalkTemplates()` | 발송 가능 템플릿 | +| `getKakaoAlimtalkTemplates()` | Get Alimtalk template list | +| `getKakaoAlimtalkTemplate(id)` | Get template details | +| `getSendableKakaoAlimtalkTemplates()` | Get sendable templates | ## Exceptions | Exception | Cause | |-----------|-------| -| `SolapiBadRequestException` | 잘못된 파라미터 | -| `SolapiInvalidApiKeyException` | API 키 오류 | -| `SolapiApiKeyException` | API 키 관련 오류 | -| `SolapiFileUploadException` | 파일 업로드 실패 | -| `SolapiMessageNotReceivedException` | 발송 실패 | -| `SolapiEmptyResponseException` | 빈 응답 | -| `SolapiUnknownException` | 알 수 없는 오류 | +| `SolapiBadRequestException` | Invalid parameters | +| `SolapiInvalidApiKeyException` | API key error | +| `SolapiApiKeyException` | API key related error | +| `SolapiFileUploadException` | File upload failed | +| `SolapiMessageNotReceivedException` | Send failed | +| `SolapiEmptyResponseException` | Empty response | +| `SolapiUnknownException` | Unknown error | ## Error Handling Pattern @@ -247,11 +275,11 @@ try { | Variable | Description | |----------|-------------| -| `SOLAPI_API_KEY` | API 키 | -| `SOLAPI_API_SECRET` | API 시크릿 | -| `SOLAPI_SENDER` | 발신번호 | -| `SOLAPI_KAKAO_PF_ID` | 카카오 채널 ID | -| `SOLAPI_KAKAO_TEMPLATE_ID` | 알림톡 템플릿 ID | +| `SOLAPI_API_KEY` | API key | +| `SOLAPI_API_SECRET` | API secret | +| `SOLAPI_SENDER` | Sender number | +| `SOLAPI_KAKAO_PF_ID` | Kakao channel ID | +| `SOLAPI_KAKAO_TEMPLATE_ID` | Alimtalk template ID | ## Imports diff --git a/README.md b/README.md index 9633753..d4dd749 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,17 @@ Kotlin과 Java에서 SOLAPI 메시지 발송 서비스를 사용할 수 있는 ## AI와 함께 시작하기 +### 사람이 읽을 가이드: Claude Code, Cursor, Windsurf 등 AI 코딩 도구에 아래 프롬프트를 붙여넣기하세요: ```text -SOLAPI SDK로 문자 메시지 발송 기능을 구현해줘. -가이드: https://raw.githubusercontent.com/solapi/solapi-kotlin/main/LLM_GUIDE.md +Implement messaging feature using SOLAPI SDK. +Guide: https://raw.githubusercontent.com/solapi/solapi-kotlin/main/LLM_GUIDE.md ``` -터미널에서 가이드 확인: +### LLM 에이전트를 위한 가이드: +SDK 가이드를 가져온 후 LLM 가이드를 따르세요. + ```bash curl -s https://raw.githubusercontent.com/solapi/solapi-kotlin/main/LLM_GUIDE.md ``` From ad1ace145f2b143999bd09aabeacf348930acbe9 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 15:31:11 +0900 Subject: [PATCH 42/43] refactor: consolidate Kotlin compile tasks and update version requirements Merge duplicate compileKotlin/compileTestKotlin configurations into single withType block. Update README to reflect actual Kotlin 2.2.0+ requirement since SDK uses Kotlin 2.3.0 compiler features. Co-Authored-By: Claude Opus 4.5 --- README.md | 2 +- build.gradle.kts | 13 +++---------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d4dd749..477b3e6 100644 --- a/README.md +++ b/README.md @@ -363,7 +363,7 @@ try { ## 요구 사항 - **Java 8** 이상 -- **Kotlin 1.8** 이상 (Kotlin 사용 시) +- **Kotlin 2.2.0** 이상 (Kotlin 사용 시) ## 관련 링크 diff --git a/build.gradle.kts b/build.gradle.kts index 6edf627..1794833 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -114,6 +114,9 @@ val generateVersionFile by tasks.register("generateVersionFile") { tasks.withType().configureEach { dependsOn(generateVersionFile) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + } } java { @@ -148,16 +151,6 @@ tasks.withType().configureEach { }) } -val compileKotlin: KotlinCompile by tasks -compileKotlin.compilerOptions { - jvmTarget.set(JvmTarget.JVM_1_8) -} - -val compileTestKotlin: KotlinCompile by tasks -compileTestKotlin.compilerOptions { - jvmTarget.set(JvmTarget.JVM_1_8) -} - tasks.withType().configureEach { dependsOn(generateVersionFile) outputDirectory.set(project.rootDir.resolve("docs")) From e7d6838f6b10032d40ff454f97c10a42b6b60d8a Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 30 Jan 2026 15:58:09 +0900 Subject: [PATCH 43/43] build: auto-exclude example modules during publish tasks Add automatic detection for publish/release tasks in settings.gradle.kts to skip example modules without requiring explicit -PskipExamples flag. Manual override still available via -PskipExamples=true/false. Co-Authored-By: Claude Opus 4.5 --- settings.gradle.kts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index d4172b6..65cea11 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,17 @@ rootProject.name = "solapi.sdk" // Example modules auto-discovery -rootDir.listFiles() - ?.filter { it.isDirectory && it.name.startsWith("solapi-kotlin-example") } - ?.filter { File(it, "build.gradle.kts").exists() } - ?.forEach { include(":${it.name}") } +// - publish/release 태스크 실행 시 자동으로 제외 +// - -PskipExamples=true 로 명시적 제외 가능 +val isPublishTask = gradle.startParameter.taskNames.any { + it.contains("publish", ignoreCase = true) || + it.contains("Release", ignoreCase = true) +} +val skipExamples = providers.gradleProperty("skipExamples").orNull?.toBoolean() ?: isPublishTask + +if (!skipExamples) { + rootDir.listFiles() + ?.filter { it.isDirectory && it.name.startsWith("solapi-kotlin-example") } + ?.filter { File(it, "build.gradle.kts").exists() } + ?.forEach { include(":${it.name}") } +}