Skip to content

Commit d1ba7e3

Browse files
committed
Word explainer processor
1 parent 8da6a79 commit d1ba7e3

File tree

9 files changed

+282
-0
lines changed

9 files changed

+282
-0
lines changed

english/README.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
This is an umbrella feature for several smaller sub-features:
44

55
* The bot sends https://www.urbandictionary.com[Urban Word of the Day] into English rooms.
6+
* The bot explains emphasised text parts in English rooms.

english/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ dependencies {
77
implementation(projects.english.languageRooms)
88
implementation(projects.english.urbanWordOfTheDay)
99
implementation(projects.english.urbanWordOfTheDayFormatter)
10+
implementation(projects.english.urbanDictionary)
11+
implementation(projects.english.dictionaryapiDev)
1012
implementation(libs.log4j.api)
1113

1214
testImplementation(libs.junit.jupiter.api)
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
package by.jprof.telegram.bot.english
2+
3+
import by.jprof.telegram.bot.core.UpdateProcessor
4+
import by.jprof.telegram.bot.english.dictionaryapi_dev.DictionaryAPIDotDevClient
5+
import by.jprof.telegram.bot.english.dictionaryapi_dev.Meaning
6+
import by.jprof.telegram.bot.english.dictionaryapi_dev.Phonetic
7+
import by.jprof.telegram.bot.english.dictionaryapi_dev.Word
8+
import by.jprof.telegram.bot.english.language_rooms.dao.LanguageRoomDAO
9+
import by.jprof.telegram.bot.english.language_rooms.model.Language
10+
import by.jprof.telegram.bot.english.urban_dictionary.Definition
11+
import by.jprof.telegram.bot.english.urban_dictionary.UrbanDictionaryClient
12+
import by.jprof.telegram.bot.english.utils.iVeExplainedSomeWordsForYou
13+
import dev.inmo.tgbotapi.bot.RequestsExecutor
14+
import dev.inmo.tgbotapi.extensions.api.send.reply
15+
import dev.inmo.tgbotapi.extensions.utils.asBaseMessageUpdate
16+
import dev.inmo.tgbotapi.extensions.utils.asContentMessage
17+
import dev.inmo.tgbotapi.extensions.utils.asTextContent
18+
import dev.inmo.tgbotapi.types.message.MarkdownV2
19+
import dev.inmo.tgbotapi.types.message.content.TextContent
20+
import dev.inmo.tgbotapi.types.message.textsources.BoldTextSource
21+
import dev.inmo.tgbotapi.types.message.textsources.CodeTextSource
22+
import dev.inmo.tgbotapi.types.message.textsources.ItalicTextSource
23+
import dev.inmo.tgbotapi.types.message.textsources.UnderlineTextSource
24+
import dev.inmo.tgbotapi.types.message.textsources.bold
25+
import dev.inmo.tgbotapi.types.message.textsources.link
26+
import dev.inmo.tgbotapi.types.message.textsources.regular
27+
import dev.inmo.tgbotapi.types.message.textsources.underline
28+
import dev.inmo.tgbotapi.types.update.abstracts.Update
29+
import java.time.Duration
30+
import kotlinx.coroutines.CoroutineScope
31+
import kotlinx.coroutines.Deferred
32+
import kotlinx.coroutines.async
33+
import kotlinx.coroutines.supervisorScope
34+
import kotlinx.coroutines.time.withTimeoutOrNull
35+
import org.apache.logging.log4j.LogManager
36+
37+
class ExplainerUpdateProcessor(
38+
private val languageRoomDAO: LanguageRoomDAO,
39+
private val urbanDictionaryClient: UrbanDictionaryClient,
40+
private val dictionaryapiDevClient: DictionaryAPIDotDevClient,
41+
private val bot: RequestsExecutor,
42+
) : UpdateProcessor {
43+
companion object {
44+
private val logger = LogManager.getLogger(ExplainerUpdateProcessor::class.java)!!
45+
}
46+
47+
override suspend fun process(update: Update) {
48+
val update = update.asBaseMessageUpdate() ?: return
49+
val roomId = update.data.chat.id
50+
val message = update.data.asContentMessage() ?: return
51+
val content = message.content.asTextContent() ?: return
52+
53+
if (
54+
languageRoomDAO.get(roomId.chatId, roomId.threadId)?.takeIf { it.language == Language.ENGLISH } == null
55+
) {
56+
return
57+
}
58+
59+
val emphasizedWords = extractEmphasizedWords(content)
60+
61+
logger.debug("Emphasized words: $emphasizedWords")
62+
63+
val explanations = fetchExplanations(emphasizedWords)
64+
65+
logger.debug("Explanations: $explanations")
66+
67+
explanations.keys.sorted().forEach { word ->
68+
bot.reply(
69+
to = message,
70+
text = buildString {
71+
appendLine(regular(iVeExplainedSomeWordsForYou()).markdownV2)
72+
appendLine()
73+
74+
dictionaryDotDevExplanations(explanations.dictionaryDotDev[word])
75+
urbanDictionaryExplanations(explanations.urbanDictionary[word])
76+
},
77+
parseMode = MarkdownV2,
78+
disableWebPagePreview = true,
79+
)
80+
}
81+
}
82+
83+
private fun extractEmphasizedWords(content: TextContent) = content.textSources
84+
.filter {
85+
it is BoldTextSource
86+
|| it is CodeTextSource
87+
|| it is ItalicTextSource
88+
|| it is UnderlineTextSource
89+
90+
}
91+
.map { it.source }
92+
93+
private data class Explanations(
94+
val urbanDictionary: Map<String, Collection<Definition>>,
95+
val dictionaryDotDev: Map<String, Collection<Word>>,
96+
) {
97+
val keys by lazy {
98+
urbanDictionary.keys + dictionaryDotDev.keys
99+
}
100+
}
101+
102+
private suspend fun fetchExplanations(terms: Collection<String>) = supervisorScope {
103+
val urbanDictionaryDefinitions = terms.map { word ->
104+
asyncWithTimeout(Duration.ofSeconds(5)) {
105+
word to urbanDictionaryClient.define(word)
106+
}
107+
}
108+
val dictionaryDevDefinitions = terms.map { word ->
109+
asyncWithTimeout(Duration.ofSeconds(5)) {
110+
word to dictionaryapiDevClient.define(word)
111+
}
112+
}
113+
114+
Explanations(
115+
urbanDictionary = urbanDictionaryDefinitions.await().toMap(),
116+
dictionaryDotDev = dictionaryDevDefinitions.await().toMap(),
117+
)
118+
}
119+
120+
private suspend fun <T> CoroutineScope.asyncWithTimeout(
121+
timeout: Duration,
122+
block: suspend CoroutineScope.() -> T,
123+
) = async {
124+
withTimeoutOrNull(timeout) { block() }
125+
}
126+
127+
private suspend fun <T> List<Deferred<T>>.await() = this.mapNotNull {
128+
try {
129+
it.await()
130+
} catch (_: Exception) {
131+
null
132+
}
133+
}
134+
135+
private fun StringBuilder.dictionaryDotDevExplanations(dictionaryDotDevExplanations: Collection<Word>?) {
136+
dictionaryDotDevExplanations?.let { definitions ->
137+
definitions.take(3).forEachIndexed { index, definition ->
138+
val link = definition.sourceUrls?.firstOrNull()
139+
140+
if (link != null) {
141+
append(bold(link(definition.word, link)).markdownV2)
142+
} else {
143+
append(bold(definition.word).markdownV2)
144+
}
145+
append(regular(" @ ").markdownV2)
146+
append(link("Free Dictionary API", "https://dictionaryapi.dev").markdownV2)
147+
appendLine()
148+
appendLine()
149+
150+
definition.phonetics?.takeUnless(Collection<Phonetic>::isEmpty)?.let { phonetics ->
151+
appendLine(
152+
phonetics.mapNotNull { phonetic ->
153+
val text = phonetic.text?.takeUnless(String::isNullOrBlank)
154+
val audio = phonetic.audio?.takeUnless(String::isNullOrBlank)
155+
156+
when {
157+
text != null && audio != null -> link("\uD83D\uDDE3$text", audio).markdownV2
158+
text != null -> regular(text).markdownV2
159+
audio != null -> link("\uD83D\uDDE3", audio).markdownV2
160+
else -> null
161+
}
162+
}.joinToString(regular("").markdownV2)
163+
)
164+
}
165+
166+
definition.meanings?.takeUnless(Collection<Meaning>::isEmpty)?.take(3)?.toList()?.let { meanings ->
167+
appendLine()
168+
169+
meanings.forEachIndexed { meaningIndex, meaning ->
170+
appendLine(underline(meaning.partOfSpeech).markdownV2)
171+
172+
meaning.definitions.toList().let { definitions ->
173+
definitions.forEachIndexed { definitionIndex, definition ->
174+
append(regular("\uD83D\uDC49 ").markdownV2)
175+
append(regular(definition.definition).markdownV2)
176+
177+
definition.example?.let { example ->
178+
appendLine()
179+
append(regular("✍️ ").markdownV2)
180+
append(regular(example).markdownV2)
181+
}
182+
183+
if (definitionIndex != definitions.lastIndex) {
184+
appendLine()
185+
appendLine()
186+
}
187+
}
188+
}
189+
190+
if (meaningIndex != meanings.lastIndex) {
191+
appendLine()
192+
appendLine()
193+
}
194+
}
195+
}
196+
}
197+
}
198+
}
199+
200+
private fun StringBuilder.urbanDictionaryExplanations(urbanDictionaryExplanations: Collection<Definition>?) {
201+
urbanDictionaryExplanations?.let {
202+
if (this.lines().size > 3) {
203+
appendLine()
204+
appendLine()
205+
}
206+
207+
val topDefinitions = it.sortedBy(Definition::thumbsUp).take(3)
208+
209+
topDefinitions.forEachIndexed { index, definition ->
210+
append(bold(link(definition.word, definition.permalink)).markdownV2)
211+
append(regular(" @ ").markdownV2)
212+
append(link("Urban Dictionary", "https://urbandictionary.com").markdownV2)
213+
appendLine()
214+
appendLine()
215+
216+
append(regular("\uD83D\uDC49 ").markdownV2)
217+
append(regular(definition.definition).markdownV2)
218+
appendLine()
219+
220+
append(regular("✍️ ").markdownV2)
221+
append(regular(definition.example).markdownV2)
222+
223+
if (index != topDefinitions.lastIndex) {
224+
appendLine()
225+
appendLine()
226+
}
227+
}
228+
}
229+
}
230+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package by.jprof.telegram.bot.english.utils
2+
3+
private val iVeExplainedSomeWordsForYouMessages = listOf(
4+
"Yo, I've explained some words for you \uD83D\uDC47",
5+
"Here are some explanations \uD83D\uDC47",
6+
"You've emphasized some words, so I decided to explain them \uD83D\uDC47",
7+
)
8+
9+
internal fun iVeExplainedSomeWordsForYou(): String {
10+
return iVeExplainedSomeWordsForYouMessages.random()
11+
}

launchers/lambda/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,6 @@ dependencies {
2626
implementation(project.projects.english)
2727
implementation(project.projects.english.languageRooms.dynamodb)
2828
implementation(project.projects.english.urbanWordOfTheDay.dynamodb)
29+
implementation(project.projects.english.urbanDictionary)
30+
implementation(project.projects.english.dictionaryapiDev)
2931
}

launchers/lambda/src/main/kotlin/by/jprof/telegram/bot/launchers/lambda/JProf.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ package by.jprof.telegram.bot.launchers.lambda
33
import by.jprof.telegram.bot.core.UpdateProcessingPipeline
44
import by.jprof.telegram.bot.launchers.lambda.config.currenciesModule
55
import by.jprof.telegram.bot.launchers.lambda.config.databaseModule
6+
import by.jprof.telegram.bot.launchers.lambda.config.dictionaryApiDevModule
67
import by.jprof.telegram.bot.launchers.lambda.config.envModule
78
import by.jprof.telegram.bot.launchers.lambda.config.jsonModule
89
import by.jprof.telegram.bot.launchers.lambda.config.pipelineModule
910
import by.jprof.telegram.bot.launchers.lambda.config.sfnModule
1011
import by.jprof.telegram.bot.launchers.lambda.config.telegramModule
12+
import by.jprof.telegram.bot.launchers.lambda.config.urbanDictionaryModule
1113
import by.jprof.telegram.bot.launchers.lambda.config.youtubeModule
1214
import com.amazonaws.services.lambda.runtime.Context
1315
import com.amazonaws.services.lambda.runtime.RequestHandler
@@ -51,6 +53,8 @@ class JProf : RequestHandler<APIGatewayV2HTTPEvent, APIGatewayV2HTTPResponse>, K
5153
pipelineModule,
5254
sfnModule,
5355
currenciesModule,
56+
urbanDictionaryModule,
57+
dictionaryApiDevModule,
5458
)
5559
}
5660
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package by.jprof.telegram.bot.launchers.lambda.config
2+
3+
import by.jprof.telegram.bot.english.dictionaryapi_dev.DictionaryAPIDotDevClient
4+
import by.jprof.telegram.bot.english.dictionaryapi_dev.KtorDictionaryAPIDotDevClient
5+
import org.koin.dsl.module
6+
7+
val dictionaryApiDevModule = module {
8+
single<DictionaryAPIDotDevClient> {
9+
KtorDictionaryAPIDotDevClient()
10+
}
11+
}

