├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── bin └── aws │ └── deploy.sh ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── src └── main └── kotlin └── io └── devholic └── epilogue ├── Network.kt ├── SendRecipientDataIfExists.kt ├── domain ├── KATCRepository.kt ├── MessageRepository.kt ├── NaverNewsRepository.kt └── SlackRepository.kt ├── entity ├── Channel.kt ├── ChannelInfo.kt ├── Recipient.kt ├── SlackMessage.kt └── ValueObject.kt ├── enum ├── HtmlElement.kt └── NaverNewsCategory.kt ├── extension ├── Iterable.kt ├── String.kt └── Times.kt ├── repository ├── KATCRepositoryImpl.kt ├── MessageRepositoryImpl.kt ├── NaverNewsRepositoryImpl.kt └── SlackRepositoryImpl.kt ├── request └── SlackMessageRequest.kt └── response ├── ChannelInfoResponse.kt └── ChannelResponse.kt /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | !gradle-wrapper.jar 3 | .gradletasknamecache 4 | 5 | *.iml 6 | .idea/ 7 | build/ 8 | out/ 9 | 10 | epilogue.zip 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | 3 | jdk: 4 | - oraclejdk8 5 | 6 | install: 7 | - pip install --user awscli 8 | - gradle jar 9 | 10 | after_success: 11 | - make upload 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2017 Sunghoon Kang 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 0. You just DO WHAT THE FUCK YOU WANT TO. 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: upload 2 | 3 | upload: 4 | ./bin/aws/deploy.sh 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # epilogue 2 | 3 | [![Build Status](https://travis-ci.org/devholic/epilogue.svg?branch=master)](https://travis-ci.org/devholic/epilogue) 4 | 5 | 훈련소 갔을때 편지써줄 여자친구가 있으면 좋겠다 6 | 7 | - [Kotlin, AWS 그리고 레이니스트와 함께라면 육군훈련소에서도 외롭지 않아](https://medium.com/rainist-engineering/writing-aws-lambda-function-in-kotlin-b3faf3f55777) 8 | 9 | ## Required Environment Variables 10 | 11 | Key | Description 12 | -----|----- 13 | SLACK_ACCESS_TOKEN | Slack Access Token 14 | SLACK_CHANNEL_ID | 메시지를 보낼 Channel의 ID 15 | SLACK_WEBHOOK_URL | Webhook URL 16 | SLACK_USER_ID | 본인의 Slack User ID 17 | SLACK_USERNAME | 본인의 Slack Username 18 | RECIPIENT_NAME | 본인의 이름 (**실명**) 19 | RECIPIENT_BIRTHDAY | 본인의 주민등록상 생년월일 (**yyyy-MM-dd**) 20 | RECIPIENT_ENTERDATE | 본인의 입대일 (**yyyy-MM-dd**) 21 | 22 | ## License 23 | 24 | WTFPL 25 | 26 | > 잘 사용하셨다면 Star 하나 눌러주시면 감사하겠습니다 (´・ω・`) 27 | 28 | ``` 29 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 30 | Version 2, December 2004 31 | 32 | Copyright (C) 2017 Sunghoon Kang 33 | 34 | Everyone is permitted to copy and distribute verbatim or modified 35 | copies of this license document, and changing it is allowed as long 36 | as the name is changed. 37 | 38 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 39 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 40 | 0. You just DO WHAT THE FUCK YOU WANT TO. 41 | ``` 42 | -------------------------------------------------------------------------------- /bin/aws/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | if [ "$TRAVIS_BRANCH" == "master" ]; then 6 | aws lambda update-function-code \ 7 | --zip-file=fileb://build/libs/epilogue.jar \ 8 | --region=$LAMBDA_REGION \ 9 | --function-name=$LAMBDA_FUNCTION_NAME \ 10 | --query 'LastModified' 11 | fi 12 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'org.jetbrains.kotlin.jvm' version '1.1.2-5' 3 | } 4 | 5 | ext { 6 | kotlinVersion = '1.1.2-5' 7 | awsLambdaVersion = '1.1.0' 8 | jsoupVersion = '1.10.3' 9 | gsonVersion = '2.8.1' 10 | okhttpVersion = '3.8.0' 11 | rxJavaVersion = '2.1.1' 12 | 13 | mainClassName = 'io.devholic.epilogue.SendRecipientDataIfExists' 14 | } 15 | 16 | sourceSets { 17 | main.kotlin.srcDirs += 'src/main/kotlin' 18 | } 19 | 20 | jar { 21 | manifest { 22 | attributes 'Main-Class': "$mainClassName" 23 | } 24 | from { 25 | configurations.compile.collect { 26 | it.isDirectory() ? it : zipTree(it) 27 | } 28 | } 29 | } 30 | 31 | repositories { 32 | mavenCentral() 33 | } 34 | 35 | dependencies { 36 | compile "org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlinVersion" 37 | compile "com.amazonaws:aws-lambda-java-core:$awsLambdaVersion" 38 | compile "com.google.code.gson:gson:$gsonVersion" 39 | compile "com.squareup.okhttp3:okhttp:$okhttpVersion" 40 | compile "org.jsoup:jsoup:$jsoupVersion" 41 | compile "io.reactivex.rxjava2:rxjava:$rxJavaVersion" 42 | } 43 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devholic/epilogue/487f10444e98ea203d056aa1e33f9ff14f3813da/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Jun 21 23:42:59 KST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-3.4.1-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn ( ) { 37 | echo "$*" 38 | } 39 | 40 | die ( ) { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save ( ) { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'epilogue' 2 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/Network.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue 2 | 3 | import com.google.gson.Gson 4 | import okhttp3.MediaType 5 | import okhttp3.OkHttpClient 6 | 7 | 8 | object Network { 9 | 10 | val client: OkHttpClient = OkHttpClient() 11 | val gson: Gson = Gson() 12 | val jsonMediaType = MediaType.parse("application/json; charset=utf-8") 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/SendRecipientDataIfExists.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue 2 | 3 | import com.amazonaws.services.lambda.runtime.Context 4 | import com.amazonaws.services.lambda.runtime.RequestHandler 5 | import io.devholic.epilogue.domain.KATCRepository 6 | import io.devholic.epilogue.domain.MessageRepository 7 | import io.devholic.epilogue.domain.NaverNewsRepository 8 | import io.devholic.epilogue.domain.SlackRepository 9 | import io.devholic.epilogue.enum.NaverNewsCategory 10 | import io.devholic.epilogue.repository.KATCRepositoryImpl 11 | import io.devholic.epilogue.repository.MessageRepositoryImpl 12 | import io.devholic.epilogue.repository.NaverNewsRepositoryImpl 13 | import io.devholic.epilogue.repository.SlackRepositoryImpl 14 | import io.reactivex.Single 15 | import io.reactivex.functions.BiFunction 16 | import java.io.InputStream 17 | import java.time.LocalDate 18 | 19 | 20 | class SendRecipientDataIfExists( 21 | private val katcRepository: KATCRepository = 22 | KATCRepositoryImpl(), 23 | private val messageRepository: MessageRepository = 24 | MessageRepositoryImpl(System.getenv(slackUsername)), 25 | private val naverNewsRepository: NaverNewsRepository = 26 | NaverNewsRepositoryImpl(), 27 | private val slackRepository: SlackRepository = 28 | SlackRepositoryImpl( 29 | System.getenv(slackAccessToken), 30 | System.getenv(slackWebhookUrl) 31 | ) 32 | ) : RequestHandler { 33 | 34 | companion object { 35 | const val slackAccessToken = "SLACK_ACCESS_TOKEN" 36 | const val slackChannelId = "SLACK_CHANNEL_ID" 37 | const val slackWebhookUrl = "SLACK_WEBHOOK_URL" 38 | const val slackUserId = "SLACK_USER_ID" 39 | const val slackUsername = "SLACK_USERNAME" 40 | const val recipientName = "RECIPIENT_NAME" 41 | const val recipientBirthday = "RECIPIENT_BIRTHDAY" 42 | const val recipientEnterDate = "RECIPIENT_ENTERDATE" 43 | } 44 | 45 | private val defaultHeadlineLimit: Int = 10 46 | private val defaultWriterId: String = "" 47 | 48 | override fun handleRequest(input: InputStream, context: Context): Unit = 49 | katcRepository.getRecipients( 50 | System.getenv(recipientName), 51 | LocalDate.parse(System.getenv(recipientBirthday)), 52 | LocalDate.parse(System.getenv(recipientEnterDate)) 53 | ).filter { 54 | it.isNotEmpty() 55 | }.flatMapSingle { recipients -> 56 | Single.zip( 57 | Single.zip( 58 | listOf( 59 | NaverNewsCategory.IT, 60 | NaverNewsCategory.ENTERTAINMENT, 61 | NaverNewsCategory.SOCIETY, 62 | NaverNewsCategory.WORLD, 63 | NaverNewsCategory.LIFE 64 | ).map { naverNewsRepository.getHeadlineList(it, defaultHeadlineLimit) }, 65 | { 66 | it.map { 67 | @Suppress("UNCHECKED_CAST") 68 | it as List 69 | }.fold(emptyList(), { acc, result -> acc + result }) 70 | } 71 | ), 72 | slackRepository 73 | .getWriterId( 74 | System.getenv(slackChannelId), 75 | System.getenv(slackUserId), 76 | defaultWriterId 77 | ), 78 | BiFunction { newsList: List, id: String -> 79 | Triple(recipients, newsList, id) 80 | } 81 | ) 82 | }.flatMapCompletable { 83 | messageRepository.create( 84 | it.first, 85 | it.second, 86 | it.third 87 | ).flatMapCompletable { 88 | slackRepository.sendMessage(it) 89 | } 90 | }.blockingGet()?.let { throw(it) } ?: Unit 91 | } 92 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/domain/KATCRepository.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.domain 2 | 3 | import io.devholic.epilogue.entity.Recipient 4 | import io.reactivex.Single 5 | import java.time.LocalDate 6 | 7 | 8 | interface KATCRepository { 9 | 10 | fun getRecipients(name: String, birthday: LocalDate, enterDate: LocalDate): Single> 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/domain/MessageRepository.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.domain 2 | 3 | import io.devholic.epilogue.entity.Recipient 4 | import io.devholic.epilogue.entity.SlackMessage 5 | import io.reactivex.Single 6 | 7 | 8 | interface MessageRepository { 9 | 10 | fun create(recipients: List, newsList: List, winner: String): Single 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/domain/NaverNewsRepository.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.domain 2 | 3 | import io.devholic.epilogue.enum.NaverNewsCategory 4 | import io.reactivex.Single 5 | 6 | 7 | interface NaverNewsRepository { 8 | 9 | fun getHeadlineList(category: NaverNewsCategory, limit: Int): Single> 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/domain/SlackRepository.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.domain 2 | 3 | import io.devholic.epilogue.entity.SlackMessage 4 | import io.reactivex.Completable 5 | import io.reactivex.Single 6 | 7 | 8 | interface SlackRepository { 9 | 10 | fun getWriterId(channelId: String, recipientId: String, defaultWriterId: String): Single 11 | fun sendMessage(message: SlackMessage): Completable 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/entity/Channel.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.entity 2 | 3 | 4 | data class Channel( 5 | val members: List 6 | ) : ValueObject 7 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/entity/ChannelInfo.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.entity 2 | 3 | 4 | data class ChannelInfo( 5 | val channel: Channel 6 | ) : ValueObject 7 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/entity/Recipient.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.entity 2 | 3 | import java.time.LocalDate 4 | 5 | 6 | data class Recipient( 7 | val birthday: LocalDate, 8 | val enterDate: LocalDate, 9 | val name: String, 10 | val regiment: Int, 11 | val company: Int, 12 | val platoon: Int 13 | ) 14 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/entity/SlackMessage.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.entity 2 | 3 | 4 | data class SlackMessage( 5 | val message: String 6 | ) : ValueObject 7 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/entity/ValueObject.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.entity 2 | 3 | 4 | interface ValueObject 5 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/enum/HtmlElement.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.enum 2 | 3 | 4 | enum class HtmlElement(val value: String) { 5 | TD("td"), 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/enum/NaverNewsCategory.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.enum 2 | 3 | 4 | enum class NaverNewsCategory(val sid1: Int) { 5 | POLITICS(100), 6 | ECONOMY(101), 7 | SOCIETY(102), 8 | LIFE(103), 9 | WORLD(104), 10 | IT(105), 11 | ENTERTAINMENT(106), 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/extension/Iterable.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.extension 2 | 3 | import java.util.* 4 | 5 | 6 | fun List.randomlyPick(): T? = 7 | Random(System.currentTimeMillis()) 8 | .nextInt(size) 9 | .let { 10 | if (isNotEmpty()) this[it] else null 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/extension/String.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.extension 2 | 3 | import java.nio.charset.Charset 4 | import java.util.* 5 | 6 | 7 | fun String.toBase64(charset: Charset = Charsets.UTF_8): String = 8 | Base64.getEncoder().encodeToString(this.toByteArray(charset)) 9 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/extension/Times.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.extension 2 | 3 | import java.text.SimpleDateFormat 4 | import java.util.* 5 | 6 | 7 | fun Date.toFormattedString(): String = 8 | SimpleDateFormat("yyyyMMdd", Locale.KOREA).format(this).toString() 9 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/repository/KATCRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.repository 2 | 3 | import io.devholic.epilogue.Network 4 | import io.devholic.epilogue.domain.KATCRepository 5 | import io.devholic.epilogue.entity.Recipient 6 | import io.devholic.epilogue.enum.HtmlElement 7 | import io.devholic.epilogue.extension.toBase64 8 | import io.reactivex.Single 9 | import okhttp3.FormBody 10 | import okhttp3.Request 11 | import org.jsoup.Jsoup 12 | import org.jsoup.nodes.Element 13 | import java.time.LocalDate 14 | import java.time.format.DateTimeFormatter 15 | 16 | 17 | class KATCRepositoryImpl : KATCRepository { 18 | 19 | private val nonNumberRegex = "\\D".toRegex() 20 | 21 | private val birthdayFormatter = DateTimeFormatter.ofPattern("yyMMdd") 22 | private val enterDateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd") 23 | 24 | private val recipientQueryUrl = "http://www.katc.mil.kr/katc/community/children.jsp" 25 | private val recipientEnterDateKey = "search_val1" 26 | private val recipientEncodedBirthDayKey = "search_val2" 27 | private val recipientBirthDayKey = "birthDay" 28 | private val recipientNameKey = "search_val3" 29 | private val recipientRegimentIdx = 1 30 | private val recipientCompanyIdx = 2 31 | private val recipientPlatoonIdx = 3 32 | private val recipientId = "[id^=childInfo]" 33 | 34 | private val requiredCellSize = 7 35 | 36 | /** 37 | * 육군훈련소 홈페이지에서 편지를 받을 훈련병을 검색합니다. 38 | * 39 | * @param name 훈련병 이름 40 | * @param birthday 훈련병 생년월일 41 | * @param enterDate 훈련병 입소일 42 | */ 43 | override fun getRecipients(name: String, birthday: LocalDate, enterDate: LocalDate): Single> = 44 | Single.fromCallable { 45 | Network.client 46 | .newCall( 47 | Request.Builder() 48 | .url(recipientQueryUrl) 49 | .post(buildRecipientQuery(name, birthday, enterDate)) 50 | .build() 51 | ).execute() 52 | }.map { 53 | it.use { 54 | it.body()?.string() 55 | ?.let { 56 | Jsoup.parse(it) 57 | .select(recipientId) 58 | .fold( 59 | emptyList(), 60 | { acc, r -> 61 | r.parent().parent() 62 | .mapRecipient(name, birthday, enterDate) 63 | ?.let { 64 | acc + it 65 | } ?: acc 66 | } 67 | ) 68 | } ?: emptyList() 69 | } 70 | }.retry(3) 71 | 72 | /** 73 | * 훈련병을 검색을 위한 FormBody를 생성합니다. 74 | * 75 | * @param name 훈련병 이름 76 | * @param birthday 훈련병 생년월일 (yyMMdd) 77 | * @param enterDate 훈련병 입소일 (yyyyMMdd) 78 | */ 79 | private fun buildRecipientQuery(name: String, birthday: LocalDate, enterDate: LocalDate): FormBody { 80 | val formattedBirthday = birthday.format(birthdayFormatter) 81 | val formattedEnterDate = enterDate.format(enterDateFormatter) 82 | 83 | return FormBody.Builder() 84 | .add(recipientNameKey, name) 85 | .add(recipientBirthDayKey, formattedBirthday) 86 | .add(recipientEncodedBirthDayKey, formattedBirthday.toBase64()) 87 | .add(recipientEnterDateKey, formattedEnterDate.toBase64()) 88 | .build() 89 | } 90 | 91 | private fun Element.mapRecipient(name: String, birthday: LocalDate, enterDate: LocalDate): Recipient? = 92 | select(HtmlElement.TD.value) 93 | .takeIf { it.size == requiredCellSize } 94 | ?.let { 95 | Recipient( 96 | birthday, 97 | enterDate, 98 | name, 99 | it[recipientRegimentIdx].text().replace(nonNumberRegex, "").toInt(), 100 | it[recipientCompanyIdx].text().replace(nonNumberRegex, "").toInt(), 101 | it[recipientPlatoonIdx].text().replace(nonNumberRegex, "").toInt() 102 | ) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/repository/MessageRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.repository 2 | 3 | import io.devholic.epilogue.domain.MessageRepository 4 | import io.devholic.epilogue.entity.Recipient 5 | import io.devholic.epilogue.entity.SlackMessage 6 | import io.devholic.epilogue.extension.randomlyPick 7 | import io.reactivex.Single 8 | 9 | 10 | class MessageRepositoryImpl(slackUsername: String) : MessageRepository { 11 | 12 | private val contentPrefixes: List = 13 | listOf( 14 | "안녕하세오 <@$slackUsername> 이에오", 15 | "훈련소간 <@$slackUsername> 봇으로 또 왔네", 16 | "안드로이드팀의 ~귀요미~ <@$slackUsername> 이에오", 17 | "솔찍히 여러분 저 보고싶어하시는거 다 알고 왔읍니다", 18 | "맞지맞지 내말맞지 나보고싶지 :pepe:", 19 | "레하~ (레이니스트 하이라는 뜻) :doge:" 20 | ) 21 | 22 | override fun create(recipients: List, newsList: List, winner: String): Single = 23 | Single.fromCallable { 24 | SlackMessage( 25 | "${contentPrefixes.randomlyPick()}\n" + 26 | "<@$winner>님이 편지 써주면 얼마나 좋을까 :pepe-sad2:\n" + 27 | "(솔직히 겜블보다 이게 더 짜릿하지 않나용 :pepe:)\n" + 28 | "편지는 http://www.katc.mil.kr 에서 쓰실 수 있어용 :doge:\n" + 29 | if (recipients.size > 1) { 30 | "아! 동명이인이 있사오니 확인하시고 써주시면 감사하겠습니당 (이 중에 한명이에용 :doge:)\n" + 31 | recipients.joinToString(separator = "\n") { formatRecipient(it) } 32 | } else { 33 | recipients.first().let { formatRecipient(it) } + 34 | "\n감사합니당!\n" + 35 | "(쓸 내용이 없으시다면 아래 뉴스라도 보내주시면 감사하겠읍니다... :pepe-dance:)\n" + 36 | "```\n${newsList.joinToString(separator = " /")}\n```" 37 | } 38 | ) 39 | } 40 | 41 | private fun formatRecipient(recipient: Recipient): String = 42 | "> 생년월일 ${recipient.birthday} 입영일 ${recipient.enterDate} / " + 43 | "${recipient.regiment}연대 ${recipient.company}중대 ${recipient.platoon}소대" 44 | } 45 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/repository/NaverNewsRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.repository 2 | 3 | import io.devholic.epilogue.Network 4 | import io.devholic.epilogue.domain.NaverNewsRepository 5 | import io.devholic.epilogue.enum.NaverNewsCategory 6 | import io.devholic.epilogue.extension.toFormattedString 7 | import io.reactivex.Single 8 | import okhttp3.Request 9 | import org.jsoup.Jsoup 10 | import java.util.* 11 | 12 | 13 | class NaverNewsRepositoryImpl : NaverNewsRepository { 14 | 15 | private val rankingUrl = "http://m.news.naver.com/rankingList.nhn?sid1=%d&date=%s" 16 | private val headlineClassname = ".commonlist_tx_headline" 17 | 18 | override fun getHeadlineList(category: NaverNewsCategory, limit: Int): Single> = 19 | Single.fromCallable { 20 | Network.client 21 | .newCall( 22 | Request.Builder() 23 | .url(rankingUrl.format(category.sid1, Date().toFormattedString())) 24 | .build() 25 | ).execute() 26 | }.map { 27 | it.use { 28 | it.body()?.string()?.let { 29 | Jsoup.parse(it) 30 | .select(headlineClassname) 31 | .take(limit) 32 | .map { it.text() } 33 | } ?: emptyList() 34 | } 35 | }.retry(3) 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/repository/SlackRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.repository 2 | 3 | import io.devholic.epilogue.Network 4 | import io.devholic.epilogue.domain.SlackRepository 5 | import io.devholic.epilogue.entity.SlackMessage 6 | import io.devholic.epilogue.extension.randomlyPick 7 | import io.devholic.epilogue.request.SlackMessageRequest 8 | import io.devholic.epilogue.response.ChannelInfoResponse 9 | import io.devholic.epilogue.response.toEntity 10 | import io.reactivex.Completable 11 | import io.reactivex.Single 12 | import okhttp3.FormBody 13 | import okhttp3.Request 14 | import okhttp3.RequestBody 15 | 16 | 17 | class SlackRepositoryImpl( 18 | private val accessToken: String, 19 | private val webhookUrl: String 20 | ) : SlackRepository { 21 | 22 | private val slackChannelApiUrl = "https://slack.com/api/channels.info" 23 | private val tokenKey = "token" 24 | private val channelKey = "channel" 25 | 26 | override fun getWriterId( 27 | channelId: String, 28 | recipientId: String, 29 | defaultWriterId: String 30 | ): Single = 31 | Single.fromCallable { 32 | Network.client.newCall( 33 | Request.Builder() 34 | .url(slackChannelApiUrl) 35 | .post( 36 | FormBody.Builder() 37 | .add(tokenKey, accessToken) 38 | .add(channelKey, channelId) 39 | .build() 40 | ) 41 | .build() 42 | ).execute() 43 | .use { 44 | it.body()?.let { 45 | Network.gson.fromJson(it.string(), ChannelInfoResponse::class.java) 46 | .toEntity() 47 | .channel 48 | .members 49 | .filter { it != recipientId } 50 | .toMutableList() 51 | .randomlyPick() 52 | } ?: defaultWriterId 53 | } 54 | }.retry(3) 55 | 56 | override fun sendMessage(message: SlackMessage): Completable = 57 | Completable.fromCallable { 58 | Network.client.newCall( 59 | Request.Builder() 60 | .url(webhookUrl) 61 | .post( 62 | RequestBody.create( 63 | Network.jsonMediaType, 64 | Network.gson.toJson(SlackMessageRequest.fromEntity(message)) 65 | ) 66 | ) 67 | .build() 68 | ).execute().close() 69 | }.retry(3) 70 | } 71 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/request/SlackMessageRequest.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.request 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import io.devholic.epilogue.entity.SlackMessage 5 | 6 | 7 | data class SlackMessageRequest( 8 | 9 | @SerializedName("text") 10 | val message: String 11 | ) { 12 | 13 | companion object { 14 | fun fromEntity(entity: SlackMessage): SlackMessageRequest = 15 | SlackMessageRequest(entity.message) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/response/ChannelInfoResponse.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.response 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import io.devholic.epilogue.entity.ChannelInfo 5 | 6 | 7 | data class ChannelInfoResponse( 8 | 9 | @SerializedName("channel") 10 | val channel: ChannelResponse 11 | ) 12 | 13 | fun ChannelInfoResponse.toEntity(): ChannelInfo = ChannelInfo(channel.toEntity()) 14 | -------------------------------------------------------------------------------- /src/main/kotlin/io/devholic/epilogue/response/ChannelResponse.kt: -------------------------------------------------------------------------------- 1 | package io.devholic.epilogue.response 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import io.devholic.epilogue.entity.Channel 5 | 6 | 7 | data class ChannelResponse( 8 | 9 | @SerializedName("members") 10 | val members: List 11 | ) 12 | 13 | fun ChannelResponse.toEntity(): Channel = Channel(members) 14 | --------------------------------------------------------------------------------