Skip to content

Commit c47ed63

Browse files
committed
Split InteractiveConversation module from BotEventRouteService to a new file
1 parent add06d4 commit c47ed63

1 file changed

Lines changed: 370 additions & 0 deletions

File tree

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
/*
2+
* Copyright 2020-2021 KonnyakuCamp.
3+
*
4+
* 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
5+
* Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
6+
*
7+
* https://github.com/KonnyakuCamp/SuperCourseTimetableBot/blob/main/LICENSE
8+
*/
9+
@file:Suppress("unused", "unchecked")
10+
package stageguard.sctimetable.utils
11+
12+
import kotlinx.coroutines.*
13+
import net.mamoe.mirai.Bot
14+
import net.mamoe.mirai.console.util.cast
15+
import net.mamoe.mirai.contact.Contact
16+
import net.mamoe.mirai.event.events.MessageEvent
17+
import net.mamoe.mirai.message.data.Message
18+
import net.mamoe.mirai.message.data.PlainText
19+
import net.mamoe.mirai.message.data.firstIsInstanceOrNull
20+
import net.mamoe.mirai.message.nextMessage
21+
import kotlin.contracts.ExperimentalContracts
22+
import kotlin.contracts.contract
23+
24+
/**
25+
* 交互式对话对象。
26+
*
27+
* 使用 [interactiveConversation] 创建一个交互式对话
28+
*
29+
* 限定 [judge], [receive], [select] 和 [collect] 在这个对象中使用。
30+
*
31+
* @see interactiveConversation
32+
* @see judge
33+
* @see receive
34+
* @see select
35+
* @see collect
36+
*/
37+
class InteractiveConversationBuilder(
38+
private val eventContext: MessageEvent,
39+
private val coroutineScope: CoroutineScope,
40+
private val timeoutLimitation: Long,
41+
private val tryCountLimitation: Int
42+
) {
43+
private val target: Contact = eventContext.subject
44+
private val bot: Bot = eventContext.bot
45+
val capturedList: MutableMap<String, Any> = mutableMapOf()
46+
47+
/**
48+
* 发送一条文本消息
49+
*/
50+
suspend fun send(msg: String) { if (bot.isOnline) target.sendMessage(msg) }
51+
/**
52+
* 发送一条消息
53+
*/
54+
suspend fun send(msg: Message) { if (bot.isOnline) target.sendMessage(msg) }
55+
56+
/**
57+
* 将任何值存入捕获列表。
58+
*
59+
* 使用:
60+
* ```
61+
* interactiveConversation {
62+
* val foo: Foo = xxx
63+
* val bar: Bar = xxx
64+
* collect("foo", foo)
65+
* collect("bar", bar)
66+
* }.finish {
67+
* val foo = it["foo"].cast<Foo>()
68+
* val bar: Bar = it["bar"].cast()
69+
* }
70+
* ```
71+
* @param key 键名称
72+
*/
73+
fun <T: Any> collect(key: String, any: T) {
74+
capturedList[key] = any
75+
}
76+
77+
/**
78+
* 同 [receive] 阻塞对话过程来监听下一条消息,但不存入捕获列表。
79+
*
80+
* 通常用于判断消息。
81+
*
82+
* @return 返回监听到的消息。
83+
* @see receive
84+
*/
85+
suspend fun judge(
86+
tryLimit: Int = tryCountLimitation,
87+
timeoutLimit: Long = timeoutLimitation,
88+
checkBlock: ((String) -> Boolean)? = null
89+
) : String = receive(tryLimit, timeoutLimit, null, checkBlock)
90+
91+
/**
92+
* 阻塞对话过程来监听下一条消息,并存储到捕获列表中。
93+
*
94+
* [checkBlock] 不为 `null`时会对消息进行判断,判断为 `false` 或出现错误时会反复询问,直到符合要求。
95+
*
96+
* 使用:
97+
* ```
98+
* interactiveConversation {
99+
* //不进行判断,直接存储
100+
* receive(key = "foo")
101+
* //正则匹配
102+
* receive(key = "bar") {
103+
* Pattern.compile("...").matcher(it).find()
104+
* }
105+
* ///转换为 Int 进行判断。
106+
* receive(key = "baz") { it.toInt() > 0 }
107+
* }
108+
* ```
109+
*
110+
* @param tryLimit 设置尝试最大次数。会覆盖在 [interactiveConversation] 中设置的 `eachTryLimit`。
111+
* @param timeoutLimit 设置每次监听的最大时长限制,会覆盖在 [interactiveConversation] 中设置的 `eachTimeLimit`。
112+
* @param key 键名称
113+
*
114+
* @return 返回监听到的消息。
115+
*
116+
* @see [judge]
117+
* @see [select]
118+
*/
119+
suspend fun receive(
120+
tryLimit: Int = tryCountLimitation,
121+
timeoutLimit: Long = timeoutLimitation,
122+
key: String? = null,
123+
checkBlock: ((String) -> Boolean)? = null,
124+
): String {
125+
if(checkBlock == null) {
126+
(eventContext.nextMessage(timeoutLimit).firstIsInstanceOrNull<PlainText>()?.content ?: "").also {
127+
if(key != null) capturedList[key] = it
128+
return it
129+
}
130+
} else {
131+
if(tryLimit == -1) {
132+
while (true) {
133+
val plainText = eventContext.nextMessage(timeoutLimit).firstIsInstanceOrNull<PlainText>()?.content ?: ""
134+
kotlin.runCatching {
135+
if(checkBlock(plainText)) {
136+
if(key != null) capturedList[key] = plainText
137+
return plainText
138+
}
139+
}
140+
send("输入不符合要求,请重新输入!")
141+
}
142+
} else {
143+
repeat(tryLimit) {
144+
val plainText = eventContext.nextMessage(timeoutLimit).firstIsInstanceOrNull<PlainText>()?.content ?: ""
145+
kotlin.runCatching {
146+
if(checkBlock(plainText)) {
147+
if(key != null) capturedList[key] = plainText
148+
return plainText
149+
}
150+
}
151+
if(it != tryLimit - 1) send("输入不符合要求,请重新输入!")
152+
}
153+
//会话直接结束
154+
throw QuitConversationExceptions.IllegalInputException()
155+
}
156+
}
157+
}
158+
159+
/**
160+
* 阻塞对话过程来监听下一条消息,并进行类似 `when` 一样的选择判断
161+
*
162+
* 可以经过初步筛选后再判断,筛选实际上是委托给 [receive] 进行的。
163+
*
164+
* 使用:
165+
* ```
166+
* interactiveConversation {
167+
* //无筛选条件监听
168+
* select {
169+
* //当消息为 foo 时执行
170+
* "foo" { }
171+
* //当消息为 bar 时执行
172+
* "bar" { }
173+
* //当上面任何都不匹配时执行
174+
* default { }
175+
* }
176+
* //初步筛选后监听
177+
* select({ it == "foo" || it == "bar" }) {
178+
* //当消息为 foo 时执行
179+
* "foo" { }
180+
* //当消息为 bar 时执行
181+
* "bar" { }
182+
* //不必需要 default,因为经过初步筛选只有上述两种情况
183+
* }
184+
* }
185+
* ```
186+
* @param key 当不为 `null` 时将监听到的消息存储进捕获列表。
187+
* @see receive
188+
*/
189+
suspend fun select(
190+
checkBlock: ((String) -> Boolean)? = null,
191+
tryLimit: Int = tryCountLimitation,
192+
timeoutLimit: Long = timeoutLimitation,
193+
key: String? = null,
194+
runBlock: suspend SelectionLambdaExpression.(String) -> Unit
195+
) = SelectionLambdaExpression(receive(tryLimit, timeoutLimit, key, checkBlock))(runBlock)
196+
197+
class SelectionLambdaExpression (private val content: String) {
198+
private var matches = false
199+
200+
/**
201+
* 在 [select] 中判断消息为这个字符串时执行
202+
*/
203+
suspend operator fun String.invoke(caseLambda: suspend () -> Unit) {
204+
if(content == this) {
205+
matches = true
206+
caseLambda()
207+
}
208+
}
209+
210+
/**
211+
* 当任何字符都不匹配时执行。
212+
*
213+
* 只有放到最底部才有效果。
214+
*/
215+
suspend fun default(caseLambda: suspend () -> Unit) {
216+
if(!matches) caseLambda()
217+
}
218+
//For execute
219+
suspend operator fun invoke(runBlock: suspend SelectionLambdaExpression.(String) -> Unit) = runBlock(content)
220+
}
221+
222+
/**
223+
* 提前结束会话。
224+
* 并在 [exception] 中抛出 [QuitConversationExceptions.AdvancedQuitException]
225+
*/
226+
fun finish() { throw QuitConversationExceptions.AdvancedQuitException() }
227+
}
228+
229+
sealed class QuitConversationExceptions : Exception() {
230+
/**
231+
* 提前结束会话时抛出
232+
* @see InteractiveConversationBuilder.finish
233+
*/
234+
class AdvancedQuitException : QuitConversationExceptions()
235+
236+
/**
237+
* 超过了尝试次数后未捕获到合适的消息时抛出
238+
*/
239+
class IllegalInputException : QuitConversationExceptions()
240+
241+
/**
242+
* 超过了时间未捕获到适合的消息时抛出
243+
*/
244+
class TimeoutException : QuitConversationExceptions()
245+
}
246+
247+
/**
248+
* 交互式对话的创建器
249+
*
250+
* 适用于任何继承 [MessageEvent] 的消息事件
251+
*
252+
* 使用:
253+
* ```
254+
* interactiveConversation { } //创建
255+
* .finish { } //正常结束
256+
* .exception { } //异常结束
257+
*
258+
* ```
259+
*
260+
* @param scope 协程作用域,默认为全局作用域 [GlobalScope]。
261+
* 建议制定为*插件作用域*或自定义协程作用域。
262+
* @param eachTimeLimit 每一次捕获消息的最大等待时间,默认为 `-1L` 即无限等待。
263+
* 若超过了这个时间未捕获到适合的消息,则在 [exception] 中抛出 [QuitConversationExceptions.IllegalInputException]。
264+
* @param eachTryLimit 每一次捕获消息时允许尝试的最大次数。
265+
* 若超过了这个次数未捕获到适合的消息,则在 [exception] 中抛出 [QuitConversationExceptions.TimeoutException]。
266+
* @see InteractiveConversationBuilder.receive
267+
* @see InteractiveConversationBuilder.judge
268+
* @see InteractiveConversationBuilder.select
269+
* @see InteractiveConversationBuilder.collect
270+
* @see InteractiveConversationBuilder.finish
271+
* @see finish
272+
* @see exception
273+
*/
274+
inline fun <T : MessageEvent> T.interactiveConversation(
275+
scope: CoroutineScope = GlobalScope,
276+
eachTimeLimit: Long = -1L,
277+
eachTryLimit: Int = -1,
278+
crossinline block: suspend InteractiveConversationBuilder.() -> Unit
279+
): Pair<CoroutineScope, Deferred<Either<InteractiveConversationBuilder, QuitConversationExceptions>>> = scope to scope.async (
280+
scope.coroutineContext
281+
) {
282+
try {
283+
InteractiveConversationBuilder(
284+
eventContext = this@interactiveConversation,
285+
coroutineScope = scope,
286+
tryCountLimitation = eachTryLimit,
287+
timeoutLimitation = eachTimeLimit
288+
).also { block(it) }.let { Either.Left(it) }
289+
} catch (ex: Exception) {
290+
when(ex) {
291+
is TimeoutCancellationException -> Either.Right(QuitConversationExceptions.TimeoutException())
292+
else -> Either.Right(ex as QuitConversationExceptions)
293+
}
294+
}
295+
}
296+
297+
/**
298+
* 会话非正常退出时调用这个函数
299+
* 处理方式:
300+
* ```
301+
* .exception { when(it) {
302+
* is QuitConversationExceptions.TimeoutException -> {} //超时结束
303+
* is QuitConversationExceptions.AdvancedQuitException -> {} //提前结束会话
304+
* is QuitConversationExceptions.IllegalInputException -> {} //输入有误次数过多
305+
* } }
306+
* ```
307+
* 不必为 `when` 添加 `else` 分支,因为 [QuitConversationExceptions] 是一个 `sealed class`
308+
*/
309+
fun Pair<CoroutineScope, Deferred<Either<InteractiveConversationBuilder, QuitConversationExceptions>>>.exception(
310+
failed: suspend (QuitConversationExceptions) -> Unit = { }
311+
) : Pair<CoroutineScope, Deferred<Either<InteractiveConversationBuilder, QuitConversationExceptions>>> {
312+
first.launch(first.coroutineContext) {
313+
when(val icBuilder = this@exception.second.await()) { is Either.Right -> failed(icBuilder.value) }
314+
}
315+
return this
316+
}
317+
318+
/**
319+
* 会话正常退出时调用这个函数。
320+
*
321+
* 在 [interactiveConversation] 中通过 [InteractiveConversationBuilder.receive], [InteractiveConversationBuilder.collect] 和 [InteractiveConversationBuilder.select] (指定 `key`) 的接收最终都会在这里捕获。
322+
*
323+
* 捕获结果是一个 [Map],`Key` 为 `String` 类型, `Value` 为 `Any` 类型。
324+
*
325+
* 使用方式:
326+
* ```
327+
* .finish {
328+
* //通过 `checkBlock` 筛选你希望捕获一个 Number。
329+
* val number = it["key1"].cast<String>().toInt()
330+
* //通过 `checkBlock` 筛选你希望捕获一个 String
331+
* val str: String = it.cast()
332+
* }
333+
* ```
334+
* 使用 [cast] 做类型转换,即把 `Any` 类型转换为你需要的类型。
335+
*
336+
* 除了使用 [InteractiveConversationBuilder.collect] 捕获的,其他捕获存储于 `Map` 中的类型实际都为 `String` (因为消息都为文本消息)。
337+
*
338+
* 所以如果想捕获一个 `Number`,你可以这样:
339+
* ```
340+
* interactiveConversation {
341+
* receive(key = "number1") { it.toInt() }
342+
* collect(key = "number2", judge { it.toInt() }.toInt())
343+
* }.finish {
344+
* val number1 = it["number1"].cast<String>().toInt()
345+
* val number2 = it["number2"].cast<Int>()
346+
* }
347+
* ```
348+
* 注意:[cast] 会忽略 `Map` 中的 `null` 检查!
349+
*
350+
*/
351+
fun Pair<CoroutineScope, Deferred<Either<InteractiveConversationBuilder, QuitConversationExceptions>>>.finish(
352+
success: suspend (Map<String, Any>) -> Unit = { }
353+
) : Pair<CoroutineScope, Deferred<Either<InteractiveConversationBuilder, QuitConversationExceptions>>> {
354+
first.launch(first.coroutineContext) {
355+
when(val icBuilder = this@finish.second.await()) { is Either.Left -> success(icBuilder.value.capturedList.toMap()) }
356+
}
357+
return this
358+
}
359+
360+
/**
361+
* Perform this as T.
362+
*
363+
* `a.cast<B>()` 相当于 `a as B`
364+
* @see cast
365+
*/
366+
@ExperimentalContracts
367+
inline fun <reified T : Any> Any?.cast(): T {
368+
contract { returns() implies (this@cast is T) }
369+
return this as T
370+
}

0 commit comments

Comments
 (0)