launchers/lambda/src/main/kotlin/by/jprof/telegram/bot/launchers/lambda/config/pipeline.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import by.jprof.telegram.bot.core.UpdateProcessingPipeline
44
import by.jprof.telegram.bot.core.UpdateProcessor
55
import by.jprof.telegram.bot.currencies.CurrenciesUpdateProcessor
66
import by.jprof.telegram.bot.english.EnglishCommandUpdateProcessor
7+
import by.jprof.telegram.bot.english.ExplainerUpdateProcessor
78
import by.jprof.telegram.bot.english.UrbanWordOfTheDayUpdateProcessor
89
import by.jprof.telegram.bot.eval.EvalUpdateProcessor
910
import by.jprof.telegram.bot.jep.JEPUpdateProcessor
@@ -165,4 +166,13 @@ val pipelineModule = module {
165166
bot = get(),
166167
)
167168
}
169+
170+
single<UpdateProcessor>(named("ExplainerUpdateProcessor")) {
171+
ExplainerUpdateProcessor(
172+
languageRoomDAO = get(),
173+
urbanDictionaryClient = get(),
174+
dictionaryapiDevClient = get(),
175+
bot = get(),
176+
)
177+
}
168178
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package by.jprof.telegram.bot.launchers.lambda.config
2+
3+
import by.jprof.telegram.bot.english.urban_dictionary.KtorUrbanDictionaryClient
4+
import by.jprof.telegram.bot.english.urban_dictionary.UrbanDictionaryClient
5+
import org.koin.dsl.module
6+
7+
val urbanDictionaryModule = module {
8+
single<UrbanDictionaryClient> {
9+
KtorUrbanDictionaryClient()
10+
}
11+
}

0 commit comments

Comments
 (0)