base framework

This commit is contained in:
ryoii 2020-02-03 03:12:44 +08:00
parent 30d41f6e9d
commit 4b9900e0bd
14 changed files with 590 additions and 33 deletions

View File

@ -3,12 +3,11 @@
<b>
Mirai-API-http 提供HTTP API供所有语言使用mirai<br>
</b>
### 开始会话-认证(Authorize)
```php
路径: /auth
方法: POST
```
[POST] /auth
```
使用此方法验证你的会话连接, 并将这个会话绑定一个BOT<br>
注意: 每个会话只能绑定一个BOT.
@ -20,36 +19,34 @@ Mirai-API-http 提供HTTP API供所有语言使用mirai<br>
| key | String |false|U9HSaDXl39ksd918273hU|MIRAI API HTTP key, HTTP API的核心key|
| qq | String |false|1040400290|需要绑定的BOT QQ号|
#### 返回(成功):<br>
| 名字 | 类型 | 举例 | 说明|
| --- | --- | --- | --- |
| success |Boolean |true|是否验证成功|
| code |Int |0|返回状态|
| session |String |UANSHDKSLAOISN|你的session key|
#### 返回(失败):<br>
| name | type | example|note|
| --- | --- | --- | --- |
| success |Boolean |false|是否验证成功|
| session |String |null|你的session key|
| error |int |0|错误码|
#### 错误码:<br>
#### 状态码:<br>
| 代码 | 原因|
| --- | --- |
| 0 | 错误的MIRAI API HTTP key |
| 1 | 试图绑定不存在的bot|
| 0 | 正常 |
| 1 | 错误的MIRAI API HTTP key|
| 2 | 试图绑定不存在的bot|
session key 是使用以下方法必须携带的</br>
session key 需要被以cookie的形式上报 <b>cookies</b> :
| name | value |
| --- | --- |
| session |your session key here |
如果出现HTTP 403错误码代表session key已过期, 需要重新获取
| 名字 | 值 |
| --- | --- |
| session |your session key here |
如果出现HTTP 403错误码代表session key已过期, 需要重新获取
### 发送好友消息
```
[POST] /sendFriendMessage
```

View File

@ -42,6 +42,7 @@ kotlin {
implementation(ktor("server-cio"))
implementation(kotlinx("io-jvm", kotlinXIoVersion))
implementation(ktor("http-jvm"))
implementation("org.slf4j:slf4j-simple:1.7.26")
}
}

View File

@ -0,0 +1,42 @@
package net.mamoe.mirai.api.http
import io.ktor.application.Application
import io.ktor.server.cio.CIO
import io.ktor.server.engine.embeddedServer
import io.ktor.util.KtorExperimentalAPI
import net.mamoe.mirai.api.http.route.mirai
import net.mamoe.mirai.utils.DefaultLogger
object MiraiHttpAPIServer {
private val logger = DefaultLogger("Mirai HTTP API")
init {
SessionManager.authKey = generateSessionKey()//用于验证的key, 使用和SessionKey相同的方法生成, 但意义不同
}
@UseExperimental(KtorExperimentalAPI::class)
fun start(
port: Int = 8080,
authKey: String? = null,
callback: (() -> Unit)? = null
) {
authKey?.apply {
if (authKey.length in 8..128) {
SessionManager.authKey = authKey
} else {
logger.error("Expected authKey length is between 8 to 128")
}
}
// TODO: start是无阻塞的理应获取启动状态后再执行后续代码
try {
embeddedServer(CIO, port, module = Application::mirai).start()
logger.info("Http api server is running with authKey: ${SessionManager.authKey}")
callback?.invoke()
} catch (e: Exception) {
logger.error("Http api server launch error")
}
}
}

View File

@ -1,9 +1,11 @@
package net.mamoe.mirai.api.http
import kotlinx.coroutines.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonConfiguration
import java.lang.StringBuilder
import net.mamoe.mirai.Bot
import net.mamoe.mirai.api.http.queue.MessageQueue
import net.mamoe.mirai.event.Listener
import net.mamoe.mirai.event.subscribeMessages
import net.mamoe.mirai.message.MessagePacket
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
@ -44,6 +46,10 @@ object SessionManager {
}
}
operator fun get(sessionKey: String) = allSession[sessionKey]
fun containSession(sessionKey: String): Boolean = allSession.containsKey(sessionKey)
fun closeSession(sessionKey: String) = allSession.remove(sessionKey)?.also {it.close() }
fun closeSession(session: Session) = closeSession(session.key)
@ -69,7 +75,7 @@ abstract class Session internal constructor(
val key:String = generateSessionKey()
internal fun close(){
internal open fun close(){
supervisorJob.complete()
}
}
@ -89,11 +95,20 @@ class TempSession internal constructor(coroutineContext: CoroutineContext) : Ses
* 任何[TempSession]认证后转化为一个[AuthedSession]
* 在这一步[AuthedSession]应该已经有assigned的bot
*/
class AuthedSession internal constructor(val botNumber:Int, coroutineContext: CoroutineContext):Session(coroutineContext){
class AuthedSession internal constructor(val bot: Bot, coroutineContext: CoroutineContext):Session(coroutineContext){
val messageQueue = MessageQueue()
private val _listener : Listener<MessagePacket<*, *>>
init {
bot.subscribeMessages {
_listener = always { this.run(messageQueue::add) }
}
}
override fun close() {
_listener.complete()
super.close()
}
}

View File

@ -0,0 +1,9 @@
package net.mamoe.mirai.api.http.dto
import kotlinx.serialization.Serializable
@Serializable
data class AuthDTO(val authKey: String) : DTO
@Serializable
data class AuthResDTO(val code: Int, val session: String) : DTO

View File

@ -0,0 +1,39 @@
package net.mamoe.mirai.api.http.dto
import kotlinx.serialization.Serializable
import net.mamoe.mirai.contact.Group
import net.mamoe.mirai.contact.Member
import net.mamoe.mirai.contact.MemberPermission
import net.mamoe.mirai.contact.QQ
@Serializable
abstract class ContactDTO : DTO {
abstract val id: Long
}
@Serializable
data class QQDTO(
override val id: Long,
val nickName: String,
val remark: String
) : ContactDTO()
suspend fun QQDTO(qq: QQ): QQDTO = QQDTO(qq.id, qq.queryProfile().nickname, qq.queryRemark().value)
@Serializable
data class MemberDTO(
override val id: Long,
val memberName: String = "",
val group: GroupDTO,
val permission: MemberPermission
) : ContactDTO()
fun MemberDTO(member: Member, name: String = ""): MemberDTO = MemberDTO(member.id, name, GroupDTO(member.group), member.permission)
@Serializable
data class GroupDTO(
override val id: Long,
val name: String
) : ContactDTO()
fun GroupDTO(group: Group): GroupDTO = GroupDTO(group.id, group.name)

View File

@ -0,0 +1,42 @@
package net.mamoe.mirai.api.http.dto
import kotlinx.serialization.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
interface DTO
object MiraiJson {
val json = Json(context = SerializersModule {
polymorphic(MessagePacketDTO.serializer()) {
GroupMessagePacketDTO::class with GroupMessagePacketDTO.serializer()
FriendMessagePacketDTO::class with FriendMessagePacketDTO.serializer()
}
})
}
// 解析失败时直接返回null由路由判断响应400状态
@UseExperimental(ImplicitReflectionSerializer::class)
inline fun <reified T : Any> String.jsonParseOrNull(
serializer: DeserializationStrategy<T>? = null
): T? = try {
if(serializer == null) MiraiJson.json.parse(this) else MiraiJson.json.parse(serializer, this)
} catch (e: Exception) { throw e }
@UseExperimental(ImplicitReflectionSerializer::class, UnstableDefault::class)
inline fun <reified T : Any> T.toJson(
serializer: SerializationStrategy<T>? = null
): String = if (serializer == null) MiraiJson.json.stringify(this)
else Json.stringify(serializer, this)
// 序列化列表时stringify需要使用的泛型是T而非List<T>
// 因为使用的stringify的stringify(objs: List<T>)重载
@UseExperimental(ImplicitReflectionSerializer::class, UnstableDefault::class)
inline fun <reified T : Any> List<T>.toJson(
serializer: SerializationStrategy<List<T>>? = null
): String = if (serializer == null) MiraiJson.json.stringify(this)
else Json.stringify(serializer, this)

View File

@ -0,0 +1,86 @@
package net.mamoe.mirai.api.http.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import net.mamoe.mirai.message.FriendMessage
import net.mamoe.mirai.message.GroupMessage
import net.mamoe.mirai.message.MessagePacket
import net.mamoe.mirai.message.data.*
/*
DTO data class
*/
@Serializable
@SerialName("FriendMessage")
data class FriendMessagePacketDTO(val sender: QQDTO) : MessagePacketDTO()
@Serializable
@SerialName("GroupMessage")
data class GroupMessagePacketDTO(val sender: MemberDTO) : MessagePacketDTO()
@Serializable
data class MessageDTO(val type: MessageType, val data: String) : DTO
typealias MessageChainDTO = Array<MessageDTO>
@Serializable
abstract class MessagePacketDTO : DTO {
lateinit var messageChain : MessageChainDTO
companion object {
val EMPTY = @SerialName("UnknownMessage") object : MessagePacketDTO() {}
}
}
/*
Extend function
*/
suspend fun MessagePacket<*, *>.toDTO(): MessagePacketDTO = when (this) {
is FriendMessage -> FriendMessagePacketDTO(QQDTO(sender))
is GroupMessage -> GroupMessagePacketDTO(MemberDTO(sender, senderName))
else -> MessagePacketDTO.EMPTY
}.apply { messageChain = Array(message.size){ message[it].toDTO() }}
fun MessageChainDTO.toMessageChain() =
MessageChain().apply { this@toMessageChain.forEach { add(it.toMessage()) } }
@UseExperimental(ExperimentalUnsignedTypes::class)
fun Message.toDTO() = when (this) {
is At -> MessageDTO(MessageType.AT, target.toString())
is Face -> MessageDTO(MessageType.FACE, id.value.toString())
is PlainText -> MessageDTO(MessageType.PLAIN, stringValue)
// is Image -> MessageDTO(MessageType.IMAGE, ???)
is Image -> MessageDTO(MessageType.IMAGE, "NOT SUPPORT IMAGE NOW")
is XMLMessage -> MessageDTO(MessageType.XML, stringValue)
else -> MessageDTO(MessageType.UNKNOWN, "not support type")
}
@UseExperimental(ExperimentalUnsignedTypes::class)
fun MessageDTO.toMessage() = when (type) {
MessageType.AT -> At(data.toLong())
MessageType.FACE -> Face(FaceId(data.toUByte()))
MessageType.PLAIN -> PlainText(data)
// MessageType.IMAGE -> Image(???)
MessageType.IMAGE -> PlainText(data)
MessageType.XML -> XMLMessage(data)
MessageType.UNKNOWN -> PlainText(data)
}
/*
Enum
*/
// TODO: will be replace by [net.mamoe.mirai.message.MessageType]
enum class MessageType {
AT,
FACE,
PLAIN,
IMAGE,
XML,
UNKNOWN,
}

View File

@ -0,0 +1,35 @@
package net.mamoe.mirai.api.http.dto
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import net.mamoe.mirai.api.http.AuthedSession
@Serializable
abstract class VerifyDTO : DTO {
abstract val sessionKey: String
@Transient lateinit var session: AuthedSession // 反序列化验证后传入
}
@Serializable
data class BindDTO(override val sessionKey: String, val qq: Long) : VerifyDTO()
// 写成data class并继承DTO接口是为了返回时的形式统一
@Serializable
open class StateCodeDTO(val code: Int, val msg: String) : DTO {
companion object {
val SUCCESS = StateCodeDTO(0, "success") // 成功
// val AUTH_WRONG = CodeDTO(1) // AuthKey错误, @see AuthResDTO
val NO_BOT = StateCodeDTO(2, "指定Bot不存在")
val ILLEGAL_SESSION = StateCodeDTO(3, "Session失效或不存在")
val NOT_VERIFIED_SESSION = StateCodeDTO(3, "Session未认证")
}
class ILLEGAL_ACCESS(msg: String) : StateCodeDTO(400, msg) // 非法访问
}
@Serializable
data class SendDTO(
override val sessionKey: String,
val target: Long,
val messageChain: MessageChainDTO
) : VerifyDTO()

View File

@ -0,0 +1,17 @@
package net.mamoe.mirai.api.http.queue
import net.mamoe.mirai.message.MessagePacket
import java.util.concurrent.ConcurrentLinkedDeque
import kotlin.collections.ArrayList
class MessageQueue : ConcurrentLinkedDeque<MessagePacket<*, *>>() {
fun fetch(size: Int): List<MessagePacket<*, *>> {
var count = size
val ret = ArrayList<MessagePacket<*, *>>(count)
while (!this.isEmpty() && count-- > 0) {
ret.add(this.pop())
}
return ret
}
}

View File

@ -0,0 +1,42 @@
package net.mamoe.mirai.api.http.route
import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.routing.routing
import net.mamoe.mirai.Bot
import net.mamoe.mirai.api.http.AuthedSession
import net.mamoe.mirai.api.http.SessionManager
import net.mamoe.mirai.api.http.dto.*
import kotlin.coroutines.EmptyCoroutineContext
fun Application.authModule() {
routing {
miraiAuth("/auth") {
if (it.authKey != SessionManager.authKey) {
call.respondDTO(AuthResDTO(1, ""))
} else {
call.respondDTO(AuthResDTO(0, SessionManager.createTempSession().key))
}
}
miraiVerify<BindDTO>("/verify", verifiedSessionKey = false) {
try {
val bot = Bot.instanceWhose(it.qq)
with(SessionManager) {
closeSession(it.sessionKey)
allSession[it.sessionKey] = AuthedSession(bot, EmptyCoroutineContext)
}
call.respondDTO(StateCodeDTO.SUCCESS)
} catch (e: NoSuchElementException) {
call.respondDTO(StateCodeDTO.NO_BOT)
}
}
miraiVerify<BindDTO>("/release") {
SessionManager.closeSession(it.sessionKey)
call.respondDTO(StateCodeDTO.SUCCESS)
}
}
}

View File

@ -0,0 +1,191 @@
package net.mamoe.mirai.api.http.route
import io.ktor.application.Application
import io.ktor.application.ApplicationCall
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.CallLogging
import io.ktor.features.DefaultHeaders
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.request.receive
import io.ktor.response.defaultTextContentType
import io.ktor.response.respondText
import io.ktor.routing.Route
import io.ktor.routing.route
import io.ktor.util.pipeline.ContextDsl
import io.ktor.util.pipeline.PipelineContext
import net.mamoe.mirai.api.http.AuthedSession
import net.mamoe.mirai.api.http.SessionManager
import net.mamoe.mirai.api.http.TempSession
import net.mamoe.mirai.api.http.dto.*
fun Application.mirai() {
install(DefaultHeaders)
install(CallLogging)
authModule()
messageModule()
}
/**
* Auth处理http server的验证
* 为闭包传入一个AuthDTO对象
*/
@ContextDsl
internal fun Route.miraiAuth(
path: String,
body: suspend PipelineContext<Unit, ApplicationCall>.(AuthDTO) -> Unit
): Route {
return route(path, HttpMethod.Post) {
intercept {
val dto = context.receiveDTO<AuthDTO>() ?: throw IllegalParamException("参数格式错误")
this.body(dto)
}
}
}
/**
* Get用于获取bot的属性
* 验证请求参数中sessionKey参数的有效性
*/
@ContextDsl
internal fun Route.miraiGet(
path: String,
body: suspend PipelineContext<Unit, ApplicationCall>.(AuthedSession) -> Unit
): Route {
return route(path, HttpMethod.Get) {
intercept {
val sessionKey = call.parameters["sessionKey"] ?: throw IllegalParamException("参数格式错误")
if (!SessionManager.containSession(sessionKey)) throw IllegalSessionException
when(val session = SessionManager[sessionKey]) {
is TempSession -> throw NotVerifiedSessionException
is AuthedSession -> this.body(session)
}
}
}
}
/**
* Verify用于处理bot的行为请求
* 验证数据传输对象(DTO)中是否包含sessionKey字段
* 且验证sessionKey的有效性
*
* @param verifiedSessionKey 是否验证sessionKey是否被激活
*
* it 为json解析出的DTO对象
*/
@ContextDsl
internal inline fun <reified T : VerifyDTO> Route.miraiVerify(
path: String,
verifiedSessionKey: Boolean = true,
crossinline body: suspend PipelineContext<Unit, ApplicationCall>.(T) -> Unit
): Route {
return route(path, HttpMethod.Post) {
intercept {
val dto = context.receiveDTO<T>() ?: throw IllegalParamException("参数格式错误")
SessionManager[dto.sessionKey]?.let {
when {
it is TempSession && verifiedSessionKey -> throw NotVerifiedSessionException
it is AuthedSession -> dto.session = it
}
} ?: throw IllegalSessionException
this.body(dto)
}
}
}
/**
* 统一捕获并处理异常
*/
internal inline fun Route.intercept(crossinline blk: suspend PipelineContext<Unit, ApplicationCall>.() -> Unit) = handle {
try {
blk(this)
} catch (e: IllegalSessionException) {
call.respondDTO(StateCodeDTO.ILLEGAL_SESSION)
} catch (e: NotVerifiedSessionException) {
call.respondDTO(StateCodeDTO.NOT_VERIFIED_SESSION)
} catch (e: IllegalAccessException) {
call.respondDTO(StateCodeDTO.ILLEGAL_ACCESS(e.message), HttpStatusCode.BadRequest)
}
}
/*
extend function
*/
internal suspend inline fun <reified T : DTO> ApplicationCall.respondDTO(dto: T, status: HttpStatusCode = HttpStatusCode.OK)
= respondJson(dto.toJson(), status)
internal suspend fun ApplicationCall.respondJson(json: String, status: HttpStatusCode = HttpStatusCode.OK) =
respondText(json, defaultTextContentType(ContentType("application", "json")), status)
internal suspend inline fun <reified T : DTO> ApplicationCall.receiveDTO(): T? = receive<String>().apply(::println).jsonParseOrNull()
fun PipelineContext<Unit, ApplicationCall>.illegalParam(
expectingType: String?,
paramName: String,
actualValue: String? = call.parameters[paramName]
): Nothing = throw IllegalParamException("Illegal param. A $expectingType is required for `$paramName` while `$actualValue` is given")
@Suppress("IMPLICIT_CAST_TO_ANY")
@UseExperimental(ExperimentalUnsignedTypes::class)
internal inline fun <reified R> PipelineContext<Unit, ApplicationCall>.paramOrNull(name: String): R =
when (R::class) {
Byte::class -> call.parameters[name]?.toByte()
Int::class -> call.parameters[name]?.toInt()
Short::class -> call.parameters[name]?.toShort()
Float::class -> call.parameters[name]?.toFloat()
Long::class -> call.parameters[name]?.toLong()
Double::class -> call.parameters[name]?.toDouble()
Boolean::class -> when (call.parameters[name]) {
"true" -> true
"false" -> false
"0" -> false
"1" -> true
null -> null
else -> illegalParam("boolean", name)
}
String::class -> call.parameters[name]
UByte::class -> call.parameters[name]?.toUByte()
UInt::class -> call.parameters[name]?.toUInt()
UShort::class -> call.parameters[name]?.toUShort()
else -> error(name::class.simpleName + " is not supported")
} as R ?: illegalParam(R::class.simpleName, name)
/**
* 错误请求. 抛出这个异常后将会返回错误给一个请求
*/
@Suppress("unused")
open class IllegalAccessException : Exception {
override val message: String get() = super.message!!
constructor(message: String) : super(message, null)
constructor(cause: Throwable) : super(cause.toString(), cause)
constructor(message: String, cause: Throwable?) : super(message, cause)
}
/**
* Session失效或不存在
*/
object IllegalSessionException : IllegalAccessException("Session失效或不存在")
/**
* Session未激活
*/
object NotVerifiedSessionException : IllegalAccessException("Session未激活")
/**
* 错误参数
*/
class IllegalParamException(message: String) : IllegalAccessException(message)

View File

@ -0,0 +1,2 @@
package net.mamoe.mirai.api.http.route

View File

@ -0,0 +1,39 @@
package net.mamoe.mirai.api.http.route
import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.routing.routing
import net.mamoe.mirai.api.http.AuthedSession
import net.mamoe.mirai.api.http.SessionManager
import net.mamoe.mirai.api.http.dto.*
fun Application.messageModule() {
routing {
miraiGet("/fetchMessage") {
val count: Int = paramOrNull("count")
val fetch = it.messageQueue.fetch(count)
val ls = Array(fetch.size) { index -> fetch[index].toDTO() }
call.respondJson(ls.toList().toJson())
}
miraiVerify<SendDTO>("/sendFriendMessage") {
it.session.bot.getQQ(it.target).sendMessage(it.messageChain.toMessageChain())
call.respondDTO(StateCodeDTO.SUCCESS)
}
miraiVerify<SendDTO>("/sendGroupMessage") {
it.session.bot.getGroup(it.target).sendMessage(it.messageChain.toMessageChain())
call.respondDTO(StateCodeDTO.SUCCESS)
}
miraiVerify<VerifyDTO>("/event/message") {
}
miraiVerify<VerifyDTO>("/addFriend") {
}
}
}