diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e21dc1c..96cf1ee 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,7 @@ dependencies { implementation(project(":libraries:agent")) implementation(project(":libraries:tools")) implementation(project(":libraries:channels:telegram")) + implementation(project(":libraries:channels:onebot")) implementation(project(":libraries:providers:openai-compatible")) implementation(project(":libraries:providers:deepseek")) implementation(project(":libraries:providers:openrouter")) diff --git a/app/src/dist/config/alexandrite.example.json b/app/src/dist/config/alexandrite.example.json index dd3258a..01864c0 100644 --- a/app/src/dist/config/alexandrite.example.json +++ b/app/src/dist/config/alexandrite.example.json @@ -5,5 +5,21 @@ "shutdownGraceSeconds": 15, "startTimeoutSeconds": 30, "plugins": [] + }, + "channels": { + "onebot": { + "enabled": false, + "settings": { + "eventQueueCapacity": 1024, + "rateLimitIntervalMillis": 500 + }, + "instances": { + "main": { + "transport": "ws_reverse", + "listenHost": "127.0.0.1", + "listenPort": 8080 + } + } + } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fff9931..a0ced11 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ logback = "1.6.5" junit = "6.1.3" kctfork = "0.14.0" poko = "0.23.2" +java-websocket = "1.6.0" [libraries] kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } @@ -32,6 +33,7 @@ logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "lo junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +java-websocket = { module = "org.java-websocket:Java-WebSocket", version.ref = "java-websocket" } [plugins] spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/libraries/channels/onebot/build.gradle.kts b/libraries/channels/onebot/build.gradle.kts new file mode 100644 index 0000000..b059679 --- /dev/null +++ b/libraries/channels/onebot/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("alexandrite.kotlin-library") + id("alexandrite.kotlin-serialization") +} + +dependencies { + implementation(project(":libraries:plugin-sdk")) + implementation(project(":libraries:internal")) + + // The client and the server of the Websocket transports, of which the JDK offers a client only. + implementation(libs.java.websocket) + + testImplementation(project(":libraries:testkit")) + testRuntimeOnly(libs.logback.classic) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotPlugin.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotPlugin.kt new file mode 100644 index 0000000..7a431d0 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotPlugin.kt @@ -0,0 +1,7 @@ +package org.foedusprogramme.alexandrite.channel.onebot + +import org.foedusprogramme.alexandrite.sdk.plugin.Plugin + +/** The OneBot v11 channel plugin, which talks to an external OneBot implementation such as NapCat. */ +@Plugin(name = "OneBot", description = "OneBot v11 channel") +public class OneBotPlugin diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApi.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApi.kt new file mode 100644 index 0000000..4d59528 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApi.kt @@ -0,0 +1,168 @@ +package org.foedusprogramme.alexandrite.channel.onebot.api + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.DeleteMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetCookies +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetCredentials +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetForwardMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupHonorInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupMemberInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupMemberList +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetImage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetRecord +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetStrangerInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.HandleQuickOperation +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCallSuffix +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCookies +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCredentials +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCsrfToken +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotFile +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotForwardMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotFriend +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotGroupHonorInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotGroupInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotGroupMemberInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotLoginInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotMessageInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotMessageSent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotRequest +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotStatusInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotStrangerInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotVersionInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotYesNo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendGroupMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendLike +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendPrivateMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetFriendAddRequest +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAddRequest +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAdmin +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAnonymous +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAnonymousBan +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupBan +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupCard +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupKick +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupLeave +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupName +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupSpecialTitle +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupWholeBan +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetRestart +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult + +/** + * The actions of the OneBot v11 API, as the types of the standard. + * + * One instance of this interface belongs to one channel instance and lives as long as its connection, so a plugin + * that holds it calls the implementation that instance talks to, and no other. The calls an implementation answers + * with data return the parsed type; the rest answer with nothing, and a call this version does not model goes + * through [raw]. + * + * Every call returns a [OneBotResult], which keeps the difference between an implementation that refused the call, a + * peer that could not be reached and a call that worked. + */ +public interface OneBotApi { + // Messages + + public suspend fun sendPrivateMsg(request: SendPrivateMessage): OneBotResult + + public suspend fun sendGroupMsg(request: SendGroupMessage): OneBotResult + + public suspend fun sendMsg(request: SendMessage): OneBotResult + + public suspend fun deleteMsg(request: DeleteMessage): OneBotResult + + public suspend fun getMsg(request: GetMessage): OneBotResult + + public suspend fun getForwardMsg(request: GetForwardMessage): OneBotResult + + // Groups and friends + + public suspend fun sendLike(request: SendLike): OneBotResult + + public suspend fun setGroupKick(request: SetGroupKick): OneBotResult + + public suspend fun setGroupBan(request: SetGroupBan): OneBotResult + + public suspend fun setGroupAnonymousBan(request: SetGroupAnonymousBan): OneBotResult + + public suspend fun setGroupWholeBan(request: SetGroupWholeBan): OneBotResult + + public suspend fun setGroupAdmin(request: SetGroupAdmin): OneBotResult + + public suspend fun setGroupAnonymous(request: SetGroupAnonymous): OneBotResult + + public suspend fun setGroupCard(request: SetGroupCard): OneBotResult + + public suspend fun setGroupName(request: SetGroupName): OneBotResult + + public suspend fun setGroupLeave(request: SetGroupLeave): OneBotResult + + public suspend fun setGroupSpecialTitle(request: SetGroupSpecialTitle): OneBotResult + + public suspend fun setFriendAddRequest(request: SetFriendAddRequest): OneBotResult + + public suspend fun setGroupAddRequest(request: SetGroupAddRequest): OneBotResult + + public suspend fun getLoginInfo(): OneBotResult + + public suspend fun getStrangerInfo(request: GetStrangerInfo): OneBotResult + + public suspend fun getFriendList(): OneBotResult> + + public suspend fun getGroupInfo(request: GetGroupInfo): OneBotResult + + public suspend fun getGroupList(): OneBotResult> + + public suspend fun getGroupMemberInfo(request: GetGroupMemberInfo): OneBotResult + + public suspend fun getGroupMemberList(request: GetGroupMemberList): OneBotResult> + + public suspend fun getGroupHonorInfo(request: GetGroupHonorInfo): OneBotResult + + // The account and its files + + public suspend fun getCookies(request: GetCookies): OneBotResult + + public suspend fun getCsrfToken(): OneBotResult + + public suspend fun getCredentials(request: GetCredentials): OneBotResult + + public suspend fun getRecord(request: GetRecord): OneBotResult + + public suspend fun getImage(request: GetImage): OneBotResult + + public suspend fun canSendImage(): OneBotResult + + public suspend fun canSendRecord(): OneBotResult + + // The implementation itself + + public suspend fun getStatus(): OneBotResult + + public suspend fun getVersionInfo(): OneBotResult + + public suspend fun setRestart(request: SetRestart): OneBotResult + + public suspend fun cleanCache(): OneBotResult + + public suspend fun handleQuickOperation(request: HandleQuickOperation): OneBotResult + + /** Calls an action of the implementation that this version does not model, with its answer untouched. */ + public suspend fun raw(action: String, params: JsonObject = JsonObject(emptyMap())): OneBotResult + + /** + * Calls one action through this version's own request type, under [suffix]. + * + * The typed methods above call the plain shape of an action. This is the way to a derived one, such as + * `send_group_msg_async` or `send_group_msg_rate_limited`, without giving up the request type or the answer's + * `raw` object. + */ + public suspend fun call( + action: String, + request: OneBotRequest, + suffix: OneBotCallSuffix = OneBotCallSuffix.NONE, + ): OneBotResult +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApiImpl.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApiImpl.kt new file mode 100644 index 0000000..556cceb --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApiImpl.kt @@ -0,0 +1,231 @@ +package org.foedusprogramme.alexandrite.channel.onebot.api + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotLink +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.DeleteMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetCookies +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetCredentials +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetForwardMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupHonorInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupMemberInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupMemberList +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetImage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetRecord +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetStrangerInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.HandleQuickOperation +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCallSuffix +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCookies +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCredentials +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCsrfToken +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotFile +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotForwardMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotFriend +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotGroupHonorInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotGroupInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotGroupMemberInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotLoginInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotMessageInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotMessageSent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotNoParameters +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotRequest +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotStatusInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotStrangerInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotVersionInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotYesNo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendGroupMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendLike +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendPrivateMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetFriendAddRequest +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAddRequest +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAdmin +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAnonymous +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAnonymousBan +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupBan +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupCard +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupKick +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupLeave +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupName +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupSpecialTitle +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupWholeBan +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetRestart +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotWire +import org.foedusprogramme.alexandrite.sdk.di.Binds +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped + +/** + * The actions of one connection, sent over whichever transport the instance configured. + * + * The answer of an action that returns data is read into the type of the standard; an action this version models as + * returning nothing answers with [Unit] once the implementation accepted it. An answer whose data does not fit the + * type is reported as a malformed answer rather than being cast. + */ +@ChannelInstanceScoped +@Binds(OneBotApi::class) +internal class OneBotApiImpl(private val link: OneBotLink) : OneBotApi { + override suspend fun sendPrivateMsg(request: SendPrivateMessage): OneBotResult = + action("send_private_msg", request, OneBotMessageSent.serializer()) + + override suspend fun sendGroupMsg(request: SendGroupMessage): OneBotResult = + action("send_group_msg", request, OneBotMessageSent.serializer()) + + override suspend fun sendMsg(request: SendMessage): OneBotResult = + action("send_msg", request, OneBotMessageSent.serializer()) + + override suspend fun deleteMsg(request: DeleteMessage): OneBotResult = nothing("delete_msg", request) + + override suspend fun getMsg(request: GetMessage): OneBotResult = + action("get_msg", request, OneBotMessageInfo.serializer()) + + override suspend fun getForwardMsg(request: GetForwardMessage): OneBotResult = + action("get_forward_msg", request, OneBotForwardMessage.serializer()) + + override suspend fun sendLike(request: SendLike): OneBotResult = nothing("send_like", request) + + override suspend fun setGroupKick(request: SetGroupKick): OneBotResult = nothing("set_group_kick", request) + + override suspend fun setGroupBan(request: SetGroupBan): OneBotResult = nothing("set_group_ban", request) + + override suspend fun setGroupAnonymousBan(request: SetGroupAnonymousBan): OneBotResult = + nothing("set_group_anonymous_ban", request) + + override suspend fun setGroupWholeBan(request: SetGroupWholeBan): OneBotResult = + nothing("set_group_whole_ban", request) + + override suspend fun setGroupAdmin(request: SetGroupAdmin): OneBotResult = nothing("set_group_admin", request) + + override suspend fun setGroupAnonymous(request: SetGroupAnonymous): OneBotResult = + nothing("set_group_anonymous", request) + + override suspend fun setGroupCard(request: SetGroupCard): OneBotResult = nothing("set_group_card", request) + + override suspend fun setGroupName(request: SetGroupName): OneBotResult = nothing("set_group_name", request) + + override suspend fun setGroupLeave(request: SetGroupLeave): OneBotResult = nothing("set_group_leave", request) + + override suspend fun setGroupSpecialTitle(request: SetGroupSpecialTitle): OneBotResult = + nothing("set_group_special_title", request) + + override suspend fun setFriendAddRequest(request: SetFriendAddRequest): OneBotResult = + nothing("set_friend_add_request", request) + + override suspend fun setGroupAddRequest(request: SetGroupAddRequest): OneBotResult = + nothing("set_group_add_request", request) + + override suspend fun getLoginInfo(): OneBotResult = + action("get_login_info", OneBotNoParameters.GET_LOGIN_INFO, OneBotLoginInfo.serializer()) + + override suspend fun getStrangerInfo(request: GetStrangerInfo): OneBotResult = + action("get_stranger_info", request, OneBotStrangerInfo.serializer()) + + override suspend fun getFriendList(): OneBotResult> = + action("get_friend_list", OneBotNoParameters.GET_FRIEND_LIST, ListSerializer(OneBotFriend.serializer())) + + override suspend fun getGroupInfo(request: GetGroupInfo): OneBotResult = + action("get_group_info", request, OneBotGroupInfo.serializer()) + + override suspend fun getGroupList(): OneBotResult> = + action("get_group_list", OneBotNoParameters.GET_GROUP_LIST, ListSerializer(OneBotGroupInfo.serializer())) + + override suspend fun getGroupMemberInfo(request: GetGroupMemberInfo): OneBotResult = + action("get_group_member_info", request, OneBotGroupMemberInfo.serializer()) + + override suspend fun getGroupMemberList(request: GetGroupMemberList): OneBotResult> = + action("get_group_member_list", request, ListSerializer(OneBotGroupMemberInfo.serializer())) + + override suspend fun getGroupHonorInfo(request: GetGroupHonorInfo): OneBotResult = + action("get_group_honor_info", request, OneBotGroupHonorInfo.serializer()) + + override suspend fun getCookies(request: GetCookies): OneBotResult = + action("get_cookies", request, OneBotCookies.serializer()) + + override suspend fun getCsrfToken(): OneBotResult = + action("get_csrf_token", OneBotNoParameters.GET_CSRF_TOKEN, OneBotCsrfToken.serializer()) + + override suspend fun getCredentials(request: GetCredentials): OneBotResult = + action("get_credentials", request, OneBotCredentials.serializer()) + + override suspend fun getRecord(request: GetRecord): OneBotResult = + action("get_record", request, OneBotFile.serializer()) + + override suspend fun getImage(request: GetImage): OneBotResult = + action("get_image", request, OneBotFile.serializer()) + + override suspend fun canSendImage(): OneBotResult = + action("can_send_image", OneBotNoParameters.CAN_SEND_IMAGE, OneBotYesNo.serializer()) + + override suspend fun canSendRecord(): OneBotResult = + action("can_send_record", OneBotNoParameters.CAN_SEND_RECORD, OneBotYesNo.serializer()) + + override suspend fun getStatus(): OneBotResult = + action("get_status", OneBotNoParameters.GET_STATUS, OneBotStatusInfo.serializer()) + + override suspend fun getVersionInfo(): OneBotResult = + action("get_version_info", OneBotNoParameters.GET_VERSION_INFO, OneBotVersionInfo.serializer()) + + override suspend fun setRestart(request: SetRestart): OneBotResult = nothing("set_restart", request) + + override suspend fun cleanCache(): OneBotResult = nothing("clean_cache", OneBotNoParameters.CLEAN_CACHE) + + override suspend fun handleQuickOperation(request: HandleQuickOperation): OneBotResult = + nothing(".handle_quick_operation", request) + + override suspend fun raw(action: String, params: JsonObject): OneBotResult = link.call(action, params) + + override suspend fun call( + action: String, + request: OneBotRequest, + suffix: OneBotCallSuffix, + ): OneBotResult = link.call(action + suffix.suffix, request.toJson()) + + /** Runs an action whose data is one object of the standard. */ + private suspend fun action( + name: String, + request: OneBotRequest, + deserializer: DeserializationStrategy, + ): OneBotResult { + val answer = link.call(name, request.toJson()) + val data = when (answer) { + is OneBotResult.Ok -> answer.data + is OneBotResult.Async -> return answer + is OneBotResult.Failed -> return answer + is OneBotResult.Unreachable -> return answer + is OneBotResult.Malformed -> return answer + } + return try { + OneBotResult.Ok( + OneBotWire.decodeFromJsonElement(deserializer, data), + answer.retcode, + answer.echo, + answer.raw, + ) + } catch (e: Exception) { + // The answer goes along, so that a caller who meets an implementation whose data this version misreads + // can read the field itself instead of rebuilding the call to see it. + OneBotResult.Malformed( + "The data of $name does not fit its type: ${e.message}", + answer.echo, + answer.raw, + ) + } + } + + /** Runs an action of the standard that answers with nothing. */ + private suspend fun nothing(name: String, request: OneBotRequest): OneBotResult = + link.call(name, request.toJson()).asUnit() + + /** Turns an answer into one that carries no data. */ + private fun OneBotResult.asUnit(): OneBotResult = when (this) { + is OneBotResult.Ok -> OneBotResult.Ok(Unit, retcode, echo, raw) + is OneBotResult.Async -> OneBotResult.Async(retcode, echo, raw) + is OneBotResult.Failed -> this + is OneBotResult.Unreachable -> this + is OneBotResult.Malformed -> this + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/auth/OneBotAuth.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/auth/OneBotAuth.kt new file mode 100644 index 0000000..e140772 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/auth/OneBotAuth.kt @@ -0,0 +1,116 @@ +package org.foedusprogramme.alexandrite.channel.onebot.auth + +import org.foedusprogramme.alexandrite.sdk.config.Secret +import java.security.MessageDigest +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * The authentication of the OneBot standard. + * + * A call carries its token either as a bearer header or as a query parameter, and a report carries an HMAC-SHA1 of + * its body under the shared secret. Neither the token nor the secret ever reaches a message: a transport masks what + * it logs with [redact], and the values only leave [Value] where a request or a comparison needs them. + */ +public object OneBotAuth { + /** The header a call carries its token in. */ + public const val AUTHORIZATION: String = "Authorization" + + /** The query parameter a call may carry its token in instead. */ + public const val TOKEN_QUERY: String = "access_token" + + /** The header a report carries its signature in. */ + public const val SIGNATURE: String = "X-Signature" + + /** The header a report names the account it is about in. */ + public const val SELF_ID: String = "X-Self-ID" + + /** The header a reverse WebSocket connection names its role in. */ + public const val CLIENT_ROLE: String = "X-Client-Role" + + /** The prefix of a signature value. */ + public const val SIGNATURE_PREFIX: String = "sha1=" + + /** The value of the [AUTHORIZATION] header for [token]. */ + public fun bearer(token: Value): String = "Bearer ${token.reveal()}" + + /** The value of the [SIGNATURE] header for [body] under [secret]. */ + public fun signature(secret: Value, body: String): String = + SIGNATURE_PREFIX + hmacSha1(secret.reveal().toByteArray(Charsets.UTF_8), body.toByteArray(Charsets.UTF_8)) + + /** Whether [signature] is the [SIGNATURE] header of [body] under [secret], whatever its case. */ + public fun matchesSignature(secret: Value, body: String, signature: String?): Boolean { + val value = signature?.trim()?.lowercase() ?: return false + val presented = value.removePrefix(SIGNATURE_PREFIX) + val expected = hmacSha1(secret.reveal().toByteArray(Charsets.UTF_8), body.toByteArray(Charsets.UTF_8)) + return MessageDigest.isEqual( + expected.lowercase().toByteArray(Charsets.UTF_8), + presented.toByteArray(Charsets.UTF_8), + ) + } + + /** The token of [authorization], which may be a bearer header or the token itself, null when it holds none. */ + public fun tokenOf(authorization: String?): String? { + val value = authorization?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val bearer = value.substringBefore(' ').lowercase() + return if (bearer == "bearer") value.substringAfter(' ').trim().ifEmpty { null } else value + } + + /** Whether [token] matches the token [presented] carries. */ + public fun matchesToken(token: Value, presented: String?): Boolean { + val expected = token.reveal() + return presented != null && MessageDigest.isEqual( + expected.toByteArray(Charsets.UTF_8), + presented.toByteArray(Charsets.UTF_8), + ) + } + + /** [uri] with the token of [token] as its query parameter, unchanged when this instance has none. */ + public fun withToken(uri: String, token: Value?): String { + if (token == null) return uri + val separator = if ('?' in uri) '&' else '?' + return "$uri$separator$TOKEN_QUERY=${urlEncode(token.reveal())}" + } + + /** + * [text] with every value of [values] replaced, so that a message carries no token and no secret. + * + * Every value that is not empty is replaced, whatever its length: an access token and a signing key have no + * minimum length, and a short one is no less a secret than a long one. + */ + public fun redact(text: String, values: Collection): String = values.asSequence() + .map { it.reveal() } + .filter { it.isNotEmpty() } + .sortedByDescending { it.length } + .fold(text) { masked, secret -> masked.replace(secret, MASKED) } + + private const val MASKED = "***" + + private fun hmacSha1(key: ByteArray, message: ByteArray): String { + val mac = Mac.getInstance("HmacSHA1") + mac.init(SecretKeySpec(key, "HmacSHA1")) + return mac.doFinal(message).joinToString("") { "%02x".format(it) } + } + + private fun urlEncode(text: String): String = java.net.URLEncoder.encode(text, Charsets.UTF_8).replace("+", "%20") +} + +/** One value of the config a transport must keep out of its logs, such as an access token or a signing key. */ +@JvmInline +public value class Value(private val secret: Secret) { + /** The value itself, for the one place that needs it. */ + public fun reveal(): String = secret.reveal() + + override fun toString(): String = secret.toString() + + public companion object { + /** A value that masks [secret]. */ + public fun of(secret: Secret): Value = Value(secret) + + /** The value of [secret], null when the instance has none. */ + public fun orNull(secret: Secret?): Value? = secret?.let(::Value) + + /** A value that masks [text], for a caller that holds the string of a token itself. */ + public fun secretOf(text: String): Value = Value(Secret(text)) + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotChannel.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotChannel.kt new file mode 100644 index 0000000..587fd61 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotChannel.kt @@ -0,0 +1,95 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.onebot.api.OneBotApi +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendGroupMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendPrivateMessage +import org.foedusprogramme.alexandrite.sdk.channel.Channel +import org.foedusprogramme.alexandrite.sdk.channel.ChannelCapabilities +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.DeliveryFailure +import org.foedusprogramme.alexandrite.sdk.channel.Markup +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.channel.ReplyEnd +import org.foedusprogramme.alexandrite.sdk.channel.ReplyRequest +import org.foedusprogramme.alexandrite.sdk.channel.ReplySink +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.turn.TurnSubmitter + +/** + * The OneBot front end of one channel instance. + * + * A message the implementation reported becomes an [IncomingMessage] and goes to the agent through the + * [TurnSubmitter], and what the agent answers goes back as segments over the actions of the standard. Which + * transport carries either is [OneBotLink]'s business, not this class's. + */ +@ChannelInstanceScoped +@Contribute(Channel::class, name = "onebot") +internal class OneBotChannel( + private val instance: ChannelInstance, + private val config: OneBotInstanceConfig, + private val link: OneBotLink, + /** The agent, null while the runtime has none. */ + private val submitter: TurnSubmitter?, +) : Channel { + /** + * The action that sends a message to [chat], null when this instance serves no such chat. + * + * A temporary session of a group is a private chat whose thread names the sender, so it is asked for first: a + * reply to it goes to the sender and not into the group, which is what its address means. + */ + internal fun action(chat: ChatAddress): Pair? = when { + OneBotMessages.privateTemporary(chat) != null -> "send_private_msg" to chat + OneBotMessages.isGroup(chat) && OneBotMessages.group(chat) != null -> "send_group_msg" to chat + OneBotMessages.privateUser(chat) != null -> "send_private_msg" to chat + else -> null + } + + /** The parameters that send [message] to [chat], null when this instance serves no such chat. */ + internal fun params(chat: ChatAddress, message: OutboundMessage): kotlinx.serialization.json.JsonObject? { + val temporary = OneBotMessages.privateTemporary(chat) + val user = OneBotMessages.privateUser(chat) + val group = OneBotMessages.group(chat) + val segments = OneBotMessages.outgoing(chat, message) + return when { + temporary != null -> SendPrivateMessage(temporary, segments).toJson() + group != null -> SendGroupMessage(group, segments).toJson() + user != null -> SendPrivateMessage(user, segments).toJson() + else -> null + } + } + + override suspend fun capabilities(chat: ChatAddress): ChannelCapabilities = OneBotMessages.capabilities() + + override suspend fun partsNeeded(chat: ChatAddress, text: String, markup: Markup): Int = + if (text.isEmpty()) 0 else 1 + + override suspend fun openReply(request: ReplyRequest): ReplySink = Sink(this, request) + + override suspend fun send(chat: ChatAddress, message: OutboundMessage): Delivery { + val target = + action(chat) ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + val params = params(chat, message) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + return OneBotMessages.delivery(link.call(target.first, params), chat) + } + + /** The reply of one turn, which delivers the final message of the agent. */ + private class Sink(private val channel: OneBotChannel, private val request: ReplyRequest) : ReplySink { + override suspend fun preview(segment: Int, text: String) = Unit + + override suspend fun complete(message: OutboundMessage): Delivery { + val target = channel.action(request.turn.chat) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no chat of this turn.") + val params = channel.params(request.turn.chat, message) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no chat of this turn.") + return OneBotMessages.delivery(channel.link.call(target.first, params), request.turn.chat) + } + + override suspend fun abandon(end: ReplyEnd) = Unit + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotInbox.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotInbox.kt new file mode 100644 index 0000000..74fd7b8 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotInbox.kt @@ -0,0 +1,67 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.foedusprogramme.alexandrite.sdk.turn.Submission +import org.foedusprogramme.alexandrite.sdk.turn.TurnSubmitter +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * What the implementation reports, taken into the agent. + * + * A message becomes a submission, which the agent queues as a turn of its chat. Every other event, a notice, a request + * or a heartbeat, is read and dropped here: it is not a turn, and this version has no way to hand one to another + * plugin. The connection can carry it, so the way out is a decision about the shape of that interface rather than + * about this class, and until it is taken a plugin that needs a notice or a request goes through [OneBotApi] for the + * state it wants. An instance whose runtime has no agent accepts the events and hands them nowhere, which is reported + * once so that the silence is visible rather than puzzling. + */ +@ChannelInstanceScoped +internal class OneBotInbox( + private val instance: ChannelInstance, + private val config: OneBotInstanceConfig, + private val link: OneBotLink, + /** The agent, null while the runtime has none. */ + private val submitter: TurnSubmitter?, +) : Lifecycle { + private var reading: Job? = null + + override suspend fun onStart() { + if (submitter == null) { + logger.warn( + "{}: the runtime has no agent, so the messages of channel instance {} reach nobody", + PLUGIN, + instance.id, + ) + return + } + reading = instance.scope.launch { read() } + } + + override suspend fun onClose() { + reading?.cancel() + reading = null + } + + private suspend fun read() { + link.events.collect { event -> + if (event !is OneBotEvent.Message) return@collect + val message = OneBotMessages.incoming(instance.id, event, config.admins) + submitter?.submit(Submission.Message(message)) + } + } + + private companion object { + const val PLUGIN = "alexandrite-channel-onebot" + + val logger: Logger = LoggerFactory.getLogger(OneBotInbox::class.java) + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotLink.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotLink.kt new file mode 100644 index 0000000..1fc3c97 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotLink.kt @@ -0,0 +1,119 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotConfig +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotTransport +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailure +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailureKind +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotForwardWebSocket +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotHttpApi +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotHttpPostReceiver +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotReverseWebSocket +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotSettings +import org.foedusprogramme.alexandrite.channel.onebot.transport.envelopeResult +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle + +/** + * The connection of one channel instance, whichever transport its config chose. + * + * This is the one place where the plugin picks a transport and opens it, so that the channel above it calls an + * action without knowing whether an HTTP request, a Websocket or a listener carries it. The instance scope owns it, + * so the container starts it when the instance opens and closes it when the instance stops. + */ +@ChannelInstanceScoped +internal class OneBotLink(private val config: OneBotConfig, private val instanceConfig: OneBotInstanceConfig) : + Lifecycle { + private val settings = OneBotSettings.of(config, instanceConfig) + + private val http: OneBotHttpApi? = if (instanceConfig.kind == OneBotTransport.HTTP) { + OneBotHttpApi( + endpoint = settings.requireEndpoint().trimEnd('/'), + token = settings.token, + connectTimeoutMillis = settings.connectTimeoutMillis, + firstByteTimeoutMillis = settings.firstByteTimeoutMillis, + idleTimeoutMillis = settings.idleTimeoutMillis, + ) + } else { + null + } + + private val receiver: OneBotHttpPostReceiver? = if (instanceConfig.kind == OneBotTransport.HTTP_POST) { + OneBotHttpPostReceiver(settings) + } else { + null + } + + private val forward: OneBotForwardWebSocket? = if (instanceConfig.kind == OneBotTransport.WS) { + OneBotForwardWebSocket(settings) + } else { + null + } + + private val reverse: OneBotReverseWebSocket? = if (instanceConfig.kind == OneBotTransport.WS_REVERSE) { + OneBotReverseWebSocket(settings) + } else { + null + } + + /** The events of the implementation, from whichever transport reports them. */ + internal val events: Flow = when { + receiver != null -> receiver.events + forward != null -> forward.events + reverse != null -> reverse.events + else -> emptyFlow() + } + + /** Runs one call of [action], with the suffix the caller chose already applied. */ + internal suspend fun call(action: String, params: JsonObject): OneBotResult = when { + http != null -> http.call(action, params) + + forward != null -> overSocket { forward.send(action, params) } + + reverse != null -> overSocket { reverse.send(action, params) } + + else -> OneBotResult.Unreachable( + OneBotFailure( + OneBotFailureKind.HTTP_STATUS, + "A transport of '${instanceConfig.transport}' only takes reports; it has no endpoint to call.", + ), + ) + } + + /** + * Runs one call over a socket, as a result rather than as a throw. + * + * A socket that is gone throws where an HTTP answer would have been a result, so a channel that sends a reply + * would fail the turn instead of reporting a delivery that did not happen. A caller that gave up is not a failure + * of the transport and is left to travel as it is. + */ + private suspend fun overSocket(send: suspend () -> JsonObject): OneBotResult = try { + envelopeResult(send()) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + OneBotResult.Unreachable(OneBotFailure(OneBotFailureKind.CONNECTION, e.message ?: "the call failed")) + } + + /** The port a listener took, which a test reads and a log may name. */ + internal val listenerPort: Int? get() = receiver?.port ?: reverse?.port + + override suspend fun onStart() { + forward?.start() + reverse?.start() + receiver?.start() + } + + override fun onStop() { + http?.close() + receiver?.close() + forward?.close() + reverse?.close() + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/config/OneBotConfig.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/config/OneBotConfig.kt new file mode 100644 index 0000000..8877e2e --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/config/OneBotConfig.kt @@ -0,0 +1,158 @@ +package org.foedusprogramme.alexandrite.channel.onebot.config + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.foedusprogramme.alexandrite.sdk.config.ConfigSection +import org.foedusprogramme.alexandrite.sdk.config.Secret +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped + +/** How an instance talks to its OneBot implementation. */ +public enum class OneBotTransport { + /** The implementation serves the API on the endpoint, and the client calls it. */ + HTTP, + + /** The implementation serves the API on the endpoint and reports events to the receiver. */ + HTTP_POST, + + /** The implementation serves the API and pushes events on the endpoint. */ + WS, + + /** The implementation connects to the receiver for the API and the events. */ + WS_REVERSE, + ; + + /** Whether the client listens for the implementation instead of calling it. */ + public val isReceiver: Boolean get() = this == HTTP_POST || this == WS_REVERSE + + public companion object { + /** The transport [name] spells, null when it names none. */ + public fun of(name: String): OneBotTransport? = when (name.lowercase()) { + "http" -> HTTP + "http_post" -> HTTP_POST + "ws", "websocket" -> WS + "ws_reverse", "ws-reverse" -> WS_REVERSE + else -> null + } + } +} + +/** + * Where the settings of the plugin sit below its config root, apart from the root its channel instances use. + * + * The paths of the two sections of this plugin have to differ, because a channel plugin's instances read the root of + * their own config under `instances` and a section at the root would collide with them. + */ +internal const val ONEBOT_SETTINGS_PATH: String = "settings" + +/** + * The settings of the OneBot plugin itself, below its config root. + * + * What describes one connection lives in [OneBotInstanceConfig] instead, because a connection is an instance of the + * channel and each instance has its own. + */ +@ConfigSection(path = ONEBOT_SETTINGS_PATH) +@Serializable +public class OneBotConfig( + /** How many events wait for the plugin to read them before the newest are dropped. */ + @SerialName("eventQueueCapacity") public val eventQueueCapacity: Int = DEFAULT_EVENT_QUEUE, +) { + init { + require(eventQueueCapacity > 0) { "An event queue holds at least one event, was $eventQueueCapacity." } + } + + public companion object { + public const val DEFAULT_EVENT_QUEUE: Int = 1024 + } +} + +/** + * One connection to an OneBot implementation, below `instances` of the plugin's config root. + * + * A transport is either what the client calls or what it listens on. [HTTP] and [WS] use [endpoint]; [HTTP_POST] + * and [WS_REVERSE] use [listenHost] and [listenPort]. + */ +@ConfigSection +@ChannelInstanceScoped +@Serializable +public class OneBotInstanceConfig( + /** How this instance reaches its implementation. */ + public val transport: String, + /** The URL of [OneBotTransport.HTTP] and [OneBotTransport.WS], such as `http://127.0.0.1:3000`. */ + public val endpoint: String? = null, + /** The address [OneBotTransport.HTTP_POST] and [OneBotTransport.WS_REVERSE] listen on. */ + @SerialName("listenHost") public val listenHost: String = DEFAULT_HOST, + /** The port [OneBotTransport.HTTP_POST] and [OneBotTransport.WS_REVERSE] listen on. */ + @SerialName("listenPort") public val listenPort: Int = 0, + /** The path [OneBotTransport.HTTP_POST] accepts reports on. */ + public val path: String = "/", + /** The token the implementation checks, null when it checks none. */ + @SerialName("accessToken") public val accessToken: Secret? = null, + /** The key the implementation signs its reports with, null when it signs none. */ + public val secret: Secret? = null, + /** The account the implementation reports for, null when the plugin does not check it. */ + @SerialName("selfId") public val selfId: String? = null, + /** How long reaching the implementation may take. */ + @SerialName("connectTimeoutMillis") public val connectTimeoutMillis: Long = DEFAULT_CONNECT_TIMEOUT, + /** How long the first byte of an answer may take. */ + @SerialName("firstByteTimeoutMillis") public val firstByteTimeoutMillis: Long = DEFAULT_FIRST_BYTE_TIMEOUT, + /** How long a call may stay silent before it is cut off. */ + @SerialName("idleTimeoutMillis") public val idleTimeoutMillis: Long = DEFAULT_IDLE_TIMEOUT, + /** How long to wait before connecting again, in milliseconds. */ + @SerialName("reconnectIntervalMillis") public val reconnectIntervalMillis: Long = DEFAULT_RECONNECT, + /** The accounts that may operate this instance, which the channel marks as admins. */ + public val admins: Set = emptySet(), +) { + /** How this instance reaches its implementation. */ + public val kind: OneBotTransport = OneBotTransport.of(transport) ?: throw IllegalArgumentException( + "transport must be one of http, http_post, ws, ws_reverse, was '$transport'", + ) + + init { + require(connectTimeoutMillis > 0) { "A connect timeout is positive, was $connectTimeoutMillis." } + require(firstByteTimeoutMillis > 0) { "A first byte timeout is positive, was $firstByteTimeoutMillis." } + require(idleTimeoutMillis > 0) { "An idle timeout is positive, was $idleTimeoutMillis." } + require(reconnectIntervalMillis > 0) { "A reconnect interval is positive, was $reconnectIntervalMillis." } + if (kind.isReceiver) { + // Port 0 lets the operating system pick one, which a test and a host that only needs to bind use; the + // caller reads the port it took from the transport. + require(listenPort in 0..MAX_PORT) { + "transport '$transport' listens on a port between 0 and $MAX_PORT, was $listenPort" + } + require(path.startsWith("/")) { "A path starts with '/', was '$path'." } + require(endpoint == null) { + "transport '$transport' listens for its implementation and takes no endpoint, was '$endpoint'" + } + } else { + require(endpoint != null) { "transport '$transport' needs the endpoint of its implementation" } + val uri = try { + java.net.URI(endpoint) + } catch (e: java.net.URISyntaxException) { + throw IllegalArgumentException("endpoint '$endpoint' is no URL: ${e.message}", e) + } + val schemes = if (kind == OneBotTransport.WS) setOf("ws", "wss") else setOf("http", "https") + require(uri.scheme in schemes) { + "transport '$transport' takes an endpoint of ${schemes.joinToString(" or ")}, was '$endpoint'" + } + require(uri.host != null) { "endpoint '$endpoint' names no host" } + } + if (selfId != null) { + require(selfId.isNotEmpty() && selfId.all(Char::isDigit)) { + "selfId is the number the implementation reports, was '$selfId'" + } + } + } + + public companion object { + public const val DEFAULT_HOST: String = "127.0.0.1" + + public const val DEFAULT_CONNECT_TIMEOUT: Long = 10_000L + + public const val DEFAULT_FIRST_BYTE_TIMEOUT: Long = 30_000L + + public const val DEFAULT_IDLE_TIMEOUT: Long = 60_000L + + public const val DEFAULT_RECONNECT: Long = 3_000L + + public const val MAX_PORT: Int = 65_535 + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt new file mode 100644 index 0000000..3b49ef3 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt @@ -0,0 +1,313 @@ +package org.foedusprogramme.alexandrite.channel.onebot.mapping + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailureKind +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.sdk.channel.ChannelCapabilities +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.DeliveryFailure +import org.foedusprogramme.alexandrite.sdk.channel.DisplayFact +import org.foedusprogramme.alexandrite.sdk.channel.IncomingMessage +import org.foedusprogramme.alexandrite.sdk.channel.Markup +import org.foedusprogramme.alexandrite.sdk.channel.MediaAttachment +import org.foedusprogramme.alexandrite.sdk.channel.MediaContent +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatInfo +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.chat.Quote +import org.foedusprogramme.alexandrite.sdk.chat.QuoteTrust +import org.foedusprogramme.alexandrite.sdk.chat.UserAddress +import org.foedusprogramme.alexandrite.sdk.transcript.MediaKind +import java.time.Instant + +/** + * The chats of an OneBot instance, printed the way the rest of Alexandrite reads them. + * + * A private chat is `private:` and a group is `group:`, the two ids the standard reports. A + * temporary session, which is a private message that came from inside a group, keeps the group as the chat and the + * sender as its thread, so that a reply goes back to the sender rather than to the group. + */ +public object OneBotChats { + /** The prefix of a private chat. */ + public const val PRIVATE: String = "private" + + /** The prefix of a group chat. */ + public const val GROUP: String = "group" + + /** The chat [event] happened in. */ + public fun addressOf(instance: ChannelInstanceId, event: OneBotEvent.Message): ChatAddress = when (event) { + is OneBotEvent.Message.Private -> if (event.subType == OneBotEvent.Message.GROUP_TEMPORARY) { + ChatAddress(instance, tempChat(event), thread = event.userId.value) + } else { + ChatAddress(instance, "$PRIVATE:${event.userId}") + } + + is OneBotEvent.Message.Group -> ChatAddress(instance, "$GROUP:${event.groupId}") + } + + /** The user of [id] in this instance. */ + public fun user(instance: ChannelInstanceId, id: UserId): UserAddress = UserAddress(instance, id.value) + + /** The chat `private:`. */ + public fun privateChat(instance: ChannelInstanceId, id: UserId): ChatAddress = + ChatAddress(instance, "$PRIVATE:${id.value}") + + /** The chat `group:`. */ + public fun groupChat(instance: ChannelInstanceId, id: GroupId): ChatAddress = + ChatAddress(instance, "$GROUP:${id.value}") + + /** The group a temporary session came from, as the implementation reported it, or a chat of the sender alone. */ + private fun tempChat(event: OneBotEvent.Message.Private): String { + val group = (event.raw["group_id"] as? JsonPrimitive)?.content + return if (group.isNullOrEmpty()) "$PRIVATE:temporary" else "$GROUP:$group" + } +} + +/** + * The messages of an OneBot instance, in both directions. + * + * A segment the SDK has no shape for is not dropped: it becomes a [DisplayFact] on the way in, so the model still + * reads that it was there, and every message keeps the raw event, so a consumer that needs the original sees it. + */ +public object OneBotMessages { + /** What an OneBot channel can do, as this module reports it. */ + public fun capabilities(): ChannelCapabilities = ChannelCapabilities.builder() + .markups(setOf(Markup.PLAIN, Markup.MARKDOWN)) + .streaming(false) + .proactive(true) + .maxPartsPerReply(null) + .build() + + /** The message [event] reported, as Alexandrite reads it. */ + public fun incoming( + instance: ChannelInstanceId, + event: OneBotEvent.Message, + admins: Set = emptySet(), + ): IncomingMessage { + val chat = OneBotChats.addressOf(instance, event) + val segments = event.message.segments + return IncomingMessage.builder( + ref = ChannelMessageRef(chat, event.messageId.value), + sender = sender(instance, event, admins), + chatInfo = chatInfo(event), + receivedAt = Instant.ofEpochSecond(event.time), + forwarded = null, + ) + .text(segments.filterIsInstance().joinToString("") { it.text }) + .media(media(segments)) + .quote(quote(event, chat)) + .facts(facts(event, segments)) + .build() + } + + /** The message that sends [message] in [chat]. */ + public fun outgoing(chat: ChatAddress, message: OutboundMessage): OneBotMessage { + val segments = when (message.markup.id) { + Markup.MARKDOWN.id -> markdown(message.text) + else -> listOf(OneBotSegment.Text(message.text)) + } + // A reply names a message of the implementation by its numeric id. A reference this version made up because + // no id came back, `sent` or `async`, names no message there, so a message that answers one is sent without + // the reply segment rather than failing before it is sent. + val reply = message.replyTo + ?.id + ?.takeIf { it.toLongOrNull() != null } + ?.let { OneBotSegment.Reply(MessageId(it)) } + return OneBotMessage.ArrayValue(if (reply == null) segments else listOf(reply) + segments) + } + + /** Whether [chat] is a group, which decides the action a message is sent with. */ + public fun isGroup(chat: ChatAddress): Boolean = chat.chat.startsWith(OneBotChats.GROUP + ":") + + /** + * The user a temporary session belongs to, null when [chat] is no such session. + * + * A temporary session is a message of a group sent privately, and its thread is the user who sent it. A reply has + * to go there rather than into the group the message came through. + */ + public fun privateTemporary(chat: ChatAddress): UserId? = chat.thread + ?.takeIf { isGroup(chat) && it.isNotEmpty() } + ?.let(::UserId) + + /** The user a private chat belongs to, null when [chat] is no private chat. */ + public fun privateUser(chat: ChatAddress): UserId? = chat.chat + .takeIf { it.startsWith(OneBotChats.PRIVATE + ":") } + ?.removePrefix(OneBotChats.PRIVATE + ":") + ?.takeIf { it.isNotEmpty() } + ?.let(::UserId) + + /** The group a group chat belongs to, null when [chat] is no group chat. */ + public fun group(chat: ChatAddress): GroupId? = chat.chat + .takeIf { it.startsWith(OneBotChats.GROUP + ":") } + ?.removePrefix(OneBotChats.GROUP + ":") + ?.takeIf { it.isNotEmpty() } + ?.let(::GroupId) + + /** What a call that ended in [result] means for the delivery of a message to [chat]. */ + public fun delivery(result: OneBotResult, chat: ChatAddress): Delivery = when (result) { + is OneBotResult.Ok -> { + // The data of the answer of a send is either the object that names the message or the message itself, + // which is what an implementation that answers with the identifier alone sends. + val data = (result.data as? JsonObject)?.get("data") as? JsonObject ?: result.data + // The `echo` of an answer is the key this client sent to match it to its call, not an id the + // implementation gave the message, so an answer that names no message is a delivery this version cannot + // name rather than one named after a correlation key. + val id = + ((data as? JsonObject)?.get("message_id") as? JsonPrimitive)?.content + ?: "sent" + Delivery.Delivered(listOf(ChannelMessageRef(chat, id))) + } + + is OneBotResult.Async -> Delivery.Delivered(listOf(ChannelMessageRef(chat, "async"))) + + is OneBotResult.Failed -> Delivery.NotDelivered(failureKind(result.retcode), result.message) + + is OneBotResult.Unreachable -> Delivery.NotDelivered( + when { + // Whether the same call may succeed again is the one thing a caller can act on, and the transport + // already answers it: a 5xx is retryable where a 4xx is not. + result.failure.retryable -> DeliveryFailure.TRANSIENT + + result.failure.kind == OneBotFailureKind.AUTHENTICATION -> DeliveryFailure.FORBIDDEN + + else -> DeliveryFailure.UNKNOWN + }, + result.failure.message, + ) + + is OneBotResult.Malformed -> Delivery.NotDelivered(DeliveryFailure.UNKNOWN, result.detail) + } + + /** The text of [message] as segments, where a Markdown image becomes an image segment. */ + private fun markdown(text: String): List { + val segments = mutableListOf() + val plain = StringBuilder() + var at = 0 + for (match in IMAGE.findAll(text)) { + plain.append(text, at, match.range.first) + if (plain.isNotEmpty()) { + segments += OneBotSegment.Text(plain.toString()) + plain.setLength(0) + } + segments += OneBotSegment.Image(file = match.groupValues[2]) + at = match.range.last + 1 + } + plain.append(text, at, text.length) + if (plain.isNotEmpty()) segments += OneBotSegment.Text(plain.toString()) + return segments + } + + /** The failure an implementation's [retcode] means, as far as this version can tell. */ + private fun failureKind(retcode: Int): DeliveryFailure = when (retcode) { + RATE_LIMITED -> DeliveryFailure.RATE_LIMITED + FORBIDDEN, NOT_ALLOWED -> DeliveryFailure.FORBIDDEN + TOO_LONG -> DeliveryFailure.TOO_LONG + NOT_FOUND -> DeliveryFailure.CHAT_GONE + else -> DeliveryFailure.UNKNOWN + } + + private fun sender(instance: ChannelInstanceId, event: OneBotEvent.Message, admins: Set): ChatUser { + val reported = event.sender + val id = event.userId.value + val card = reported.card?.takeIf { it.isNotEmpty() } + return ChatUser( + address = OneBotChats.user(instance, event.userId), + displayName = card ?: reported.nickname ?: id, + username = null, + isBot = reported.userId == event.selfId.value, + isAdmin = id in admins || (event is OneBotEvent.Message.Group && reported.isAdmin), + ) + } + + private fun chatInfo(event: OneBotEvent.Message): ChatInfo = when (event) { + is OneBotEvent.Message.Private -> ChatInfo(ChatKind.DIRECT, null, null) + + // A group message carries no name of the group, and the card of whoever spoke is the name of that member, so + // putting it here would label the chat with the last person who said something. + is OneBotEvent.Message.Group -> ChatInfo(ChatKind.GROUP, null, null) + } + + private fun media(segments: List): List = segments.mapNotNull { segment -> + val attachment = when (segment) { + is OneBotSegment.Image -> MediaKind.IMAGE to (segment.url ?: segment.file) + is OneBotSegment.Record -> MediaKind.AUDIO to (segment.url ?: segment.file) + is OneBotSegment.Video -> MediaKind.VIDEO to (segment.url ?: segment.file) + else -> return@mapNotNull null + } + val (kind, reference) = attachment + if (reference.isNullOrEmpty()) return@mapNotNull null + MediaAttachment.builder(kind, mediaType(kind), unread(reference)) + .name(reference.substringAfterLast('/').takeIf { it.isNotEmpty() }) + .build() + } + + /** The media type this version reports while it cannot read the file itself. */ + private fun mediaType(kind: MediaKind): String = when (kind) { + MediaKind.IMAGE -> "image/*" + MediaKind.AUDIO -> "audio/*" + else -> "video/*" + } + + /** + * The bytes of the file an implementation named, which this version does not read yet. + * + * Reading it needs the plugin's transport, so it stays the task of whoever asks for the bytes; failing here keeps + * that visible rather than answering with an empty file. + */ + private fun unread(reference: String): MediaContent = MediaContent { + throw UnsupportedOperationException("Reading '$reference' is not implemented yet.") + } + + private fun quote(event: OneBotEvent.Message, chat: ChatAddress): Quote? { + val reply = event.message.segments.filterIsInstance().firstOrNull() ?: return null + return Quote.builder("") + .target(ChannelMessageRef(chat, reply.id.value)) + .trust(QuoteTrust.UNTRUSTED) + .build() + } + + private fun facts(event: OneBotEvent.Message, segments: List): List = buildList { + if (event is OneBotEvent.Message.Group) { + event.anonymous?.let { add(DisplayFact("anonymous", it.name ?: it.flag ?: "unnamed")) } + } + for (segment in segments) { + when (segment) { + is OneBotSegment.Unknown -> add(DisplayFact("unsupported segment", segment.type)) + is OneBotSegment.Json -> add(DisplayFact("json message", segment.data)) + is OneBotSegment.Xml -> add(DisplayFact("xml message", segment.data)) + is OneBotSegment.Forward -> add(DisplayFact("forwarded message", segment.id)) + is OneBotSegment.At -> add(DisplayFact("mentioned", segment.userId)) + is OneBotSegment.Image -> add(DisplayFact("image", segment.file ?: segment.url ?: "unnamed")) + is OneBotSegment.Record -> add(DisplayFact("voice", segment.file ?: segment.url ?: "unnamed")) + is OneBotSegment.Video -> add(DisplayFact("video", segment.file ?: segment.url ?: "unnamed")) + is OneBotSegment.Face -> add(DisplayFact("emoji", segment.id)) + is OneBotSegment.Share -> add(DisplayFact("link", segment.url)) + else -> Unit + } + } + } + + private val IMAGE = Regex("!\\[([^\\]]*)]\\(([^)\\s]+)\\)") + + private const val RATE_LIMITED = 1004 + + private const val FORBIDDEN = 1003 + + private const val NOT_ALLOWED = 1002 + + private const val TOO_LONG = 1005 + + private const val NOT_FOUND = 100 +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/OneBotIds.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/OneBotIds.kt new file mode 100644 index 0000000..65a3c2e --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/OneBotIds.kt @@ -0,0 +1,119 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * A QQ number. + * + * OneBot prints it as a JSON number, but implementations also send it as a string, so [OneBotJson] decodes both. + * The value is kept as the string the platform used, so a number outside `Long` still round-trips. + */ +@JvmInline +@Serializable(with = UserIdSerializer::class) +public value class UserId(public val value: String) { + init { + require(value.isNotEmpty() && value.all(Char::isDigit)) { "A user id is a non-empty number, was '$value'." } + } + + /** The number, null when it does not fit a [Long]. */ + public val number: Long? get() = value.toLongOrNull() + + override fun toString(): String = value +} + +/** A group number, kept as [UserId] keeps a user number. */ +@JvmInline +@Serializable(with = GroupIdSerializer::class) +public value class GroupId(public val value: String) { + init { + require(value.isNotEmpty() && value.all(Char::isDigit)) { "A group id is a non-empty number, was '$value'." } + } + + public val number: Long? get() = value.toLongOrNull() + + override fun toString(): String = value +} + +/** The message id of one platform message, kept as [UserId] keeps a number. */ +@JvmInline +@Serializable(with = MessageIdSerializer::class) +public value class MessageId(public val value: String) { + init { + require(value.isNotEmpty() && value.all(Char::isDigit)) { "A message id is a non-empty number, was '$value'." } + } + + public val number: Long? get() = value.toLongOrNull() + + override fun toString(): String = value +} + +/** The QQ number an implementation reports as its own. */ +@JvmInline +@Serializable(with = SelfIdSerializer::class) +public value class SelfId(public val value: String) { + init { + require(value.isNotEmpty() && value.all(Char::isDigit)) { "A self id is a non-empty number, was '$value'." } + } + + public val number: Long? get() = value.toLongOrNull() + + override fun toString(): String = value +} + +internal object UserIdSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OneBotUserId", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: UserId) { + encoder.encodeNumber(value.value, value.number) + } + + override fun deserialize(decoder: Decoder): UserId = UserId(decoder.numberText()) +} + +internal object GroupIdSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OneBotGroupId", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: GroupId) { + encoder.encodeNumber(value.value, value.number) + } + + override fun deserialize(decoder: Decoder): GroupId = GroupId(decoder.numberText()) +} + +internal object MessageIdSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OneBotMessageId", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: MessageId) { + encoder.encodeNumber(value.value, value.number) + } + + override fun deserialize(decoder: Decoder): MessageId = MessageId(decoder.numberText()) +} + +internal object SelfIdSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OneBotSelfId", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: SelfId) { + encoder.encodeNumber(value.value, value.number) + } + + override fun deserialize(decoder: Decoder): SelfId = SelfId(decoder.numberText()) +} + +/** Prints [number] as a JSON number when it fits one, and the id as a string when it does not. */ +private fun Encoder.encodeNumber(id: String, number: Long?) { + if (number == null) encodeString(id) else encodeLong(number) +} + +/** The number [this] holds, whether the platform printed it as a JSON number or as a string. */ +private fun Decoder.numberText(): String { + val text = decodeString() + require(text.isNotEmpty() && text.all(Char::isDigit)) { "Expected a number, got '$text'." } + return text +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/OneBotStatus.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/OneBotStatus.kt new file mode 100644 index 0000000..ebb96f4 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/OneBotStatus.kt @@ -0,0 +1,80 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol + +import kotlinx.serialization.Serializable + +/** + * The `status` field of an OneBot response. + * + * `ok` carries `retcode` 0, `async` carries 1, and `failed` carries anything else, so an implementation's own + * code stays readable through [of]. + */ +@JvmInline +@Serializable +public value class OneBotStatus(public val value: String) { + init { + require(value.isNotEmpty()) { "A status may not be empty." } + } + + override fun toString(): String = value + + public companion object { + public val OK: OneBotStatus = OneBotStatus("ok") + + public val ASYNC: OneBotStatus = OneBotStatus("async") + + public val FAILED: OneBotStatus = OneBotStatus("failed") + + /** The values this version knows. */ + public val entries: List = listOf(OK, ASYNC, FAILED) + + /** The value of [value], which keeps a status this version does not know. */ + public fun of(value: String): OneBotStatus = OneBotStatus(value) + + /** The status that [retcode] means. */ + public fun ofRetcode(retcode: Int): OneBotStatus = when (retcode) { + OneBotRetcode.OK -> OK + OneBotRetcode.ASYNC -> ASYNC + else -> FAILED + } + } +} + +/** The `retcode` values OneBot v11 defines. */ +public object OneBotRetcode { + public const val OK: Int = 0 + + public const val ASYNC: Int = 1 + + /** Bad request, the WebSocket counterpart of HTTP 400. */ + public const val BAD_REQUEST: Int = 1400 + + /** Unauthorized, the counterpart of HTTP 401. */ + public const val UNAUTHORIZED: Int = 1401 + + /** Forbidden, the counterpart of HTTP 403. */ + public const val FORBIDDEN: Int = 1403 + + /** No such action, the counterpart of HTTP 404. */ + public const val NOT_FOUND: Int = 1404 + + /** The counterpart of the HTTP status [status], which a WebSocket reports instead of a status line. */ + public fun ofHttp(status: Int): Int = when (status) { + 400 -> BAD_REQUEST + 401 -> UNAUTHORIZED + 403 -> FORBIDDEN + 404 -> NOT_FOUND + else -> BAD_REQUEST + } +} + +/** What an OneBot response was about, used to report and to classify failures. */ +public enum class OneBotResponseKind { + /** A synchronous success. */ + OK, + + /** Work accepted for later, whose outcome the client cannot learn. */ + ASYNC, + + /** The implementation refused the call, see its `retcode`. */ + FAILED, +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotAction.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotAction.kt new file mode 100644 index 0000000..c3142d1 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotAction.kt @@ -0,0 +1,82 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.api + +/** + * One action of the OneBot API. + * + * The suffix of a call is not part of the action: `send_private_msg_async` calls [name] with [OneBotCallSuffix.ASYNC], + * so an implementation never has to copy an action for the variants the standard derives. + */ +public data class OneBotAction( + /** The action name, such as `send_private_msg`. */ + public val name: String, + /** Which part of the API the action belongs to. */ + public val category: OneBotCategory, + /** Whether the action is one the standard hides from ordinary users, such as `.handle_quick_operation`. */ + public val hidden: Boolean = false, + /** Whether an implementation runs the action anyway, so the `async` suffix adds nothing. */ + public val inherentlyAsync: Boolean = false, +) { + init { + require(name.isNotEmpty()) { "An action needs a name." } + } + + /** Whether the action exists under [suffix]. */ + public fun accepts(suffix: OneBotCallSuffix): Boolean = when (suffix) { + OneBotCallSuffix.NONE -> true + OneBotCallSuffix.ASYNC, OneBotCallSuffix.RATE_LIMITED -> category.derivable + } + + /** The action name of a call with [suffix]. */ + public fun actionName(suffix: OneBotCallSuffix): String = name + suffix.suffix + + public companion object { + /** The prefix every hidden action carries. */ + public const val HIDDEN_PREFIX: String = "." + } +} + +/** A variant of an action the standard derives with a suffix. */ +public enum class OneBotCallSuffix(public val suffix: String) { + /** The plain call. */ + NONE(""), + + /** The call the implementation answers at once and carries out later. */ + ASYNC("_async"), + + /** The call the implementation queues behind its own rate limit. */ + RATE_LIMITED("_rate_limited"), + ; + + public companion object { + /** The suffix [action] carries, null when it carries none. */ + public fun of(action: String): OneBotCallSuffix? = + entries.firstOrNull { it != NONE && action.endsWith(it.suffix) } + + /** [action] without the suffix [this] names. */ + public fun strip(action: String): String = + entries.filter { it != NONE }.firstOrNull { action.endsWith(it.suffix) } + ?.let { action.dropLast(it.suffix.length) } + ?: action + } +} + +/** Which part of the API an action belongs to, which decides whether the derived suffixes exist. */ +public enum class OneBotCategory( + /** Whether the standard derives the `_async` and `_rate_limited` calls of the actions. */ + public val derivable: Boolean, +) { + /** Messages: sending, recalling and reading them. */ + MESSAGE(true), + + /** Friends, groups and their members. */ + GROUP(true), + + /** The credentials and the files of the implementation. */ + ACCOUNT(true), + + /** The implementation itself: its status, its version, its restart. */ + SYSTEM(true), + + /** What an implementation does on its own, such as running a quick operation. */ + HIDDEN(false), +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotApiTypes.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotApiTypes.kt new file mode 100644 index 0000000..04907fc --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotApiTypes.kt @@ -0,0 +1,170 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId + +/** + * The answer `get_msg` gives. + * + * Fields an implementation adds are not kept here; a caller that needs one reads the raw response the result + * carries. + */ +@Serializable +public class OneBotMessageInfo( + @SerialName("time") public val time: Long? = null, + @SerialName("message_type") public val messageType: String? = null, + @SerialName("message_id") public val messageId: MessageId? = null, + @SerialName("real_id") public val realId: MessageId? = null, + @SerialName("sender") public val sender: OneBotSenderInfo? = null, + @SerialName("message") public val message: kotlinx.serialization.json.JsonElement? = null, +) + +/** The sender fields `get_msg` and the message events share. */ +@Serializable +public class OneBotSenderInfo( + @SerialName("user_id") public val userId: UserId? = null, + @SerialName("nickname") public val nickname: String? = null, + @SerialName("sex") public val sex: String? = null, + @SerialName("age") public val age: Int? = null, + @SerialName("card") public val card: String? = null, + @SerialName("area") public val area: String? = null, + @SerialName("level") public val level: String? = null, + @SerialName("role") public val role: String? = null, + @SerialName("title") public val title: String? = null, +) + +/** One friend, as `get_friend_list` reports it. */ +@Serializable +public class OneBotFriend( + @SerialName("user_id") public val userId: UserId, + @SerialName("nickname") public val nickname: String = "", + @SerialName("remark") public val remark: String? = null, +) + +/** A stranger, as `get_stranger_info` reports them. */ +@Serializable +public class OneBotStrangerInfo( + @SerialName("user_id") public val userId: UserId? = null, + @SerialName("nickname") public val nickname: String? = null, + @SerialName("sex") public val sex: String? = null, + @SerialName("age") public val age: Int? = null, +) + +/** A group, as `get_group_info` and `get_group_list` report it. */ +@Serializable +public class OneBotGroupInfo( + @SerialName("group_id") public val groupId: GroupId? = null, + @SerialName("group_name") public val groupName: String? = null, + @SerialName("member_count") public val memberCount: Int? = null, + @SerialName("max_member_count") public val maxMemberCount: Int? = null, +) + +/** One group member, as `get_group_member_info` and `get_group_member_list` report them. */ +@Serializable +public class OneBotGroupMemberInfo( + @SerialName("group_id") public val groupId: GroupId? = null, + @SerialName("user_id") public val userId: UserId? = null, + @SerialName("nickname") public val nickname: String? = null, + @SerialName("card") public val card: String? = null, + @SerialName("sex") public val sex: String? = null, + @SerialName("age") public val age: Int? = null, + @SerialName("area") public val area: String? = null, + @SerialName("join_time") public val joinTime: Long? = null, + @SerialName("last_sent_time") public val lastSentTime: Long? = null, + @SerialName("level") public val level: String? = null, + @SerialName("role") public val role: String? = null, + @SerialName("unfriendly") public val unfriendly: Boolean? = null, + @SerialName("title") public val title: String? = null, + @SerialName("title_expire_time") public val titleExpireTime: Long? = null, + @SerialName("card_changeable") public val cardChangeable: Boolean? = null, +) + +/** The honors of a group, as `get_group_honor_info` reports them. */ +@Serializable +public class OneBotGroupHonorInfo( + @SerialName("group_id") public val groupId: GroupId? = null, + @SerialName("current_talkative") public val currentTalkative: OneBotTalkative? = null, + @SerialName("talkative_list") public val talkativeList: List = emptyList(), + @SerialName("performer_list") public val performerList: List = emptyList(), + @SerialName("legend_list") public val legendList: List = emptyList(), + @SerialName("strong_newbie_list") public val strongNewbieList: List = emptyList(), + @SerialName("emotion_list") public val emotionList: List = emptyList(), +) + +/** The current 龙王 of a group. */ +@Serializable +public class OneBotTalkative( + @SerialName("user_id") public val userId: UserId? = null, + @SerialName("nickname") public val nickname: String? = null, + @SerialName("avatar") public val avatar: String? = null, + @SerialName("day_count") public val dayCount: Int? = null, +) + +/** One entry of a group's honor lists. */ +@Serializable +public class OneBotHonor( + @SerialName("user_id") public val userId: UserId? = null, + @SerialName("nickname") public val nickname: String? = null, + @SerialName("avatar") public val avatar: String? = null, + @SerialName("description") public val description: String? = null, +) + +/** The cookies of a domain. */ +@Serializable +public class OneBotCookies(@SerialName("cookies") public val cookies: String = "") + +/** A CSRF token. */ +@Serializable +public class OneBotCsrfToken(@SerialName("token") public val token: Long? = null) + +/** The cookies and CSRF token of a domain. */ +@Serializable +public class OneBotCredentials( + @SerialName("cookies") public val cookies: String = "", + @SerialName("csrf_token") public val csrfToken: Long? = null, +) + +/** The path of a file an implementation converted or downloaded. */ +@Serializable +public class OneBotFile(@SerialName("file") public val file: String = "") + +/** Whether an implementation can send a kind of media. */ +@Serializable +public class OneBotYesNo(@SerialName("yes") public val yes: Boolean = false) + +/** The status of an implementation, whose extra fields stay in the raw response. */ +@Serializable +public class OneBotStatusInfo( + /** Whether the account is online, null when the implementation cannot tell. */ + @SerialName("online") public val online: Boolean? = null, + /** Whether every module of the implementation works and the account is online. */ + @SerialName("good") public val good: Boolean? = null, +) + +/** What an implementation says it is. */ +@Serializable +public class OneBotVersionInfo( + @SerialName("app_name") public val appName: String? = null, + @SerialName("app_version") public val appVersion: String? = null, + @SerialName("protocol_version") public val protocolVersion: String? = null, +) + +/** The login of an implementation. */ +@Serializable +public class OneBotLoginInfo( + @SerialName("user_id") public val userId: UserId? = null, + @SerialName("nickname") public val nickname: String? = null, +) + +/** The data of a message that an implementation sent. */ +@Serializable +public class OneBotMessageSent(@SerialName("message_id") public val messageId: MessageId? = null) + +/** The result of `get_forward_msg`, whose nodes are the segments of the message. */ +@Serializable +public class OneBotForwardMessage( + @SerialName("message") public val message: kotlinx.serialization.json.JsonElement? = null, +) diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRegistry.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRegistry.kt new file mode 100644 index 0000000..bbcc0ee --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRegistry.kt @@ -0,0 +1,109 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.api + +/** + * Every action of the OneBot v11 API, and how a call names one. + * + * A call is an action plus an [OneBotCallSuffix], so the `_async` and `_rate_limited` variants the standard derives + * exist without an implementation of their own. An action this version does not know is still a call: [resolve] + * reports it as unknown and the transport sends its name as it is, which keeps an implementation's own extension + * usable without a release of this module. + */ +public object OneBotRegistry { + /** The action that runs a quick operation on an event. */ + public val QUICK_OPERATION: OneBotAction = + OneBotAction(".handle_quick_operation", OneBotCategory.HIDDEN, hidden = true) + + /** The hidden actions of the standard. */ + public val hiddenActions: List = listOf(QUICK_OPERATION) + + /** The actions the standard lists, by name. */ + public val actions: List = listOf( + OneBotAction("send_private_msg", OneBotCategory.MESSAGE), + OneBotAction("send_group_msg", OneBotCategory.MESSAGE), + OneBotAction("send_msg", OneBotCategory.MESSAGE), + OneBotAction("delete_msg", OneBotCategory.MESSAGE), + OneBotAction("get_msg", OneBotCategory.MESSAGE), + OneBotAction("get_forward_msg", OneBotCategory.MESSAGE), + OneBotAction("send_like", OneBotCategory.GROUP), + OneBotAction("set_group_kick", OneBotCategory.GROUP), + OneBotAction("set_group_ban", OneBotCategory.GROUP), + OneBotAction("set_group_anonymous_ban", OneBotCategory.GROUP), + OneBotAction("set_group_whole_ban", OneBotCategory.GROUP), + OneBotAction("set_group_admin", OneBotCategory.GROUP), + OneBotAction("set_group_anonymous", OneBotCategory.GROUP), + OneBotAction("set_group_card", OneBotCategory.GROUP), + OneBotAction("set_group_name", OneBotCategory.GROUP), + OneBotAction("set_group_leave", OneBotCategory.GROUP), + OneBotAction("set_group_special_title", OneBotCategory.GROUP), + OneBotAction("set_friend_add_request", OneBotCategory.ACCOUNT), + OneBotAction("set_group_add_request", OneBotCategory.ACCOUNT), + OneBotAction("get_login_info", OneBotCategory.ACCOUNT), + OneBotAction("get_stranger_info", OneBotCategory.ACCOUNT), + OneBotAction("get_friend_list", OneBotCategory.ACCOUNT), + OneBotAction("get_group_info", OneBotCategory.GROUP), + OneBotAction("get_group_list", OneBotCategory.GROUP), + OneBotAction("get_group_member_info", OneBotCategory.GROUP), + OneBotAction("get_group_member_list", OneBotCategory.GROUP), + OneBotAction("get_group_honor_info", OneBotCategory.GROUP), + OneBotAction("get_cookies", OneBotCategory.ACCOUNT), + OneBotAction("get_csrf_token", OneBotCategory.ACCOUNT), + OneBotAction("get_credentials", OneBotCategory.ACCOUNT), + OneBotAction("get_record", OneBotCategory.ACCOUNT), + OneBotAction("get_image", OneBotCategory.ACCOUNT), + OneBotAction("can_send_image", OneBotCategory.SYSTEM), + OneBotAction("can_send_record", OneBotCategory.SYSTEM), + OneBotAction("get_status", OneBotCategory.SYSTEM), + OneBotAction("get_version_info", OneBotCategory.SYSTEM), + OneBotAction("set_restart", OneBotCategory.SYSTEM), + OneBotAction("clean_cache", OneBotCategory.SYSTEM), + ) + + private val byName: Map = (actions + hiddenActions).associateBy(OneBotAction::name) + + /** The action named [name], null when this version does not know it. */ + public fun action(name: String): OneBotAction? = byName[name] + + /** How [call] names an action, unknown when this version does not list the action it names. */ + public fun resolve(call: String): OneBotCall { + val suffix = OneBotCallSuffix.of(call) ?: OneBotCallSuffix.NONE + val name = OneBotCallSuffix.strip(call) + val action = byName[name] + return if (action != null) { + OneBotCall.Known(action, suffix) + } else { + OneBotCall.Unknown(call, name, suffix) + } + } + + /** Whether [call] names an action this version lists. */ + public fun knows(call: String): Boolean = resolve(call) is OneBotCall.Known +} + +/** How a call names one action. */ +public sealed interface OneBotCall { + /** The action the call names. */ + public val action: String + + /** The suffix the call carries. */ + public val suffix: OneBotCallSuffix + + /** A call of an action this version lists. */ + public data class Known(public val definition: OneBotAction, override val suffix: OneBotCallSuffix) : OneBotCall { + override val action: String get() = definition.name + + /** Whether the action exists in the shape the call asks for. */ + public val isSupported: Boolean get() = definition.accepts(suffix) + } + + /** + * A call of an action this version does not list. + * + * [name] is [action] without the suffix, so a transport sends [action] as the implementation wrote it and can + * still tell the variants apart. + */ + public data class Unknown( + override val action: String, + public val name: String, + override val suffix: OneBotCallSuffix, + ) : OneBotCall +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRequests.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRequests.kt new file mode 100644 index 0000000..7d79f41 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRequests.kt @@ -0,0 +1,443 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.api + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegmentCodec + +/** + * The parameters of one API call. + * + * A request writes only the fields it holds, because the standard gives several of them defaults that an + * implementation applies itself, and writing a default would take that choice away from it. + */ +public sealed interface OneBotRequest { + /** The parameters of the call. */ + public fun toJson(): JsonObject +} + +/** `send_private_msg`: sends a message to one user. */ +public class SendPrivateMessage( + public val userId: UserId, + public val message: OneBotMessage, + /** Whether the implementation sends the message as plain text, which only a string message allows. */ + public val autoEscape: Boolean = false, +) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("user_id", OneBotSegmentCodec.number(userId.value)) + put("message", OneBotSegmentCodec.encodeMessage(message)) + // Only a whole message as one piece of text can be taken as it is written, which is what an implementation + // reads `auto_escape` as. A message of segments is sent as segments, so that the CQ text one of them holds + // is not read as an escape it never asked for. + if (autoEscape && message is OneBotMessage.StringValue) put("auto_escape", true) + } +} + +/** `send_group_msg`: sends a message to one group. */ +public class SendGroupMessage( + public val groupId: GroupId, + public val message: OneBotMessage, + public val autoEscape: Boolean = false, +) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("message", OneBotSegmentCodec.encodeMessage(message)) + // Only a whole message as one piece of text can be taken as it is written, which is what an implementation + // reads `auto_escape` as. A message of segments is sent as segments, so that the CQ text one of them holds + // is not read as an escape it never asked for. + if (autoEscape && message is OneBotMessage.StringValue) put("auto_escape", true) + } +} + +/** `send_msg`: sends a message, whose chat the implementation takes from whichever id the request holds. */ +public class SendMessage( + public val message: OneBotMessage, + /** `private` or `group`, null to let the implementation decide from the ids. */ + public val messageType: String? = null, + public val userId: UserId? = null, + public val groupId: GroupId? = null, + public val autoEscape: Boolean = false, +) : OneBotRequest { + init { + require(userId != null || groupId != null) { "A message needs a user id or a group id." } + require(messageType == null || messageType == PRIVATE || messageType == GROUP) { + "A message type is '$PRIVATE' or '$GROUP', was '$messageType'." + } + } + + override fun toJson(): JsonObject = buildJsonObject { + messageType?.let { put("message_type", it) } + userId?.let { put("user_id", OneBotSegmentCodec.number(it.value)) } + groupId?.let { put("group_id", OneBotSegmentCodec.number(it.value)) } + put("message", OneBotSegmentCodec.encodeMessage(message)) + // Only a whole message as one piece of text can be taken as it is written, which is what an implementation + // reads `auto_escape` as. A message of segments is sent as segments, so that the CQ text one of them holds + // is not read as an escape it never asked for. + if (autoEscape && message is OneBotMessage.StringValue) put("auto_escape", true) + } + + public companion object { + public const val PRIVATE: String = "private" + public const val GROUP: String = "group" + } +} + +/** `delete_msg`: recalls a message. */ +public class DeleteMessage(public val messageId: MessageId) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("message_id", OneBotSegmentCodec.number(messageId.value)) + } +} + +/** `get_msg`: reads one message. */ +public class GetMessage(public val messageId: MessageId) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("message_id", OneBotSegmentCodec.number(messageId.value)) + } +} + +/** `get_forward_msg`: reads the nodes of a forwarded message. */ +public class GetForwardMessage(public val id: String) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { put("id", id) } +} + +/** `send_like`: gives a friend a thumbs up. */ +public class SendLike(public val userId: UserId, public val times: Int = 1) : OneBotRequest { + init { + require(times > 0) { "A like is sent at least once, was $times." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("user_id", OneBotSegmentCodec.number(userId.value)) + put("times", times) + } +} + +/** `set_group_kick`: removes a member from a group. */ +public class SetGroupKick( + public val groupId: GroupId, + public val userId: UserId, + /** Whether the implementation also refuses that member's pending request to join. */ + public val rejectAddRequest: Boolean = false, +) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) + if (rejectAddRequest) put("reject_add_request", true) + } +} + +/** `set_group_ban`: mutes one member of a group, or lifts their mute with a duration of 0. */ +public class SetGroupBan( + public val groupId: GroupId, + public val userId: UserId, + /** The mute lasts this many seconds, 0 lifts it. */ + public val duration: Long = DEFAULT_DURATION, +) : OneBotRequest { + init { + require(duration >= 0) { "A mute lasts at least no time, was $duration." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) + put("duration", duration) + } + + public companion object { + /** The mute the standard gives a call that names none: 30 minutes. */ + public const val DEFAULT_DURATION: Long = 30L * 60L + } +} + +/** `set_group_anonymous_ban`: mutes one anonymous member of a group. */ +public class SetGroupAnonymousBan( + public val groupId: GroupId, + /** The flag the message event reported, which identifies the anonymous member. */ + public val flag: String, + public val duration: Long = SetGroupBan.DEFAULT_DURATION, +) : OneBotRequest { + init { + require(flag.isNotBlank()) { "An anonymous ban needs the flag of the anonymous member." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("anonymous_flag", flag) + put("duration", duration) + } +} + +/** `set_group_whole_ban`: mutes everyone in a group, or lifts it. */ +public class SetGroupWholeBan(public val groupId: GroupId, public val enable: Boolean = true) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("enable", enable) + } +} + +/** `set_group_admin`: makes a member of a group an administrator, or takes the role away. */ +public class SetGroupAdmin(public val groupId: GroupId, public val userId: UserId, public val enable: Boolean = true) : + OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) + put("enable", enable) + } +} + +/** `set_group_anonymous`: allows or forbids anonymous messages in a group. */ +public class SetGroupAnonymous(public val groupId: GroupId, public val enable: Boolean = true) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("enable", enable) + } +} + +/** `set_group_card`: writes a member's card in a group, or removes it with an empty card. */ +public class SetGroupCard(public val groupId: GroupId, public val userId: UserId, public val card: String = "") : + OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) + put("card", card) + } +} + +/** `set_group_name`: renames a group. */ +public class SetGroupName(public val groupId: GroupId, public val groupName: String) : OneBotRequest { + init { + require(groupName.isNotBlank()) { "A group name may not be blank." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("group_name", groupName) + } +} + +/** `set_group_leave`: leaves a group, or dismisses it when the account owns it and [isDismiss] is set. */ +public class SetGroupLeave(public val groupId: GroupId, public val isDismiss: Boolean = false) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + if (isDismiss) put("is_dismiss", true) + } +} + +/** `set_group_special_title`: writes a member's special title, or removes it with an empty title. */ +public class SetGroupSpecialTitle( + public val groupId: GroupId, + public val userId: UserId, + public val specialTitle: String = "", + /** The title lasts this many seconds, -1 for good. */ + public val duration: Long = PERMANENT, +) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) + put("special_title", specialTitle) + put("duration", duration) + } + + public companion object { + public const val PERMANENT: Long = -1L + } +} + +/** `set_friend_add_request`: answers a friend request. */ +public class SetFriendAddRequest( + /** The flag the request event reported. */ + public val flag: String, + public val approve: Boolean = true, + /** The remark to save with a friend the call accepts. */ + public val remark: String = "", +) : OneBotRequest { + init { + require(flag.isNotBlank()) { "A request answer needs the flag of the request." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("flag", flag) + put("approve", approve) + if (approve && remark.isNotEmpty()) put("remark", remark) + } +} + +/** `set_group_add_request`: answers a request to join a group, or an invitation to one. */ +public class SetGroupAddRequest( + public val flag: String, + /** `add` or `invite`, which must match the subtype the request event reported. */ + public val subType: String, + public val approve: Boolean = true, + /** The reason to give a request the call refuses. */ + public val reason: String = "", +) : OneBotRequest { + init { + require(flag.isNotBlank()) { "A request answer needs the flag of the request." } + require(subType == ADD || subType == INVITE) { "A group request is '$ADD' or '$INVITE', was '$subType'." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("flag", flag) + put("sub_type", subType) + put("approve", approve) + if (!approve && reason.isNotEmpty()) put("reason", reason) + } + + public companion object { + public const val ADD: String = "add" + public const val INVITE: String = "invite" + } +} + +/** `get_stranger_info`: reads a user's information. */ +public class GetStrangerInfo(public val userId: UserId, public val noCache: Boolean = false) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("user_id", OneBotSegmentCodec.number(userId.value)) + if (noCache) put("no_cache", true) + } +} + +/** `get_group_info`: reads a group's information. */ +public class GetGroupInfo(public val groupId: GroupId, public val noCache: Boolean = false) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + if (noCache) put("no_cache", true) + } +} + +/** `get_group_member_info`: reads one member's information in a group. */ +public class GetGroupMemberInfo( + public val groupId: GroupId, + public val userId: UserId, + public val noCache: Boolean = false, +) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) + if (noCache) put("no_cache", true) + } +} + +/** `get_group_member_list`: reads the members of a group. */ +public class GetGroupMemberList(public val groupId: GroupId) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { put("group_id", OneBotSegmentCodec.number(groupId.value)) } +} + +/** `get_group_honor_info`: reads the honors of a group. */ +public class GetGroupHonorInfo(public val groupId: GroupId, public val type: String = ALL) : OneBotRequest { + init { + require(type.isNotEmpty()) { "A honor type may not be empty." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("type", type) + } + + public companion object { + public const val TALKATIVE: String = "talkative" + public const val PERFORMER: String = "performer" + public const val LEGEND: String = "legend" + public const val STRONG_NEWBIE: String = "strong_newbie" + public const val EMOTION: String = "emotion" + public const val ALL: String = "all" + } +} + +/** `get_cookies`: reads the cookies of a domain. */ +public class GetCookies(public val domain: String = "") : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + if (domain.isNotEmpty()) put("domain", domain) + } +} + +/** `get_credentials`: reads the cookies and the CSRF token of a domain. */ +public class GetCredentials(public val domain: String = "") : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + if (domain.isNotEmpty()) put("domain", domain) + } +} + +/** `get_record`: asks the implementation to convert a voice message. */ +public class GetRecord(public val file: String, public val outFormat: String) : OneBotRequest { + init { + require(file.isNotBlank()) { "A record needs the file the message segment named." } + require(outFormat.isNotBlank()) { "A record conversion needs its output format." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("file", file) + put("out_format", outFormat) + } +} + +/** `get_image`: asks the implementation to download an image. */ +public class GetImage(public val file: String) : OneBotRequest { + init { + require(file.isNotBlank()) { "An image needs the file the message segment named." } + } + + override fun toJson(): JsonObject = buildJsonObject { put("file", file) } +} + +/** `set_restart`: asks the implementation to restart itself. */ +public class SetRestart(public val delayMillis: Long = 0) : OneBotRequest { + init { + require(delayMillis >= 0) { "A restart waits at least no time, was $delayMillis." } + } + + override fun toJson(): JsonObject = buildJsonObject { + if (delayMillis > 0) put("delay", delayMillis) + } +} + +/** `.handle_quick_operation`: runs a quick operation as if an event's report had asked for it. */ +public class HandleQuickOperation( + /** The event object, which may name only the fields the operation reads. */ + public val context: JsonObject, + /** The operation, such as `{"ban": true, "reply": "please stop"}`. */ + public val operation: JsonObject, +) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("context", context) + put("operation", operation) + } +} + +/** The calls that take no parameters at all. */ +public enum class OneBotNoParameters : OneBotRequest { + /** `get_login_info`. */ + GET_LOGIN_INFO, + + /** `get_friend_list`. */ + GET_FRIEND_LIST, + + /** `get_group_list`. */ + GET_GROUP_LIST, + + /** `get_csrf_token`. */ + GET_CSRF_TOKEN, + + /** `can_send_image`. */ + CAN_SEND_IMAGE, + + /** `can_send_record`. */ + CAN_SEND_RECORD, + + /** `get_status`. */ + GET_STATUS, + + /** `get_version_info`. */ + GET_VERSION_INFO, + + /** `clean_cache`. */ + CLEAN_CACHE, + ; + + override fun toJson(): JsonObject = JsonObject(emptyMap()) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEvent.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEvent.kt new file mode 100644 index 0000000..b2c1d2c --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEvent.kt @@ -0,0 +1,490 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.event + +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.SelfId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotMessage + +/** + * One event an OneBot implementation reported. + * + * Every event carries the fields the standard gives every event, and [raw] keeps the whole JSON object, so a + * consumer reads a field of an implementation's own extension without this module knowing it. + */ +public sealed interface OneBotEvent { + /** When the event happened, in seconds since the epoch. */ + public val time: Long + + /** The QQ number the implementation reported the event for. */ + public val selfId: SelfId + + /** The `post_type` of the event. */ + public val postType: String + + /** The whole event object as the implementation sent it. */ + public val raw: JsonObject + + /** The quick operation fields this event accepts. */ + public val quickOperations: Set + + /** A message event, private or in a group. */ + public sealed interface Message : OneBotEvent { + /** The message. */ + public val message: OneBotMessage + + /** The message as the implementation spelled it, with its CQ codes. */ + public val rawMessage: String + + public val messageId: MessageId + + public val userId: UserId + + /** The sender, as far as the implementation could tell. */ + public val sender: Sender + + /** How the message arrived. */ + public val subType: String + + /** A private message. */ + public data class Private( + override val time: Long, + override val selfId: SelfId, + override val message: OneBotMessage, + override val rawMessage: String, + override val messageId: MessageId, + override val userId: UserId, + override val sender: Sender, + override val subType: String, + override val raw: JsonObject, + ) : Message { + override val postType: String get() = POST_TYPE + override val quickOperations: Set get() = REPLY + } + + /** A group message. */ + public data class Group( + override val time: Long, + override val selfId: SelfId, + override val message: OneBotMessage, + override val rawMessage: String, + override val messageId: MessageId, + override val userId: UserId, + override val sender: Sender, + override val subType: String, + public val groupId: GroupId, + /** Who sent the message without a name, null when it carried a name. */ + public val anonymous: Anonymous?, + override val raw: JsonObject, + ) : Message { + override val postType: String get() = POST_TYPE + override val quickOperations: Set get() = GROUP_OPERATIONS + + /** Whether the message was sent without a name. */ + public val isAnonymous: Boolean get() = anonymous != null + } + + public companion object { + public const val POST_TYPE: String = "message" + + /** The subtypes a private message reports. */ + public const val FRIEND: String = "friend" + public const val GROUP_TEMPORARY: String = "group" + public const val OTHER: String = "other" + + /** The subtypes a group message reports. */ + public const val NORMAL: String = "normal" + public const val ANONYMOUS: String = "anonymous" + public const val NOTICE: String = "notice" + + private val REPLY: Set = setOf("reply", "auto_escape") + + private val GROUP_OPERATIONS: Set = + REPLY + setOf("at_sender", "delete", "kick", "ban", "ban_duration") + } + } + + /** A notification, such as a membership change or a recall. */ + public sealed interface Notice : OneBotEvent { + /** A notification type. */ + public val noticeType: String + + /** A file uploaded to a group. */ + public data class GroupUpload( + override val time: Long, + override val selfId: SelfId, + public val groupId: GroupId, + public val userId: UserId, + public val file: File, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = GROUP_UPLOAD + override val quickOperations: Set get() = emptySet() + } + + /** An administrator set or unset. */ + public data class GroupAdmin( + override val time: Long, + override val selfId: SelfId, + public val groupId: GroupId, + public val userId: UserId, + /** Whether the member became an administrator. */ + public val isSet: Boolean, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = GROUP_ADMIN + override val quickOperations: Set get() = emptySet() + } + + /** A member left a group. */ + public data class GroupDecrease( + override val time: Long, + override val selfId: SelfId, + public val groupId: GroupId, + /** Who left. */ + public val userId: UserId, + /** Who made them leave, the same as [userId] when they left on their own. */ + public val operatorId: UserId, + val subType: String? = null, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = GROUP_DECREASE + override val quickOperations: Set get() = emptySet() + } + + /** A member joined a group. */ + public data class GroupIncrease( + override val time: Long, + override val selfId: SelfId, + public val groupId: GroupId, + public val userId: UserId, + public val operatorId: UserId, + val subType: String? = null, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = GROUP_INCREASE + override val quickOperations: Set get() = emptySet() + } + + /** A member was muted or unmuted. */ + public data class GroupBan( + override val time: Long, + override val selfId: SelfId, + public val groupId: GroupId, + public val userId: UserId, + public val operatorId: UserId, + /** Whether the member was muted. */ + public val isBan: Boolean, + /** How long the mute lasts, in seconds. */ + public val duration: Long?, + val subType: String? = null, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = GROUP_BAN + override val quickOperations: Set get() = emptySet() + } + + /** The bot got a new friend. */ + public data class FriendAdd( + override val time: Long, + override val selfId: SelfId, + public val userId: UserId, + val subType: String? = null, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = FRIEND_ADD + override val quickOperations: Set get() = emptySet() + } + + /** A group message was recalled. */ + public data class GroupRecall( + override val time: Long, + override val selfId: SelfId, + public val groupId: GroupId, + public val userId: UserId, + public val operatorId: UserId, + public val messageId: MessageId, + val subType: String? = null, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = GROUP_RECALL + override val quickOperations: Set get() = emptySet() + } + + /** A private message was recalled. */ + public data class FriendRecall( + override val time: Long, + override val selfId: SelfId, + public val userId: UserId, + public val messageId: MessageId, + val subType: String? = null, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = FRIEND_RECALL + override val quickOperations: Set get() = emptySet() + } + + /** A poke, a lucky king or a member honor, which all report the `notify` notice type. */ + public data class Notify( + override val time: Long, + override val selfId: SelfId, + public val groupId: GroupId?, + public val userId: UserId, + /** The member a poke or a lucky king names, null for a honor. */ + public val targetId: UserId?, + val subType: String? = null, + /** The honor kind, null unless the subtype is `honor`. */ + public val honorType: String?, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = NOTIFY + override val quickOperations: Set get() = emptySet() + } + + /** A notification of a type this version does not model. */ + public data class Other( + override val time: Long, + override val selfId: SelfId, + override val noticeType: String, + override val raw: JsonObject, + val subType: String? = null, + ) : Notice { + override val postType: String get() = POST_TYPE + override val quickOperations: Set get() = emptySet() + } + + /** The file a [GroupUpload] reported. */ + public data class File( + public val id: String, + public val name: String, + /** The size in bytes. */ + public val size: Long?, + public val busid: Long?, + ) + + public companion object { + public const val POST_TYPE: String = "notice" + + public const val GROUP_UPLOAD: String = "group_upload" + public const val GROUP_ADMIN: String = "group_admin" + public const val GROUP_DECREASE: String = "group_decrease" + public const val GROUP_INCREASE: String = "group_increase" + public const val GROUP_BAN: String = "group_ban" + public const val FRIEND_ADD: String = "friend_add" + public const val GROUP_RECALL: String = "group_recall" + public const val FRIEND_RECALL: String = "friend_recall" + public const val NOTIFY: String = "notify" + + /** The subtypes a [Notify] reports. */ + public const val POKE: String = "poke" + public const val LUCKY_KING: String = "lucky_king" + public const val HONOR: String = "honor" + + /** The subtypes a [GroupDecrease] reports. */ + public const val LEAVE: String = "leave" + public const val KICK: String = "kick" + public const val KICK_ME: String = "kick_me" + + /** The subtypes a [GroupIncrease] reports. */ + public const val APPROVE: String = "approve" + public const val INVITE: String = "invite" + + /** The subtypes a [GroupBan] reports. */ + public const val BAN: String = "ban" + public const val LIFT_BAN: String = "lift_ban" + + /** The subtypes a [GroupAdmin] reports. */ + public const val SET: String = "set" + public const val UNSET: String = "unset" + } + } + + /** A request the bot may answer. */ + public sealed interface Request : OneBotEvent { + /** The request type. */ + public val requestType: String + + /** The flag the API answers the request with. */ + public val flag: String + + /** The verification message. */ + public val comment: String + + public val userId: UserId + + /** A friend request. */ + public data class Friend( + override val time: Long, + override val selfId: SelfId, + override val flag: String, + override val comment: String, + override val userId: UserId, + override val raw: JsonObject, + ) : Request { + override val postType: String get() = POST_TYPE + override val requestType: String get() = FRIEND + override val quickOperations: Set get() = OPERATIONS + } + + /** A request to join a group, or an invitation to one. */ + public data class Group( + override val time: Long, + override val selfId: SelfId, + override val flag: String, + override val comment: String, + override val userId: UserId, + public val groupId: GroupId, + val subType: String? = null, + override val raw: JsonObject, + ) : Request { + override val postType: String get() = POST_TYPE + override val requestType: String get() = GROUP + override val quickOperations: Set get() = OPERATIONS + } + + /** A request of a type this version does not model. */ + public data class Other( + override val time: Long, + override val selfId: SelfId, + override val requestType: String, + override val flag: String, + override val comment: String, + override val userId: UserId, + override val raw: JsonObject, + val subType: String? = null, + ) : Request { + override val postType: String get() = POST_TYPE + override val quickOperations: Set get() = emptySet() + } + + public companion object { + public const val POST_TYPE: String = "request" + + public const val FRIEND: String = "friend" + public const val GROUP: String = "group" + + /** The subtypes a group request reports. */ + public const val ADD: String = "add" + public const val INVITE: String = "invite" + + private val OPERATIONS: Set = setOf("approve", "remark", "reason") + } + } + + /** An event about the OneBot implementation itself. */ + public sealed interface Meta : OneBotEvent { + /** The meta event type. */ + public val metaEventType: String + + /** The implementation started, stopped or a WebSocket connected. */ + public data class Lifecycle( + override val time: Long, + override val selfId: SelfId, + /** `enable`, `disable` or `connect`. */ + val subType: String? = null, + override val raw: JsonObject, + ) : Meta { + override val postType: String get() = POST_TYPE + override val metaEventType: String get() = LIFECYCLE + override val quickOperations: Set get() = emptySet() + } + + /** The implementation reports how it is doing. */ + public data class Heartbeat( + override val time: Long, + override val selfId: SelfId, + /** The status object, as `get_status` returns it. */ + public val status: JsonObject, + /** The milliseconds until the next heartbeat. */ + public val interval: Long?, + override val raw: JsonObject, + ) : Meta { + override val postType: String get() = POST_TYPE + override val metaEventType: String get() = HEARTBEAT + override val quickOperations: Set get() = emptySet() + } + + /** A meta event of a type this version does not model. */ + public data class Other( + override val time: Long, + override val selfId: SelfId, + override val metaEventType: String, + override val raw: JsonObject, + val subType: String? = null, + ) : Meta { + override val postType: String get() = POST_TYPE + override val quickOperations: Set get() = emptySet() + } + + public companion object { + public const val POST_TYPE: String = "meta_event" + + public const val LIFECYCLE: String = "lifecycle" + public const val HEARTBEAT: String = "heartbeat" + + /** The subtypes a lifecycle event reports. */ + public const val ENABLE: String = "enable" + public const val DISABLE: String = "disable" + public const val CONNECT: String = "connect" + } + } + + /** An event of a kind this version does not model, with its JSON untouched. */ + public data class Unknown( + override val time: Long, + override val selfId: SelfId, + override val postType: String, + override val raw: JsonObject, + ) : OneBotEvent { + override val quickOperations: Set get() = emptySet() + } + + /** The sender of a message, as far as an implementation could tell. */ + public data class Sender( + public val userId: String?, + public val nickname: String?, + /** `male`, `female` or `unknown`. */ + public val sex: String?, + public val age: Long?, + /** The member's card in the group, null outside a group. */ + public val card: String?, + /** The member's area, null when the implementation did not report one. */ + public val area: String?, + /** The member's level, null when the implementation did not report one. */ + public val level: String?, + /** `owner`, `admin` or `member`, null outside a group. */ + public val role: String?, + /** The member's special title, null when they hold none. */ + public val title: String?, + ) { + /** Whether the sender owns the group. */ + public val isOwner: Boolean get() = role == ROLE_OWNER + + /** Whether the sender administers the group. */ + public val isAdmin: Boolean get() = role == ROLE_ADMIN || isOwner + + public companion object { + public const val ROLE_OWNER: String = "owner" + public const val ROLE_ADMIN: String = "admin" + public const val ROLE_MEMBER: String = "member" + } + } + + /** Who sent a group message without a name. */ + public data class Anonymous( + public val id: String?, + public val name: String?, + /** The flag the ban API takes. */ + public val flag: String?, + ) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodec.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodec.kt new file mode 100644 index 0000000..130545c --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodec.kt @@ -0,0 +1,270 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.event + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.SelfId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegmentCodec + +/** + * Reads an event object. + * + * The kind of an event is read from `post_type` and then its own type field, the same way the standard separates + * them. An event whose kind this version does not model becomes [OneBotEvent.Unknown] with its JSON untouched, and + * so does an event whose kind it models but whose own type it does not. + */ +public object OneBotEventCodec { + /** The event of [element], which throws when it is no object, an event of the standard being one. */ + public fun decode(element: JsonElement): OneBotEvent { + // A scalar or an array is no event: making one of it would tell a caller that a report naming nothing is a + // report worth reading, and would lose what actually arrived. + val raw = element as? JsonObject + ?: throw IllegalArgumentException("An event is a JSON object, was ${element::class.simpleName}.") + val time = raw.number("time") ?: 0L + val selfId = SelfId(raw.numberText("self_id") ?: "0") + return when (val postType = raw.string("post_type")) { + OneBotEvent.Message.POST_TYPE -> decodeMessage(raw, time, selfId) + OneBotEvent.Notice.POST_TYPE -> decodeNotice(raw, time, selfId) + OneBotEvent.Request.POST_TYPE -> decodeRequest(raw, time, selfId) + OneBotEvent.Meta.POST_TYPE -> decodeMeta(raw, time, selfId) + else -> OneBotEvent.Unknown(time, selfId, postType.orEmpty(), raw) + } + } + + private fun decodeMessage(raw: JsonObject, time: Long, selfId: SelfId): OneBotEvent = when ( + val messageType = raw.string("message_type") + ) { + "private" -> OneBotEvent.Message.Private( + time = time, + selfId = selfId, + message = message(raw), + rawMessage = raw.string("raw_message").orEmpty(), + messageId = MessageId(raw.numberText("message_id") ?: "0"), + userId = UserId(raw.numberText("user_id") ?: "0"), + sender = sender(raw), + subType = raw.string("sub_type").orEmpty(), + raw = raw, + ) + + "group" -> OneBotEvent.Message.Group( + time = time, + selfId = selfId, + message = message(raw), + rawMessage = raw.string("raw_message").orEmpty(), + messageId = MessageId(raw.numberText("message_id") ?: "0"), + userId = UserId(raw.numberText("user_id") ?: "0"), + sender = sender(raw), + subType = raw.string("sub_type").orEmpty(), + groupId = GroupId(raw.numberText("group_id") ?: "0"), + anonymous = anonymous(raw), + raw = raw, + ) + + else -> OneBotEvent.Unknown(time, selfId, OneBotEvent.Message.POST_TYPE, raw) + } + + private fun decodeNotice(raw: JsonObject, time: Long, selfId: SelfId): OneBotEvent { + val noticeType = raw.string("notice_type").orEmpty() + val subType = raw.string("sub_type") + val groupId = { raw.numberText("group_id")?.let(::GroupId) } + val userId = { UserId(raw.numberText("user_id") ?: "0") } + val operatorId = { UserId(raw.numberText("operator_id") ?: "0") } + return when (noticeType) { + OneBotEvent.Notice.GROUP_UPLOAD -> OneBotEvent.Notice.GroupUpload( + time, + selfId, + groupId() ?: GroupId("0"), + userId(), + file(raw["file"] as? JsonObject ?: JsonObject(emptyMap())), + raw, + ) + + OneBotEvent.Notice.GROUP_ADMIN -> OneBotEvent.Notice.GroupAdmin( + time, + selfId, + groupId() ?: GroupId("0"), + userId(), + subType == OneBotEvent.Notice.SET, + raw, + ) + + OneBotEvent.Notice.GROUP_DECREASE -> OneBotEvent.Notice.GroupDecrease( + time, + selfId, + groupId() ?: GroupId("0"), + userId(), + operatorId(), + subType.orEmpty(), + raw, + ) + + OneBotEvent.Notice.GROUP_INCREASE -> OneBotEvent.Notice.GroupIncrease( + time, + selfId, + groupId() ?: GroupId("0"), + userId(), + operatorId(), + subType.orEmpty(), + raw, + ) + + OneBotEvent.Notice.GROUP_BAN -> OneBotEvent.Notice.GroupBan( + time = time, + selfId = selfId, + groupId = groupId() ?: GroupId("0"), + userId = userId(), + operatorId = operatorId(), + isBan = subType == OneBotEvent.Notice.BAN, + duration = raw.number("duration"), + subType = subType, + raw = raw, + ) + + OneBotEvent.Notice.FRIEND_ADD -> + OneBotEvent.Notice.FriendAdd(time, selfId, userId(), raw = raw) + + OneBotEvent.Notice.GROUP_RECALL -> OneBotEvent.Notice.GroupRecall( + time = time, + selfId = selfId, + groupId = groupId() ?: GroupId("0"), + userId = userId(), + operatorId = operatorId(), + messageId = MessageId(raw.numberText("message_id") ?: "0"), + raw = raw, + ) + + OneBotEvent.Notice.FRIEND_RECALL -> OneBotEvent.Notice.FriendRecall( + time = time, + selfId = selfId, + userId = userId(), + messageId = MessageId(raw.numberText("message_id") ?: "0"), + raw = raw, + ) + + OneBotEvent.Notice.NOTIFY -> OneBotEvent.Notice.Notify( + time = time, + selfId = selfId, + groupId = groupId(), + userId = userId(), + targetId = raw.numberText("target_id")?.let(::UserId), + subType = subType.orEmpty(), + honorType = raw.string("honor_type"), + raw = raw, + ) + + else -> OneBotEvent.Notice.Other( + time = time, + selfId = selfId, + noticeType = noticeType, + raw = raw, + subType = subType, + ) + } + } + + private fun decodeRequest(raw: JsonObject, time: Long, selfId: SelfId): OneBotEvent { + val requestType = raw.string("request_type").orEmpty() + val flag = raw.string("flag").orEmpty() + val comment = raw.string("comment").orEmpty() + val userId = UserId(raw.numberText("user_id") ?: "0") + return when (requestType) { + OneBotEvent.Request.FRIEND -> + OneBotEvent.Request.Friend(time, selfId, flag, comment, userId, raw) + + OneBotEvent.Request.GROUP -> OneBotEvent.Request.Group( + time, + selfId, + flag, + comment, + userId, + GroupId(raw.numberText("group_id") ?: "0"), + raw.string("sub_type").orEmpty(), + raw, + ) + + else -> OneBotEvent.Request.Other( + time = time, + selfId = selfId, + requestType = requestType, + flag = flag, + comment = comment, + userId = userId, + raw = raw, + subType = raw.string("sub_type"), + ) + } + } + + private fun decodeMeta(raw: JsonObject, time: Long, selfId: SelfId): OneBotEvent { + val metaEventType = raw.string("meta_event_type").orEmpty() + val subType = raw.string("sub_type") + return when (metaEventType) { + OneBotEvent.Meta.LIFECYCLE -> + OneBotEvent.Meta.Lifecycle(time, selfId, subType.orEmpty(), raw) + + OneBotEvent.Meta.HEARTBEAT -> OneBotEvent.Meta.Heartbeat( + time, + selfId, + raw["status"] as? JsonObject ?: JsonObject(emptyMap()), + raw.number("interval"), + raw, + ) + + else -> OneBotEvent.Meta.Other( + time = time, + selfId = selfId, + metaEventType = metaEventType, + raw = raw, + subType = subType, + ) + } + } + + private fun message(raw: JsonObject): OneBotMessage = OneBotSegmentCodec.decodeMessage(raw["message"] ?: JsonNull) + + private fun sender(raw: JsonObject): OneBotEvent.Sender { + val sender = raw["sender"] as? JsonObject ?: JsonObject(emptyMap()) + return OneBotEvent.Sender( + userId = sender.numberText("user_id"), + nickname = sender.string("nickname"), + sex = sender.string("sex"), + age = sender.number("age"), + card = sender.string("card"), + area = sender.string("area"), + level = sender.string("level"), + role = sender.string("role"), + title = sender.string("title"), + ) + } + + private fun anonymous(raw: JsonObject): OneBotEvent.Anonymous? { + val anonymous = raw["anonymous"] as? JsonObject ?: return null + return OneBotEvent.Anonymous( + id = anonymous.numberText("id"), + name = anonymous.string("name"), + flag = anonymous.string("flag"), + ) + } + + private fun file(raw: JsonObject): OneBotEvent.Notice.File = OneBotEvent.Notice.File( + id = raw.string("id").orEmpty(), + name = raw.string("name").orEmpty(), + size = raw.number("size"), + busid = raw.number("busid"), + ) + + private fun JsonObject.string(key: String): String? = + (this[key] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull + + private fun JsonObject.number(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull + + private fun JsonObject.numberText(key: String): String? = + (this[key] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull?.takeIf { it.isNotEmpty() } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCode.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCode.kt new file mode 100644 index 0000000..d41da04 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCode.kt @@ -0,0 +1,356 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.message + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId + +/** + * The CQ code string format of a message, `[CQ:type,key=value]` with plain text between the codes. + * + * The characters that end a code are escaped, so a message always round-trips: `&` as `&`, `[` as `[`, + * `]` as `]`, `,` as `,`. Reading accepts these entities and leaves every other character as it is. + */ +public object CqCode { + private const val PREFIX = "[CQ:" + private const val SUFFIX = ']' + + /** The entity a parameter uses for a comma, which is the one entity plain text leaves as it is. */ + private const val COMMA_ENTITY = "," + private const val SEPARATOR = ',' + private const val ASSIGNMENT = '=' + + /** + * The segments of [text], with the text between the codes as [OneBotSegment.Text]. + * + * The entities of the format stand for the characters that would otherwise end a code, so reading turns them + * back into those characters everywhere, inside a code and in the text between the codes alike. + */ + public fun decode(text: String): List { + if (PREFIX !in text) return if (text.isEmpty()) emptyList() else listOf(OneBotSegment.Text(unescape(text))) + val segments = mutableListOf() + val plain = StringBuilder() + var at = 0 + while (at < text.length) { + val start = text.indexOf(PREFIX, at) + if (start < 0) { + plain.append(text, at, text.length) + break + } + plain.append(text, at, start) + val end = text.indexOf(SUFFIX, start + PREFIX.length) + if (end < 0) { + plain.append(text, start, text.length) + break + } + val segment = parse(text.substring(start + PREFIX.length, end)) + if (segment == null) { + plain.append(text, start, end + 1) + } else { + if (plain.isNotEmpty()) { + segments += OneBotSegment.Text(unescape(plain.toString())) + plain.setLength(0) + } + segments += segment + } + at = end + 1 + } + if (plain.isNotEmpty()) segments += OneBotSegment.Text(unescape(plain.toString())) + return segments + } + + /** The string that spells [segments] as CQ codes. */ + public fun encode(segments: List): String = buildString { + for (segment in segments) append(encode(segment)) + } + + /** The string that spells [segment] as a CQ code, or as plain text for a [OneBotSegment.Text]. */ + public fun encode(segment: OneBotSegment): String = when (segment) { + is OneBotSegment.Text -> escape(segment.text) + + is OneBotSegment.Unknown -> encodeUnknown(segment) + + is OneBotSegment.Rps -> "[CQ:rps]" + + is OneBotSegment.Dice -> "[CQ:dice]" + + is OneBotSegment.Shake -> "[CQ:shake]" + + is OneBotSegment.At -> parameters("at", "qq" to segment.userId) + + is OneBotSegment.Face -> parameters("face", "id" to segment.id) + + is OneBotSegment.Image -> parameters( + "image", + "file" to segment.file, + "type" to segment.subtype, + "url" to segment.url, + "cache" to segment.cache?.let(::flag), + "proxy" to segment.proxy?.let(::flag), + "timeout" to segment.timeout?.toString(), + ) + + is OneBotSegment.Record -> parameters( + "record", + "file" to segment.file, + "magic" to segment.magic?.let(::flag), + "url" to segment.url, + "cache" to segment.cache?.let(::flag), + "proxy" to segment.proxy?.let(::flag), + "timeout" to segment.timeout?.toString(), + ) + + is OneBotSegment.Video -> parameters( + "video", + "file" to segment.file, + "url" to segment.url, + "cache" to segment.cache?.let(::flag), + "proxy" to segment.proxy?.let(::flag), + "timeout" to segment.timeout?.toString(), + ) + + is OneBotSegment.Poke -> parameters( + "poke", + "type" to segment.pokeType, + "id" to segment.id, + "name" to segment.name, + ) + + is OneBotSegment.Anonymous -> parameters("anonymous", "ignore" to segment.ignore?.let(::flag)) + + is OneBotSegment.Share -> parameters( + "share", + "url" to segment.url, + "title" to segment.title, + "content" to segment.content, + "image" to segment.image, + ) + + is OneBotSegment.Contact -> + parameters("contact", "type" to segment.contactType, "id" to segment.id) + + is OneBotSegment.Location -> parameters( + "location", + "lat" to segment.lat, + "lon" to segment.lon, + "title" to segment.title, + "content" to segment.content, + ) + + is OneBotSegment.Music -> parameters( + "music", + "type" to segment.musicType, + "id" to segment.id, + "url" to segment.url, + "audio" to segment.audio, + "title" to segment.title, + "content" to segment.content, + "image" to segment.image, + ) + + is OneBotSegment.Reply -> parameters("reply", "id" to segment.id.value) + + is OneBotSegment.Forward -> parameters("forward", "id" to segment.id) + + is OneBotSegment.Node -> parameters( + "node", + "id" to segment.id?.value, + "user_id" to segment.userId?.value, + "nickname" to segment.nickname, + "content" to segment.content?.let { encode(it.segments) }, + ) + + is OneBotSegment.Xml -> parameters("xml", "data" to segment.data) + + is OneBotSegment.Json -> parameters("json", "data" to segment.data) + } + + /** + * The value of [text] with the characters that end a code escaped. + * + * A comma separates the parameters of a code and means nothing in the text outside one, so it is escaped only by + * [escapeValue]: escaping it in ordinary text would turn every comma a person types into an entity the standard + * does not ask for. + */ + public fun escape(text: String): String = escape(text, comma = false) + + /** The value of a parameter, where a comma also ends it and is therefore escaped. */ + public fun escapeValue(value: String): String = escape(value, comma = true) + + private fun escape(text: String, comma: Boolean): String = buildString(text.length) { + for (char in text) { + when { + char == '&' -> append("&") + char == '[' -> append("[") + char == ']' -> append("]") + char == ',' && comma -> append(",") + else -> append(char) + } + } + } + + /** The value of [text] with the entities the format uses turned back into their characters. */ + public fun unescape(text: String): String = unescape(text, comma = false) + + /** The value of a parameter, where `,` names the comma that would otherwise end it. */ + public fun unescapeValue(value: String): String = unescape(value, comma = true) + + private fun unescape(text: String, comma: Boolean): String = buildString(text.length) { + var at = 0 + while (at < text.length) { + val char = text[at] + if (char != '&') { + append(char) + at++ + continue + } + val entity = ENTITIES.firstOrNull { text.startsWith(it.first, at) && (comma || it.first != COMMA_ENTITY) } + if (entity == null) { + append(char) + at++ + } else { + append(entity.second) + at += entity.first.length + } + } + } + + private val ENTITIES: List> = + listOf("&" to '&', "[" to '[', "]" to ']', "," to ',') + .sortedByDescending { it.first.length } + + private fun flag(value: Boolean): String = if (value) "1" else "0" + + private fun parameters(type: String, vararg pairs: Pair): String = buildString { + append(PREFIX).append(type) + for ((key, value) in pairs) { + if (value == null) continue + append(SEPARATOR).append(key).append(ASSIGNMENT).append(escapeValue(value)) + } + append(SUFFIX) + } + + private fun encodeUnknown(segment: OneBotSegment.Unknown): String { + val pairs = segment.data.mapNotNull { (key, value) -> + (value as? JsonPrimitive)?.contentOrNull?.let { key to it } + } + return parameters(segment.type, *pairs.toTypedArray()) + } + + /** The segment of the text between `[CQ:` and `]`, null when it names no segment type. */ + private fun parse(body: String): OneBotSegment? { + if (body.isEmpty()) return null + val parts = body.split(SEPARATOR) + val type = parts.first().trim() + if (type.isEmpty()) return null + val parameters = LinkedHashMap() + for (part in parts.drop(1)) { + val assignment = part.indexOf(ASSIGNMENT) + if (assignment <= 0) continue + parameters[part.substring(0, assignment).trim()] = unescapeValue(part.substring(assignment + 1)) + } + return segment(type, parameters) + } + + private fun segment(type: String, parameters: Map): OneBotSegment { + val yes = { key: String -> parameters[key]?.let { it == "1" || it == "yes" || it == "true" } } + val int = { key: String -> parameters[key]?.toIntOrNull() } + return when (type) { + OneBotSegment.TEXT -> OneBotSegment.Text(parameters["text"].orEmpty()) + + OneBotSegment.FACE -> OneBotSegment.Face(parameters["id"].orEmpty()) + + OneBotSegment.IMAGE -> OneBotSegment.Image( + file = parameters["file"], + subtype = parameters["type"], + url = parameters["url"], + cache = yes("cache"), + proxy = yes("proxy"), + timeout = int("timeout"), + ) + + OneBotSegment.RECORD -> OneBotSegment.Record( + file = parameters["file"], + magic = yes("magic"), + url = parameters["url"], + cache = yes("cache"), + proxy = yes("proxy"), + timeout = int("timeout"), + ) + + OneBotSegment.VIDEO -> OneBotSegment.Video( + file = parameters["file"], + url = parameters["url"], + cache = yes("cache"), + proxy = yes("proxy"), + timeout = int("timeout"), + ) + + OneBotSegment.AT -> OneBotSegment.At(parameters["qq"].orEmpty()) + + OneBotSegment.RPS -> OneBotSegment.Rps + + OneBotSegment.DICE -> OneBotSegment.Dice + + OneBotSegment.SHAKE -> OneBotSegment.Shake + + OneBotSegment.POKE -> + OneBotSegment.Poke(parameters["type"], parameters["id"], parameters["name"]) + + OneBotSegment.ANONYMOUS -> OneBotSegment.Anonymous(yes("ignore")) + + OneBotSegment.SHARE -> OneBotSegment.Share( + url = parameters["url"].orEmpty(), + title = parameters["title"].orEmpty(), + content = parameters["content"], + image = parameters["image"], + ) + + OneBotSegment.CONTACT -> + OneBotSegment.Contact(parameters["type"].orEmpty(), parameters["id"].orEmpty()) + + OneBotSegment.LOCATION -> OneBotSegment.Location( + lat = parameters["lat"].orEmpty(), + lon = parameters["lon"].orEmpty(), + title = parameters["title"], + content = parameters["content"], + ) + + OneBotSegment.MUSIC -> OneBotSegment.Music( + musicType = parameters["type"].orEmpty(), + id = parameters["id"], + url = parameters["url"], + audio = parameters["audio"], + title = parameters["title"], + content = parameters["content"], + image = parameters["image"], + ) + + OneBotSegment.REPLY -> { + val id = parameters["id"] + OneBotSegment.Reply(MessageId(if (id != null && isNumber(id)) id else "0")) + } + + OneBotSegment.FORWARD -> OneBotSegment.Forward(parameters["id"].orEmpty()) + + OneBotSegment.NODE -> OneBotSegment.Node( + id = parameters["id"]?.takeIf(::isNumber)?.let { MessageId(it) }, + userId = parameters["user_id"]?.takeIf(::isNumber)?.let { UserId(it) }, + nickname = parameters["nickname"], + content = parameters["content"]?.let { OneBotMessage.StringValue(it) }, + ) + + OneBotSegment.XML -> OneBotSegment.Xml(parameters["data"].orEmpty()) + + OneBotSegment.JSON -> OneBotSegment.Json(parameters["data"].orEmpty()) + + else -> OneBotSegment.Unknown(type, unknownData(parameters)) + } + } + + private fun unknownData(parameters: Map): JsonObject = + JsonObject(parameters.mapValues { (_, value) -> JsonPrimitive(value) }) + + private fun isNumber(text: String): Boolean = text.isNotEmpty() && text.all(Char::isDigit) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotMessage.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotMessage.kt new file mode 100644 index 0000000..8ff3abc --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotMessage.kt @@ -0,0 +1,49 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.message + +/** + * A message as the API takes it and as events report it. + * + * OneBot accepts the same message as a string, as an array of segments, or as a single segment object, so all three + * shapes decode into this type and the codec writes the one a request needs. + */ +public sealed interface OneBotMessage { + /** The segments of the message, one text segment for [StringValue]. */ + public val segments: List + + /** A message written as a string, in the message's own [format] such as a CQ code string. */ + public data class StringValue( + public val text: String, + /** How [text] spells its segments, by default the string format with CQ codes. */ + public val format: StringFormat = StringFormat.STRING, + ) : OneBotMessage { + override val segments: List get() = format.parse(text) + } + + /** A message written as an array of segments. */ + public data class ArrayValue(override val segments: List) : OneBotMessage + + /** A message written as one segment object, the shape the API also accepts. */ + public data class SingleSegment(public val segment: OneBotSegment) : OneBotMessage { + override val segments: List get() = listOf(segment) + } + + public companion object { + /** A message of one plain text segment. */ + public fun text(text: String): OneBotMessage = ArrayValue(listOf(OneBotSegment.Text(text))) + + /** A message of [segments]. */ + public fun of(vararg segments: OneBotSegment): OneBotMessage = ArrayValue(segments.toList()) + } +} + +/** How a string message spells its segments. */ +public enum class StringFormat { + /** The string format, where segments are written as CQ codes. */ + STRING { + override fun parse(text: String): List = CqCode.decode(text) + }, + ; + + /** The segments of [text]. */ + public abstract fun parse(text: String): List +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegment.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegment.kt new file mode 100644 index 0000000..add499c --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegment.kt @@ -0,0 +1,242 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.message + +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId + +/** + * One segment of an OneBot message. + * + * A segment names what it is in `type` and carries its own fields in `data`. A type this version does not know is + * kept as [Unknown] with its `data` untouched, so a message never loses content while it crosses the plugin. + * + * [OneBotSegmentCodec] encodes and decodes the shapes the standard describes, so a `type` field that is also a + * parameter of a segment, as in an image, never collides with the discriminator. + */ +public sealed interface OneBotSegment { + /** The `type` of the segment. */ + public val type: String + + /** Plain text. */ + public data class Text(public val text: String) : OneBotSegment { + override val type: String get() = OneBotSegment.TEXT + } + + /** A QQ emoji. */ + public data class Face(public val id: String) : OneBotSegment { + override val type: String get() = OneBotSegment.FACE + } + + /** An image. */ + public data class Image( + public val file: String? = null, + /** `flash` for a self-destructing image, null for a plain one. */ + public val subtype: String? = null, + public val url: String? = null, + public val cache: Boolean? = null, + public val proxy: Boolean? = null, + public val timeout: Int? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.IMAGE + } + + /** A voice message. */ + public data class Record( + public val file: String? = null, + /** Whether the record is sent with a changed voice. */ + public val magic: Boolean? = null, + public val url: String? = null, + public val cache: Boolean? = null, + public val proxy: Boolean? = null, + public val timeout: Int? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.RECORD + } + + /** A short video. */ + public data class Video( + public val file: String? = null, + public val url: String? = null, + public val cache: Boolean? = null, + public val proxy: Boolean? = null, + public val timeout: Int? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.VIDEO + } + + /** A mention of one member, or of everyone when [userId] is [At.ALL]. */ + public data class At(public val userId: String) : OneBotSegment { + override val type: String get() = OneBotSegment.AT + + /** Whether the mention names everyone in the group. */ + public val isAll: Boolean get() = userId == ALL + + public companion object { + /** The `qq` value that mentions everyone. */ + public const val ALL: String = "all" + } + } + + /** The rock-paper-scissors magic emoji. */ + public data object Rps : OneBotSegment { + override val type: String get() = OneBotSegment.RPS + } + + /** The dice magic emoji. */ + public data object Dice : OneBotSegment { + override val type: String get() = OneBotSegment.DICE + } + + /** The window shake, the simplest kind of a poke. */ + public data object Shake : OneBotSegment { + override val type: String get() = OneBotSegment.SHAKE + } + + /** A poke. */ + public data class Poke( + /** The poke kind. */ + public val pokeType: String? = null, + public val id: String? = null, + /** The poke's name, which an implementation reports but does not take. */ + public val name: String? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.POKE + } + + /** A message sent without a name. */ + public data class Anonymous( + /** Whether the message is sent anyway when the platform refuses anonymity. */ + public val ignore: Boolean? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.ANONYMOUS + } + + /** A shared link. */ + public data class Share( + public val url: String, + public val title: String, + public val content: String? = null, + public val image: String? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.SHARE + } + + /** A recommended friend or group. */ + public data class Contact( + /** `qq` for a friend, `group` for a group. */ + public val contactType: String, + public val id: String, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.CONTACT + } + + /** A location. */ + public data class Location( + public val lat: String, + public val lon: String, + public val title: String? = null, + public val content: String? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.LOCATION + } + + /** A shared piece of music, either from a provider named by [musicType] or described by [url] and [audio]. */ + public data class Music( + /** `qq`, `163`, `xm` for a provider, or `custom` for a share the sender described itself. */ + public val musicType: String, + public val id: String? = null, + public val url: String? = null, + public val audio: String? = null, + public val title: String? = null, + public val content: String? = null, + public val image: String? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.MUSIC + } + + /** A reply to an earlier message. */ + public data class Reply(public val id: MessageId) : OneBotSegment { + override val type: String get() = OneBotSegment.REPLY + } + + /** A forwarded message, whose content only `get_forward_msg` returns. */ + public data class Forward(public val id: String) : OneBotSegment { + override val type: String get() = OneBotSegment.FORWARD + } + + /** A node of a forwarded message, either an existing message named by [id] or one the sender described. */ + public data class Node( + public val id: MessageId? = null, + public val userId: UserId? = null, + public val nickname: String? = null, + /** A message in any of the three shapes the API accepts, null when the node names a message by [id]. */ + public val content: OneBotMessage? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.NODE + } + + /** An XML message. */ + public data class Xml(public val data: String) : OneBotSegment { + override val type: String get() = OneBotSegment.XML + } + + /** A JSON message. */ + public data class Json(public val data: String) : OneBotSegment { + override val type: String get() = OneBotSegment.JSON + } + + /** A segment of a type this version does not know, with the value of its `data` object untouched. */ + public data class Unknown( + override val type: String, + /** The `data` object as the implementation sent it, empty when the segment carried none. */ + public val data: JsonObject = JsonObject(emptyMap()), + ) : OneBotSegment + + /** The names of the segment types OneBot v11 defines. */ + public companion object { + public const val TEXT: String = "text" + public const val FACE: String = "face" + public const val IMAGE: String = "image" + public const val RECORD: String = "record" + public const val VIDEO: String = "video" + public const val AT: String = "at" + public const val RPS: String = "rps" + public const val DICE: String = "dice" + public const val SHAKE: String = "shake" + public const val POKE: String = "poke" + public const val ANONYMOUS: String = "anonymous" + public const val SHARE: String = "share" + public const val CONTACT: String = "contact" + public const val LOCATION: String = "location" + public const val MUSIC: String = "music" + public const val REPLY: String = "reply" + public const val FORWARD: String = "forward" + public const val NODE: String = "node" + public const val XML: String = "xml" + public const val JSON: String = "json" + + /** The names of every segment type, in the order the standard lists them. */ + public val KNOWN_TYPES: List = listOf( + TEXT, + FACE, + IMAGE, + RECORD, + VIDEO, + AT, + RPS, + DICE, + SHAKE, + POKE, + ANONYMOUS, + SHARE, + CONTACT, + LOCATION, + MUSIC, + REPLY, + FORWARD, + NODE, + XML, + JSON, + ) + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegmentCodec.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegmentCodec.kt new file mode 100644 index 0000000..0ffdd8d --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegmentCodec.kt @@ -0,0 +1,280 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.message + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId + +/** + * Reads and writes the three shapes of a message and the `type` plus `data` shape of a segment. + * + * Reading keeps every field of a segment the standard does not name in [OneBotSegment.Unknown], and reading a + * segment whose parameter the standard names but this version does not keep still returns the named type. + */ +public object OneBotSegmentCodec { + /** The message of [element], which may be a string, an array of segments or a single segment object. */ + public fun decodeMessage(element: JsonElement): OneBotMessage = when (element) { + is JsonArray -> OneBotMessage.ArrayValue(element.map(::decodeSegment)) + + is JsonObject -> + if (element.containsKey("type")) { + OneBotMessage.SingleSegment(decodeSegment(element)) + } else { + OneBotMessage.ArrayValue(emptyList()) + } + + is JsonPrimitive -> OneBotMessage.StringValue(element.content) + } + + /** The segments of [element], which may be an array of segments or a single segment object. */ + public fun decodeMessageSegments(element: JsonElement): List = decodeMessage(element).segments + + /** The segment of [element], an object with a `type` and a `data` object. */ + public fun decodeSegment(element: JsonElement): OneBotSegment { + val segment = element as? JsonObject + ?: return OneBotSegment.Unknown(OneBotSegment.TEXT, JsonObject(emptyMap())) + val type = segment["type"]?.jsonPrimitive?.contentOrNull.orEmpty() + val data = segment["data"] as? JsonObject ?: JsonObject(emptyMap()) + return segment(type, data) + } + + /** The JSON of [message] in the array shape, or as the string it already is. */ + public fun encodeMessage(message: OneBotMessage): JsonElement = when (message) { + is OneBotMessage.StringValue -> JsonPrimitive(message.text) + is OneBotMessage.ArrayValue -> buildJsonArray { message.segments.forEach { add(encodeSegment(it)) } } + is OneBotMessage.SingleSegment -> encodeSegment(message.segment) + } + + /** The JSON of [message] in the array shape. */ + public fun encodeMessageArray(message: OneBotMessage): JsonArray = + buildJsonArray { message.segments.forEach { add(encodeSegment(it)) } } + + /** The JSON of [segment], an object with a `type` and a `data` object. */ + public fun encodeSegment(segment: OneBotSegment): JsonObject = buildJsonObject { + put("type", segment.type) + when (segment) { + is OneBotSegment.Text -> putJsonObject("data") { put("text", segment.text) } + + is OneBotSegment.Face -> putJsonObject("data") { put("id", segment.id) } + + is OneBotSegment.Image -> putJsonObject("data") { + put("file", segment.file) + optional("type", segment.subtype) + optional("url", segment.url) + optional("cache", segment.cache?.let(::flag)) + optional("proxy", segment.proxy?.let(::flag)) + optional("timeout", segment.timeout) + } + + is OneBotSegment.Record -> putJsonObject("data") { + put("file", segment.file) + optional("magic", segment.magic?.let(::flag)) + optional("url", segment.url) + optional("cache", segment.cache?.let(::flag)) + optional("proxy", segment.proxy?.let(::flag)) + optional("timeout", segment.timeout) + } + + is OneBotSegment.Video -> putJsonObject("data") { + put("file", segment.file) + optional("url", segment.url) + optional("cache", segment.cache?.let(::flag)) + optional("proxy", segment.proxy?.let(::flag)) + optional("timeout", segment.timeout) + } + + is OneBotSegment.At -> putJsonObject("data") { put("qq", user(segment.userId)) } + + is OneBotSegment.Rps, is OneBotSegment.Dice, is OneBotSegment.Shake -> putJsonObject("data") {} + + is OneBotSegment.Poke -> putJsonObject("data") { + put("type", segment.pokeType) + put("id", segment.id) + optional("name", segment.name) + } + + is OneBotSegment.Anonymous -> putJsonObject("data") { optional("ignore", segment.ignore?.let(::flag)) } + + is OneBotSegment.Share -> putJsonObject("data") { + optional("url", segment.url) + optional("title", segment.title) + optional("content", segment.content) + optional("image", segment.image) + } + + is OneBotSegment.Contact -> putJsonObject("data") { + put("type", segment.contactType) + put("id", segment.id) + } + + is OneBotSegment.Location -> putJsonObject("data") { + put("lat", segment.lat) + put("lon", segment.lon) + optional("title", segment.title) + optional("content", segment.content) + } + + is OneBotSegment.Music -> putJsonObject("data") { + put("type", segment.musicType) + put("id", segment.id) + optional("url", segment.url) + optional("audio", segment.audio) + optional("title", segment.title) + optional("content", segment.content) + optional("image", segment.image) + } + + is OneBotSegment.Reply -> putJsonObject("data") { put("id", number(segment.id.value)) } + + is OneBotSegment.Forward -> putJsonObject("data") { put("id", segment.id) } + + is OneBotSegment.Node -> putJsonObject("data") { + segment.id?.let { put("id", number(it.value)) } + segment.userId?.let { put("user_id", user(it.value)) } + put("nickname", segment.nickname) + segment.content?.let { content -> put("content", encodeMessage(content)) } + } + + is OneBotSegment.Xml -> putJsonObject("data") { put("data", segment.data) } + + is OneBotSegment.Json -> putJsonObject("data") { put("data", segment.data) } + + is OneBotSegment.Unknown -> put("data", segment.data) + } + } + + private fun flag(value: Boolean): String = if (value) "1" else "0" + + /** [value] written under [key], and left out when it is null, as an optional parameter that is absent. */ + private fun JsonObjectBuilder.optional(key: String, value: String?) { + if (value != null) put(key, value) + } + + /** [value] written under [key], and left out when it is null. */ + private fun JsonObjectBuilder.optional(key: String, value: Int?) { + if (value != null) put(key, value) + } + + /** [value] as a JSON number when it is one, as a string when it is too large for a [Long]. */ + internal fun number(id: String): JsonElement = id.toLongOrNull()?.let(::JsonPrimitive) ?: JsonPrimitive(id) + + /** [qq] as a JSON number, or the string `all`. */ + private fun user(qq: String): JsonElement = qq.toLongOrNull()?.let(::JsonPrimitive) ?: JsonPrimitive(qq) + + private fun segment(type: String, data: JsonObject): OneBotSegment = when (type) { + OneBotSegment.TEXT -> OneBotSegment.Text(data.string("text").orEmpty()) + + OneBotSegment.FACE -> OneBotSegment.Face(data.string("id").orEmpty()) + + OneBotSegment.IMAGE -> OneBotSegment.Image( + file = data.string("file"), + subtype = data.string("type"), + url = data.string("url"), + cache = data.flag("cache"), + proxy = data.flag("proxy"), + timeout = data.number("timeout"), + ) + + OneBotSegment.RECORD -> OneBotSegment.Record( + file = data.string("file"), + magic = data.flag("magic"), + url = data.string("url"), + cache = data.flag("cache"), + proxy = data.flag("proxy"), + timeout = data.number("timeout"), + ) + + OneBotSegment.VIDEO -> OneBotSegment.Video( + file = data.string("file"), + url = data.string("url"), + cache = data.flag("cache"), + proxy = data.flag("proxy"), + timeout = data.number("timeout"), + ) + + OneBotSegment.AT -> OneBotSegment.At(data.string("qq").orEmpty()) + + OneBotSegment.RPS -> OneBotSegment.Rps + + OneBotSegment.DICE -> OneBotSegment.Dice + + OneBotSegment.SHAKE -> OneBotSegment.Shake + + OneBotSegment.POKE -> OneBotSegment.Poke(data.string("type"), data.string("id"), data.string("name")) + + OneBotSegment.ANONYMOUS -> OneBotSegment.Anonymous(data.flag("ignore")) + + OneBotSegment.SHARE -> OneBotSegment.Share( + url = data.string("url").orEmpty(), + title = data.string("title").orEmpty(), + content = data.string("content"), + image = data.string("image"), + ) + + OneBotSegment.CONTACT -> OneBotSegment.Contact(data.string("type").orEmpty(), data.string("id").orEmpty()) + + OneBotSegment.LOCATION -> OneBotSegment.Location( + lat = data.string("lat").orEmpty(), + lon = data.string("lon").orEmpty(), + title = data.string("title"), + content = data.string("content"), + ) + + OneBotSegment.MUSIC -> OneBotSegment.Music( + musicType = data.string("type").orEmpty(), + id = data.string("id"), + url = data.string("url"), + audio = data.string("audio"), + title = data.string("title"), + content = data.string("content"), + image = data.string("image"), + ) + + OneBotSegment.REPLY -> OneBotSegment.Reply(MessageId(data.numberText("id") ?: "0")) + + OneBotSegment.FORWARD -> OneBotSegment.Forward(data.string("id").orEmpty()) + + OneBotSegment.NODE -> OneBotSegment.Node( + id = data.numberText("id")?.let(::MessageId), + userId = data.numberText("user_id")?.let(::UserId), + nickname = data.string("nickname"), + content = data["content"]?.let(::decodeMessage), + ) + + OneBotSegment.XML -> OneBotSegment.Xml(data.string("data").orEmpty()) + + OneBotSegment.JSON -> OneBotSegment.Json(data.string("data").orEmpty()) + + else -> OneBotSegment.Unknown(type, data) + } + + private fun JsonObject.string(key: String): String? = + (this[key] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull + + private fun JsonObject.number(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull + + private fun JsonObject.numberText(key: String): String? = + (this[key] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull?.takeIf { it.isNotEmpty() } + + private fun JsonObject.flag(key: String): Boolean? = (this[key] as? JsonPrimitive)?.let { value -> + when (value.contentOrNull) { + "1", "yes", "true" -> true + "0", "no", "false" -> false + else -> value.booleanOrNull + } + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/result/OneBotResult.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/result/OneBotResult.kt new file mode 100644 index 0000000..92ce005 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/result/OneBotResult.kt @@ -0,0 +1,139 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.result + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import org.foedusprogramme.alexandrite.channel.onebot.protocol.OneBotRetcode +import org.foedusprogramme.alexandrite.channel.onebot.protocol.OneBotStatus + +/** + * What came of one API call. + * + * The kinds stay apart on purpose: a transport that never reached the implementation, an implementation that + * refused the call, and a call that succeeded are different facts, and only [data] needs the answer of the call. + * Every kind keeps the response [raw], so a field an implementation added is read from there. + */ +public sealed interface OneBotResult { + /** The `status` the response reported, or the one the transport made of its failure. */ + public val status: OneBotStatus + + /** The `retcode` the response reported, or one this module chose for a failure it saw itself. */ + public val retcode: Int + + /** The `echo` the response carried back, null when it carried none. */ + public val echo: JsonElement? + + /** The whole response object, empty when no response came. */ + public val raw: JsonObject + + /** The data of a successful call. */ + public data class Ok( + public val data: T, + override val retcode: Int = OneBotRetcode.OK, + override val echo: JsonElement? = null, + override val raw: JsonObject = JsonObject(emptyMap()), + ) : OneBotResult { + override val status: OneBotStatus get() = OneBotStatus.OK + } + + /** A call the implementation accepted for later, whose outcome the client cannot learn. */ + public data class Async( + override val retcode: Int = OneBotRetcode.ASYNC, + override val echo: JsonElement? = null, + override val raw: JsonObject = JsonObject(emptyMap()), + ) : OneBotResult { + override val status: OneBotStatus get() = OneBotStatus.ASYNC + } + + /** A call the implementation refused, carrying its `retcode`. */ + public data class Failed( + override val retcode: Int, + /** The `message` an implementation may add, null when it added none. */ + public val message: String? = null, + override val echo: JsonElement? = null, + override val raw: JsonObject = JsonObject(emptyMap()), + ) : OneBotResult { + override val status: OneBotStatus get() = OneBotStatus.FAILED + } + + /** A call that never reached the implementation, or whose answer was lost on the way. */ + public data class Unreachable( + public val failure: OneBotFailure, + /** The answer of the implementation, null when the request never got one. */ + override val raw: JsonObject = JsonObject(emptyMap()), + override val echo: JsonElement? = null, + ) : OneBotResult { + override val status: OneBotStatus get() = OneBotStatus.FAILED + + override val retcode: Int + get() = when (failure.kind) { + OneBotFailureKind.AUTHENTICATION -> OneBotRetcode.UNAUTHORIZED + OneBotFailureKind.UNKNOWN_ACTION -> OneBotRetcode.NOT_FOUND + else -> OneBotRetcode.BAD_REQUEST + } + } + + /** An answer this version cannot read, with what the implementation sent. */ + public data class Malformed( + public val detail: String, + override val echo: JsonElement? = null, + /** The answer as it arrived, so that a caller can read the field this version misread. */ + override val raw: JsonObject = JsonObject(emptyMap()), + ) : OneBotResult { + override val status: OneBotStatus get() = OneBotStatus.FAILED + + override val retcode: Int get() = OneBotRetcode.BAD_REQUEST + } +} + +/** Why an [OneBotResult.Unreachable] call failed. */ +public enum class OneBotFailureKind { + /** The endpoint could not be reached, or the connection broke. */ + CONNECTION, + + /** The implementation was too slow for the call's timeouts. */ + TIMEOUT, + + /** The token was missing or wrong. */ + AUTHENTICATION, + + /** The implementation answered with an HTTP status the call cannot use. */ + HTTP_STATUS, + + /** The implementation closed the connection, or the client did. */ + CLOSED, + + /** The call spoke about an action the registry does not know. */ + UNKNOWN_ACTION, +} + +/** One failure of a call that never produced an answer. */ +public data class OneBotFailure( + public val kind: OneBotFailureKind, + /** A description that names no token or secret. */ + public val message: String, + /** The HTTP status of the answer, null when none came. */ + public val httpStatus: Int? = null, +) { + init { + require(message.isNotBlank()) { "A failure needs a message." } + } + + /** Whether sending the same call again may succeed. */ + public val retryable: Boolean + get() = kind == OneBotFailureKind.CONNECTION || kind == OneBotFailureKind.TIMEOUT || + (httpStatus != null && httpStatus in 500..599) +} + +/** The `message` field of a response, null when it holds none. */ +public fun JsonObject.messageOrNull(): String? = + (this["message"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotEmpty() } + +/** The `retcode` the response body of [raw] holds, null when it holds none. */ +public fun retcodeOf(raw: JsonObject): Int? = (raw["retcode"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull() + +/** The `status` of the response body of [raw], null when it holds none. */ +public fun statusOf(raw: JsonObject): OneBotStatus? = + (raw["status"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotEmpty() }?.let(OneBotStatus::of) diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/Connection.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/Connection.kt new file mode 100644 index 0000000..1820ff6 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/Connection.kt @@ -0,0 +1,127 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import java.util.concurrent.atomic.AtomicLong + +/** Where a connection is in its life, which its instance reports to whoever asks. */ +public enum class OneBotLinkState { + /** Nothing was connected yet. */ + CLOSED, + + /** The transport is reaching its implementation, or waiting for it. */ + CONNECTING, + + /** The connection takes calls and reports the events. */ + OPEN, + + /** The connection is closing, or closed on its own. */ + LOST, + ; + + /** Whether the connection takes calls right now. */ + public val isOpen: Boolean get() = this == OPEN +} + +/** + * One live connection to an OneBot implementation, whichever transport carries it. + * + * The plugin calls [send] and reads [events]; nothing outside this package sees the socket or the library behind + * either. The events end when the connection closes, so a reader stops waiting rather than hanging on a connection + * that will never speak again. + */ +public interface OneBotConnection : AutoCloseable { + /** Where the connection is. */ + public val state: StateFlow + + /** The events of the implementation, in the order it reported them. */ + public val events: Flow + + /** Sends one call and returns the answer body, which the caller decodes into its own type. */ + public suspend fun send(action: String, params: JsonObject): JsonObject + + /** Ends the connection and returns once it takes no call anymore. */ + override fun close() +} + +/** + * What every transport of this module shares: the events it reports, the state it reports them under and the close + * that ends both. + * + * A full queue drops its oldest event rather than blocking the thread the implementation reports on, and what it + * dropped is worked out from what it took less what was read and what still waits, so that a connection that cannot + * keep up is visible rather than silent. + */ +internal abstract class AbstractOneBotConnection(private val eventCapacity: Int) : OneBotConnection { + private val dropped = AtomicLong() + private val queue = Channel(eventCapacity, BufferOverflow.DROP_OLDEST) { dropped.incrementAndGet() } + private val mutableState = MutableStateFlow(OneBotLinkState.CLOSED) + + /** + * How many events this connection dropped because nothing read them fast enough. + * + * The queue answers that it took an event even when it displaced one, so the count cannot be read from its answer. + * It is the events it never delivered, which it names as they are dropped, and it only grows. + */ + internal val droppedEvents: Long get() = dropped.get() + + override val state: StateFlow = mutableState.asStateFlow() + + override val events: Flow = queue.receiveAsFlow() + + /** The last failure of the connection, null while it is healthy. */ + @Volatile + protected var failure: Throwable? = null + + @Volatile + private var closed = false + + /** Reports [event], dropping the oldest when nothing reads them fast enough. */ + protected fun report(event: OneBotEvent): Boolean = queue.trySend(event).isSuccess + + /** Moves the connection to [state]. */ + protected fun moveTo(state: OneBotLinkState) { + mutableState.value = state + } + + /** Records [error] and moves the connection to [OneBotLinkState.LOST]. */ + protected fun lost(error: Throwable) { + failure = error + moveTo(OneBotLinkState.LOST) + } + + /** Ends the events once, so that a reader stops after the ones already reported. */ + protected fun endEvents() { + queue.close() + } + + override fun close() { + if (closed) return + closed = true + try { + stop() + } finally { + moveTo(OneBotLinkState.CLOSED) + endEvents() + } + } + + /** Ends whatever the transport opened, once [close] was called for the first time. */ + protected abstract fun stop() +} + +/** The key a call is answered under, which an implementation returns as it is. */ +internal class Echo { + private val counter = java.util.concurrent.atomic.AtomicLong() + + /** The `echo` of the next call. */ + fun next(): String = "alexandrite-${counter.incrementAndGet()}" +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotForwardWebSocket.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotForwardWebSocket.kt new file mode 100644 index 0000000..eb72092 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotForwardWebSocket.kt @@ -0,0 +1,174 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEventCodec +import org.java_websocket.client.WebSocketClient +import org.java_websocket.handshake.ServerHandshake +import java.net.URI +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The connection of an implementation that serves its API and pushes its events on one WebSocket. + * + * The standard answers a call on the same socket it was sent on, under the `echo` of the request, and pushes events + * on the same socket without an echo, so this class tells the two apart and keeps the calls that are still waiting. + * The socket is kept up: a connection that drops is made again after the instance's reconnect interval, and a call + * that waits when the socket drops fails rather than waiting for a socket that may never answer it. + */ +internal class OneBotForwardWebSocket(settings: OneBotSettings) : AbstractOneBotConnection(settings.eventCapacity) { + private val settings = settings + private var scope: CoroutineScope? = null + private var loop: Job? = null + + @Volatile + private var client: AnsweringClient? = null + + /** Connects and returns once the implementation answered the handshake. */ + public suspend fun start() { + check(scope == null) { "The connection is already open." } + moveTo(OneBotLinkState.CONNECTING) + val opened = CompletableDeferred() + val run = CoroutineScope(SupervisorJob() + Dispatchers.IO) + scope = run + loop = run.launch { connectLoop(opened) } + val failure = withTimeout(settings.connectTimeoutMillis) { opened.await() } + if (failure != null) throw failure + } + + override suspend fun send(action: String, params: JsonObject): JsonObject { + val socket = client ?: throw IllegalStateException("The connection is not open.") + val echo = echoes.next() + val waiting = CompletableDeferred() + socket.awaiting[echo] = waiting + try { + socket.send(OneBotWire.encodeToString(requestOf(action, params, echo))) + } catch (e: Exception) { + socket.awaiting.remove(echo) + throw e + } + return try { + withTimeout(settings.firstByteTimeoutMillis) { waiting.await() } + } catch (e: Exception) { + socket.awaiting.remove(echo) + throw e + } + } + + override fun stop() { + // A call waiting for an answer is ended here rather than by the close, whose handler runs on a thread this is + // about to cancel: a caller would otherwise stay suspended until its own timeout, after `stop` has returned. + // The close comes after, so that nothing new is sent in between. + failPending(IllegalStateException("The connection to ${settings.endpoint} is closing.")) + runCatching { client?.close() } + scope?.cancel() + scope = null + loop = null + } + + private suspend fun connectLoop(opened: CompletableDeferred) { + while (scope != null) { + val ended = CompletableDeferred() + val socket = AnsweringClient(URI(settings.requireEndpoint()), ended) + client = socket + val connected = try { + socket.connectBlocking(settings.connectTimeoutMillis, java.util.concurrent.TimeUnit.MILLISECONDS) + } catch (e: Exception) { + if (!opened.isCompleted) { + opened.complete(e) + return + } + null + } + if (connected == false) { + if (!opened.isCompleted) { + opened.complete(IllegalStateException("The implementation at ${settings.endpoint} did not answer.")) + return + } + } else if (connected == true) { + moveTo(OneBotLinkState.OPEN) + if (!opened.isCompleted) opened.complete(null) + ended.await() + failPending(IllegalStateException("The connection to ${settings.endpoint} ended.")) + } + if (scope == null) return + delay(settings.reconnectIntervalMillis) + } + } + + private fun failPending(error: Throwable) { + val socket = client ?: return + for (waiting in socket.awaiting.values) waiting.completeExceptionally(error) + socket.awaiting.clear() + } + + /** The socket of one connection, which reads its frames on the thread of the library. */ + private inner class AnsweringClient(uri: URI, private val ended: CompletableDeferred) : + WebSocketClient( + uri, + buildMap { + settings.token?.let { put(OneBotAuth.AUTHORIZATION, OneBotAuth.bearer(it)) } + }, + ) { + val awaiting = ConcurrentHashMap>() + private val stopping = AtomicBoolean() + + override fun onOpen(handshake: ServerHandshake) { + moveTo(OneBotLinkState.OPEN) + } + + override fun onMessage(message: String) { + val element = try { + OneBotWire.parseToJsonElement(message) + } catch (e: Exception) { + failure = e + return + } + val answer = element as? JsonObject ?: return + val echo = (answer["echo"] as? kotlinx.serialization.json.JsonPrimitive)?.content + if (echo != null) { + val waiting = awaiting.remove(echo) + if (waiting != null) { + if (answer["status"] == null && answer["retcode"] == null) { + waiting.completeExceptionally(IllegalArgumentException("The answer names no status: $answer")) + } else { + waiting.complete(answer) + } + return + } + } + report(OneBotEventCodec.decode(answer)) + } + + override fun onClose(code: Int, reason: String, remote: Boolean) { + if (!stopping.get() && scope != null) lost(IllegalStateException("The connection closed: $code $reason")) + ended.complete(Unit) + } + + override fun onError(ex: Exception) { + failure = ex + } + + /** The headers this connection presents, which carry the token when the instance has one. */ + private fun headers(): Map = buildMap { + settings.token?.let { put(OneBotAuth.AUTHORIZATION, OneBotAuth.bearer(it)) } + } + + override fun close() { + stopping.set(true) + super.close() + } + } + + private val echoes = Echo() +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpApi.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpApi.kt new file mode 100644 index 0000000..0be54bf --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpApi.kt @@ -0,0 +1,193 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.auth.Value +import org.foedusprogramme.alexandrite.channel.onebot.protocol.OneBotRetcode +import org.foedusprogramme.alexandrite.channel.onebot.protocol.OneBotStatus +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailure +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailureKind +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.internal.http.HttpCall +import org.foedusprogramme.alexandrite.internal.http.HttpException +import org.foedusprogramme.alexandrite.internal.http.HttpFailureKind +import org.foedusprogramme.alexandrite.internal.http.HttpTimeouts +import org.foedusprogramme.alexandrite.internal.http.HttpTransport +import java.net.URI +import kotlin.time.Duration.Companion.milliseconds + +/** + * The API of an implementation that serves it over HTTP. + * + * One call is one request to `/` with the token as a bearer header, and the answer is the JSON + * object of the standard. The transport of `internal` sends it, so this class only says what a call is and what its + * answer means. + */ +public class OneBotHttpApi( + private val endpoint: String, + private val token: Value?, + connectTimeoutMillis: Long, + firstByteTimeoutMillis: Long, + idleTimeoutMillis: Long, +) : AutoCloseable { + private val masked: List = listOfNotNull(token?.reveal()?.let(Value::secretOf)) + private val transport = HttpTransport( + HttpTimeouts( + connect = connectTimeoutMillis.milliseconds, + firstByte = firstByteTimeoutMillis.milliseconds, + idle = idleTimeoutMillis.milliseconds, + ), + ) + + /** Sends one call, with [action] already carrying its suffix. */ + public suspend fun call(action: String, params: JsonObject): OneBotResult { + val call = HttpCall( + method = "POST", + // The token goes in both places the standard allows: a call is accepted by an implementation that reads + // the header and by one that only reads the query, and sending the same token twice costs nothing. + uri = URI.create(OneBotAuth.withToken("${endpoint.trimEnd('/')}/$action", token)), + headers = buildMap { + put("Content-Type", "application/json") + token?.let { put(OneBotAuth.AUTHORIZATION, OneBotAuth.bearer(it)) } + }, + body = OneBotWire.encodeToString(JsonObject.serializer(), params), + secrets = masked.map(Value::reveal), + ) + val answer = try { + OneBotWire.decodeFromString(JsonObject.serializer(), transport.send(call)) + } catch (e: HttpException) { + return failure(e) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + return unreachable(OneBotFailureKind.CONNECTION, call.mask("$call was not answered: ${e.message}")) + } + return resultOf(answer, call) + } + + override fun close() { + transport.close() + } + + private fun failure(error: HttpException): OneBotResult.Unreachable { + val kind = when (error.kind) { + HttpFailureKind.AUTHENTICATION, HttpFailureKind.PERMISSION_DENIED -> OneBotFailureKind.AUTHENTICATION + HttpFailureKind.TIMEOUT -> OneBotFailureKind.TIMEOUT + HttpFailureKind.CONNECTION -> OneBotFailureKind.CONNECTION + else -> OneBotFailureKind.HTTP_STATUS + } + return unreachable(kind, error.message ?: "the call failed", error.status) + } + + private fun unreachable( + kind: OneBotFailureKind, + message: String, + httpStatus: Int? = null, + ): OneBotResult.Unreachable = OneBotResult.Unreachable( + OneBotFailure(kind, OneBotAuth.redact(message, masked), httpStatus), + ) + + private fun resultOf(answer: JsonObject, call: HttpCall): OneBotResult { + val status = answer.status() + val retcode = answer.retcode() + val echo = answer["echo"]?.takeUnless { it is JsonNull } + return when { + status == OneBotStatus.ASYNC -> OneBotResult.Async( + retcode = retcode ?: OneBotRetcode.ASYNC, + echo = echo, + raw = answer, + ) + + status == OneBotStatus.OK && (retcode == null || retcode == OneBotRetcode.OK) -> OneBotResult.Ok( + data = answer.data(), + echo = echo, + raw = answer, + ) + + status == null -> OneBotResult.Malformed( + "the answer of $call names no status: ${answer.keys.sorted()}", + echo, + answer, + ) + + else -> OneBotResult.Failed( + retcode = retcode ?: OneBotRetcode.BAD_REQUEST, + message = answer.message(), + echo = echo, + raw = answer, + ) + } + } +} + +/** + * Classifies the answer of one call that came back over a socket, as a call over HTTP classifies its own. + * + * A socket carries the same envelope as an HTTP answer, and reading it as data made a `failed` answer an `Ok` whose + * data was the envelope, which a caller read as a delivery that happened. The status is what says whether a call + * succeeded, whichever transport carried it. + */ +internal fun envelopeResult(answer: JsonObject): OneBotResult { + val status = answer.status() + val retcode = answer.retcode() + val echo = answer["echo"]?.takeUnless { it is JsonNull } + return when { + status == OneBotStatus.ASYNC -> OneBotResult.Async( + retcode = retcode ?: OneBotRetcode.ASYNC, + echo = echo, + raw = answer, + ) + + status == OneBotStatus.OK && (retcode == null || retcode == OneBotRetcode.OK) -> OneBotResult.Ok( + data = answer.data(), + echo = echo, + raw = answer, + ) + + status == null -> OneBotResult.Malformed( + "an answer over a socket names no status: ${answer.keys.sorted()}", + echo, + answer, + ) + + else -> OneBotResult.Failed( + retcode = retcode ?: OneBotRetcode.BAD_REQUEST, + message = answer.message(), + echo = echo, + raw = answer, + ) + } +} + +/** The `status` of this answer, null when it carries none. */ +internal fun JsonObject.status(): OneBotStatus? = + (this["status"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotEmpty() }?.let(OneBotStatus::of) + +/** The `retcode` of this answer, null when it carries none. */ +internal fun JsonObject.retcode(): Int? = (this["retcode"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull() + +/** + * The `data` of this answer as it arrived. + * + * The standard lets an action answer with an object, with an array or with nothing, and a list of friends or of groups + * is one of the arrays, so a reader that took the object shape alone reported every such answer as malformed. + */ +internal fun JsonObject.data(): JsonElement = this["data"] ?: JsonNull + +/** The `message` or `wording` of this answer, null when it carries neither. */ +internal fun JsonObject.message(): String? = + (this["message"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotEmpty() } + ?: (this["wording"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotEmpty() } + +/** The request of one call over a socket, as the standard spells it. */ +internal fun requestOf(action: String, params: JsonObject, echo: String): JsonObject = buildJsonObject { + put("action", action) + put("params", params) + put("echo", echo) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiver.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiver.kt new file mode 100644 index 0000000..3fda22d --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiver.kt @@ -0,0 +1,351 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.foedusprogramme.alexandrite.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEventCodec +import java.io.BufferedInputStream +import java.io.IOException +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.nio.charset.StandardCharsets +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.thread + +/** Why a report was not accepted, as the status the implementation is answered with. */ +public enum class OneBotReportRejection(public val status: Int, public val reason: String) { + /** The request is not a POST of a JSON body. */ + NOT_A_REPORT(400, "a report is a POST of a JSON body"), + + /** The request names another path than the one this instance accepts reports on. */ + WRONG_PATH(404, "no report is accepted on this path"), + + /** The report is larger than a report may be. */ + TOO_LARGE(413, "the report is too large"), + + /** Every thread of this instance is reading a report and the queue behind them is full. */ + BUSY(503, "the reports of this instance are being read as fast as they arrive"), + + /** The report carries no signature, or one that does not match its body. */ + BAD_SIGNATURE(403, "the signature of the report does not match its body"), + + /** The report names another account than the one this instance serves. */ + WRONG_ACCOUNT(403, "the report names another account"), + + /** The body is no event of the standard. */ + MALFORMED(400, "the report is no event of the standard"), +} + +/** What this instance does with one report. */ +public sealed interface OneBotReportAnswer { + /** The report was taken, and nothing more is said. */ + public data object Accepted : OneBotReportAnswer + + /** The report was taken, and the implementation should run [operation] as a quick operation. */ + public data class QuickOperation(public val operation: JsonObject) : OneBotReportAnswer + + /** The report was refused, and the implementation is told why. */ + public data class Rejected(public val rejection: OneBotReportRejection) : OneBotReportAnswer +} + +/** + * The reports of an implementation that sends them over HTTP POST. + * + * One report is one POST of an event, signed with the shared secret when the instance has one, and the answer of the + * receiver is either empty or a quick operation the implementation runs. Alexandrite listens, so this is the side + * that accepts. + */ +internal class OneBotHttpPostReceiver( + private val settings: OneBotSettings, + /** What this instance does with [event], the report it just took. */ + private val answer: (OneBotEvent) -> OneBotReportAnswer = { OneBotReportAnswer.Accepted }, +) : AbstractOneBotConnection(settings.eventCapacity) { + private val accepting = AtomicBoolean() + private val acceptedReports = java.util.concurrent.atomic.AtomicInteger() + private var server: ServerSocket? = null + private var acceptor: Thread? = null + + /** + * The threads that read reports, bounded so that a client cannot hold one thread per connection it opens. + * + * The pool is small and its queue shorter: a listener of one chat is not the busiest thing in the process, and a + * report arrives in a burst of one. Past that a connection is refused at once as busy rather than kept, which is + * what stops a slow sender from occupying the process. + */ + private val readers: java.util.concurrent.ThreadPoolExecutor = java.util.concurrent.ThreadPoolExecutor( + MAX_CONCURRENT_REPORTS, + MAX_CONCURRENT_REPORTS, + 0L, + java.util.concurrent.TimeUnit.MILLISECONDS, + java.util.concurrent.ArrayBlockingQueue(MAX_QUEUED_REPORTS), + java.util.concurrent.ThreadFactory { runnable -> + Thread(runnable, "onebot-http-post-report-$reportPort").apply { isDaemon = true } + }, + ) + + /** The port this receiver bound, which names its threads and which a refusal is about. */ + private val reportPort: Int get() = server?.localPort ?: settings.listenPort + + /** How many reports this receiver took, which tells a refused one from an accepted one. */ + internal val reportedCount: Int get() = acceptedReports.get() + + /** Starts listening and returns once the socket is bound. */ + public fun start() { + check(!accepting.getAndSet(true)) { "The receiver is already open." } + moveTo(OneBotLinkState.CONNECTING) + val socket = try { + ServerSocket().apply { + reuseAddress = true + bind(InetSocketAddress(settings.listenHost, settings.listenPort)) + } + } catch (e: IOException) { + accepting.set(false) + lost(e) + throw e + } + server = socket + acceptor = thread(name = "onebot-http-post-${socket.localPort}", isDaemon = true) { acceptLoop(socket) } + moveTo(OneBotLinkState.OPEN) + } + + /** The port this receiver listens on, which a configured port of 0 leaves to the operating system. */ + public val port: Int get() = server?.localPort ?: settings.listenPort + + override suspend fun send(action: String, params: JsonObject): JsonObject = throw UnsupportedOperationException( + "An implementation that reports over HTTP POST is called over HTTP; use its endpoint as the API.", + ) + + override fun stop() { + accepting.set(false) + runCatching { server?.close() } + acceptor?.let { runCatching { it.join(CLOSE_TIMEOUT_MILLIS) } } + // A reader that is mid-report is left to its deadline rather than interrupted; one that has not started is + // dropped, so that a stopping receiver reads no more than it already took. + readers.shutdownNow() + server = null + acceptor = null + } + + private fun acceptLoop(socket: ServerSocket) { + while (accepting.get() && !socket.isClosed) { + val client = try { + socket.accept() + } catch (e: IOException) { + if (accepting.get()) lost(e) + return + } + try { + readers.execute { client.use { exchange(it) } } + } catch (e: java.util.concurrent.RejectedExecutionException) { + // Every reader is busy and the queue is full, so this connection is answered as busy at once instead + // of being held. A report that is refused this way can be sent again. + client.use { respond(it, OneBotReportRejection.BUSY) } + } + } + } + + private fun exchange(client: Socket) { + try { + // An idle timeout only measures the gap between two reads, so one deadline covers the whole request: a + // client that sends a byte at a time is cut off at the deadline rather than kept for as long as it likes. + val deadline = System.nanoTime() + settings.firstByteTimeoutMillis * 1_000_000 + client.soTimeout = settings.firstByteTimeoutMillis.toInt() + val input = BufferedInputStream(client.getInputStream()) + // A report larger than this instance reads is refused as such, which is what the implementation is told. + // Its declared body is read into nothing first, so that the answer is read by a peer that is still + // sending rather than lost to a connection that closed under it. + val request = when (val outcome = read(input, deadline)) { + is ReadOutcome.TooLarge -> { + drain(input, outcome.declaredBytes, deadline) + return respond(client, OneBotReportRejection.TOO_LARGE) + } + + is ReadOutcome.Read -> outcome.request + + ReadOutcome.NotAReport -> return respond(client, OneBotReportRejection.NOT_A_REPORT) + } + if (request.method != "POST" || request.path != settings.path) { + val rejection = + if (request.method == + "POST" + ) { + OneBotReportRejection.WRONG_PATH + } else { + OneBotReportRejection.NOT_A_REPORT + } + return respond(client, rejection) + } + if (request.body.length > MAX_REPORT_BYTES) return respond(client, OneBotReportRejection.TOO_LARGE) + val secret = settings.secret + val signature = request.headers[OneBotAuth.SIGNATURE.lowercase()] + if (secret != null && !OneBotAuth.matchesSignature(secret, request.body, signature)) { + return respond(client, OneBotReportRejection.BAD_SIGNATURE) + } + val event = try { + OneBotEventCodec.decode(OneBotWire.parseToJsonElement(request.body)) + } catch (e: Exception) { + return respond(client, OneBotReportRejection.MALFORMED) + } + val selfId = settings.selfId + val reported = request.headers[OneBotAuth.SELF_ID.lowercase()] ?: reportedInBody(request.body) + // An instance that serves one account takes no report that names none: the account is what binds the + // report to this instance, so a missing one is as wrong as a different one. This is asked after the body + // is read, so that a report that is no event at all is told apart from one of another account. + if (selfId != null && reported != selfId) { + return respond(client, OneBotReportRejection.WRONG_ACCOUNT) + } + // The decision of this instance is taken first: a report it refuses is answered with that refusal and is + // not reported to anyone, because the implementation has already been told it was not taken. + when (val taken = answer(event)) { + is OneBotReportAnswer.Rejected -> return respond(client, taken.rejection) + + is OneBotReportAnswer.QuickOperation -> { + report(event) + acceptedReports.incrementAndGet() + respond(client, taken.operation) + } + + OneBotReportAnswer.Accepted -> { + report(event) + acceptedReports.incrementAndGet() + respond(client, null) + } + } + } catch (e: IOException) { + failure = e + } catch (e: Exception) { + failure = e + } + } + + private fun respond(client: Socket, rejection: OneBotReportRejection) { + respond(client, null, rejection.status, rejection.reason) + } + + private fun respond(client: Socket, operation: JsonObject?) { + respond(client, operation, 200, null) + } + + private fun respond(client: Socket, operation: JsonObject?, initialStatus: Int, detail: String?) { + val status = if (operation == null && initialStatus == 200) 204 else initialStatus + val body = operation?.let(OneBotWire::encodeToString) ?: detail.orEmpty() + val bytes = body.toByteArray(StandardCharsets.UTF_8) + // A quick operation is an object the implementation reads as JSON; a rejection is a sentence for whoever wrote + // it, and a client that parses it as JSON would fail instead of showing the reason. + val type = if (operation == null) "text/plain; charset=utf-8" else "application/json" + val out = client.getOutputStream() + out.write( + buildString { + append("HTTP/1.1 $status ${if (status == 204) "No Content" else "OK"}\r\n") + if (status != 204) append("Content-Type: $type\r\n") + append("Content-Length: ${bytes.size}\r\n") + append("Connection: close\r\n\r\n") + }.toByteArray(StandardCharsets.US_ASCII), + ) + out.write(bytes) + out.flush() + } + + /** The `self_id` of the report in [body], null when the body names none. */ + private fun reportedInBody(body: String): String? = try { + (OneBotWire.parseToJsonElement(body) as? JsonObject)?.let { report -> + (report["self_id"] as? JsonPrimitive)?.content + } + } catch (e: Exception) { + null + } + + private class Request(val method: String, val path: String, val headers: Map, val body: String) + + /** What reading the head of a connection came to. */ + private sealed interface ReadOutcome { + /** A request this receiver reads. */ + data class Read(val request: Request) : ReadOutcome + + /** A body whose declared length is over what this instance reads, refused before it is allocated. */ + data class TooLarge(val declaredBytes: Int) : ReadOutcome + + /** Not an HTTP request this receiver reads. */ + data object NotAReport : ReadOutcome + } + + /** + * Reads [declaredBytes] of a body that will not be kept, in fixed pieces. + * + * Reading nothing would answer a peer that is still sending, and reading it in one piece is what the limit + * exists to prevent, so it is read in pieces small enough to be forgotten. + */ + private fun drain(input: BufferedInputStream, declaredBytes: Int, deadline: Long) { + val piece = ByteArray(DRAIN_PIECE_BYTES) + var left = declaredBytes.toLong() + while (left > 0) { + // An over-limit body is dropped under the same deadline as any other request, so a sender that drips it + // just faster than the idle timeout cannot hold a reader past the deadline. + if (System.nanoTime() > deadline) return + val count = input.read(piece, 0, minOf(piece.size.toLong(), left).toInt()) + if (count < 0) return + left -= count + } + } + + /** The request of [input], or why it is not one this receiver reads. */ + private fun read(input: BufferedInputStream, deadline: Long): ReadOutcome { + val head = readHead(input, deadline) ?: return ReadOutcome.NotAReport + val lines = head.split("\r\n") + val start = lines.firstOrNull()?.split(' ') ?: return ReadOutcome.NotAReport + if (start.size < 2) return ReadOutcome.NotAReport + val headers = lines.drop(1).mapNotNull { line -> + val colon = line.indexOf(':') + if (colon <= 0) null else line.substring(0, colon).trim().lowercase() to line.substring(colon + 1).trim() + }.toMap() + val length = headers["content-length"]?.toIntOrNull() ?: 0 + // The length is checked before it is used to allocate, so that a client cannot ask this listener for an array + // of the size it names. + if (length < 0 || length > MAX_REPORT_BYTES) return ReadOutcome.TooLarge(length) + val bytes = ByteArray(length) + var read = 0 + while (read < length) { + if (System.nanoTime() > deadline) return ReadOutcome.NotAReport + val count = input.read(bytes, read, length - read) + if (count < 0) return ReadOutcome.NotAReport + read += count + } + return ReadOutcome.Read(Request(start[0].uppercase(), start[1], headers, String(bytes, StandardCharsets.UTF_8))) + } + + /** The request head of [input], which ends at the first empty line, null when it ends first. */ + private fun readHead(input: BufferedInputStream, deadline: Long): String? { + val text = StringBuilder() + var previous = -1 + while (text.length <= MAX_HEAD_BYTES) { + if (System.nanoTime() > deadline) return null + val byte = input.read() + if (byte < 0) return null + text.append(byte.toChar()) + if (previous == '\r'.code && byte == '\n'.code && text.endsWith("\r\n\r\n")) { + return text.toString().removeSuffix("\r\n") + } + previous = byte + } + return null + } + + private companion object { + internal const val MAX_REPORT_BYTES = 8 * 1024 * 1024 + + /** How much of a refused body is read at a time, so that nothing large is ever held. */ + const val DRAIN_PIECE_BYTES = 8 * 1024 + + /** How many reports are read at once; past this a connection waits in the queue or is refused as busy. */ + const val MAX_CONCURRENT_REPORTS = 4 + + /** How many connections wait for a reader before one is refused as busy. */ + const val MAX_QUEUED_REPORTS = 16 + + const val MAX_HEAD_BYTES = 32 * 1024 + + const val CLOSE_TIMEOUT_MILLIS = 2_000L + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotReverseWebSocket.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotReverseWebSocket.kt new file mode 100644 index 0000000..76e59ca --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotReverseWebSocket.kt @@ -0,0 +1,226 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.foedusprogramme.alexandrite.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEventCodec +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds + +/** + * The connection of an implementation that dials Alexandrite to call its API and push its events. + * + * Alexandrite listens, so this is the side that accepts. One connection serves both the calls and the events, as the + * standard's universal client does, and its `X-Client-Role`, `X-Self-ID` and token are checked before it is taken. + */ +internal class OneBotReverseWebSocket(private val settings: OneBotSettings) : + AbstractOneBotConnection(settings.eventCapacity) { + private val awaiting = ConcurrentHashMap>() + private val connected = CompletableDeferred() + private val bound = CountDownLatch(1) + + @Volatile + private var socket: WebSocket? = null + + private val server = object : WebSocketServer(InetSocketAddress(settings.listenHost, settings.listenPort)) { + override fun onStart() { + bound.countDown() + } + + override fun onOpen(connection: WebSocket, handshake: ClientHandshake) { + when (val refusal = refusalOf(handshake)) { + null -> { + // A peer that replaces the one being served takes over the calls of the connection it replaces, + // and the frames of the peer that left are no longer read. Calls sent on that peer would + // therefore wait for an answer that can never be read, so they are ended before it replaces it. + socket?.let { left -> + socket = null + failWaiting(IllegalStateException("A newer peer took over the connection.")) + left.close(REPLACED_CODE, "a newer peer took over the connection") + } + socket = connection + connected.complete(Unit) + } + + else -> { + failure = IllegalStateException(refusal) + connected.completeExceptionally(IllegalStateException(refusal)) + connection.close(REFUSAL_CODE, refusal) + } + } + } + + /** + * Why this connection is refused, null when its token and its account are the ones this instance serves. + * + * The headers are read by walking the handshake: asking the library for one it does not hold answers Java's + * `null` into a Kotlin non-null type, which throws inside the library's selector thread and takes the whole + * connection with it. + */ + private fun refusalOf(handshake: ClientHandshake): String? { + val headers = HEADERS.filter { handshake.hasFieldValue(it) } + .associate { it.lowercase() to handshake.getFieldValue(it) } + val token = settings.token + val authorization = headers[OneBotAuth.AUTHORIZATION.lowercase()] + val offered = OneBotAuth.tokenOf(authorization) + if (token != null && !OneBotAuth.matchesToken(token, offered)) { + return "The connection carries another token than this instance." + } + val selfId = settings.selfId + val reported = headers[OneBotAuth.SELF_ID.lowercase()] + // An instance that serves one account takes no connection that names none: the account is what binds the + // connection to this instance, so a missing one is as wrong as a different one. + if (selfId != null && reported != selfId) { + return "The connection names another account than this instance." + } + return refusedClientRole(headers[OneBotAuth.CLIENT_ROLE.lowercase()]) + } + override fun onMessage(connection: WebSocket, message: String) { + // A connection this instance refused is closed as it is opened, and one that an implementation replaced + // is no longer the peer being served. Frames are read from the active peer alone, so that neither can + // report an event or answer a call of the connection that is. + if (socket !== connection) return + val element = try { + OneBotWire.parseToJsonElement(message) + } catch (e: Exception) { + failure = e + return + } + val answer = element as? JsonObject ?: return + val echo = (answer["echo"] as? JsonPrimitive)?.content + if (echo == null) { + report(OneBotEventCodec.decode(answer)) + return + } + val waiting = awaiting.remove(echo) ?: return + if (answer["status"] == null && answer["retcode"] == null) { + waiting.completeExceptionally(IllegalArgumentException("The answer names no status: $answer")) + } else { + waiting.complete(answer) + } + } + + override fun onClose(connection: WebSocket, code: Int, reason: String, remote: Boolean) { + // A refused connection and one that a newer peer replaced close too, and neither of them owes an answer + // to a call of the connection being served. Their close says nothing about that connection, so nothing + // waiting on it is ended here. + if (socket !== connection) return + socket = null + if (state.value.isOpen) lost(IllegalStateException("The connection closed: $code $reason")) + // A call that was waiting for this connection is not answered by a connection that is gone, so it is told + // so now rather than when its own timeout runs out, which would report a slow implementation instead of a + // lost one. + failWaiting(IllegalStateException("The connection closed before it answered: $code $reason")) + } + + override fun onError(connection: WebSocket?, ex: Exception) { + failure = ex + } + } + + /** Starts listening and returns once the socket is bound, whether an implementation dialed yet or not. */ + suspend fun start() { + moveTo(OneBotLinkState.CONNECTING) + server.start() + if (!bound.await(settings.connectTimeoutMillis, TimeUnit.MILLISECONDS)) { + throw IllegalStateException("The listener at ${settings.listenHost}:${server.port} did not start.") + } + // The library counts the server started from its own thread before that thread takes connections, so a + // client that dials the moment `start` returns can be answered by nobody. Let the selector take over. + delay(START_SETTLE_MILLIS.milliseconds) + moveTo(OneBotLinkState.OPEN) + } + + /** Waits for the implementation to dial in, and returns whether one did. */ + suspend fun awaitPeer(): Boolean = try { + withTimeout(settings.connectTimeoutMillis) { connected.await() } + true + } catch (e: Exception) { + false + } + + /** + * Ends every call that waits for an answer, with [reason]. + * + * A call that outlives the connection that would answer it has nothing left to wait for, so it is told why rather + * than being left until its own timeout says the implementation was slow. + */ + private fun failWaiting(reason: Exception) { + awaiting.keys.toList().forEach { echo -> awaiting.remove(echo)?.completeExceptionally(reason) } + } + + /** The port this connection listens on, which a configured port of 0 leaves to the operating system. */ + val port: Int get() = server.port + + override suspend fun send(action: String, params: JsonObject): JsonObject { + val connection = socket ?: throw IllegalStateException("No implementation is connected.") + val echo = echoes.next() + val waiting = CompletableDeferred() + awaiting[echo] = waiting + try { + connection.send(OneBotWire.encodeToString(requestOf(action, params, echo))) + } catch (e: Exception) { + awaiting.remove(echo) + throw e + } + return try { + withTimeout(settings.firstByteTimeoutMillis) { waiting.await() } + } catch (e: Exception) { + awaiting.remove(echo) + throw e + } + } + + override fun stop() { + runCatching { server.stop(CLOSE_TIMEOUT_MILLIS.toInt()) } + for (waiting in awaiting.values) { + waiting.completeExceptionally(IllegalStateException("The connection is closing.")) + } + awaiting.clear() + } + + private val echoes = Echo() + + internal companion object { + const val CLOSE_TIMEOUT_MILLIS = 1_000L + + /** The headers this listener reads, which the library answers only for the ones it holds. */ + val HEADERS = listOf(OneBotAuth.AUTHORIZATION, OneBotAuth.SELF_ID, OneBotAuth.CLIENT_ROLE) + + /** The `X-Client-Role` values one connection of this transport can carry the calls and the events both under. */ + val SERVED_ROLES = listOf("Universal", "Service") + + /** How long the listener gives the library to take over the socket it bound. */ + const val START_SETTLE_MILLIS = 150L + + /** The code a connection is closed under when this instance does not serve it. */ + const val REFUSAL_CODE = 1008 + + /** The code a connection is closed under when a newer peer takes over the instance. */ + const val REPLACED_CODE = 1000 + } +} + +/** + * Why the role of a connection is refused, null when an instance can serve it. + * + * One connection of this transport carries the calls and the events both, which is the standard's universal + * client, so that is the role an instance serves. A connection of `Event` carries no answers to its calls and one + * of `API` sends none of its own, and taking either would leave the instance talking to itself. An implementation + * that names no role keeps working, because older ones report none. + */ +internal fun refusedClientRole(role: String?): String? { + val served = listOf("Universal", "Service") + if (role == null || role in served) return null + return "The connection is a $role client, but this instance serves ${served.joinToString()}; " + + "configure a ws_reverse instance per client role instead." +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotSettings.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotSettings.kt new file mode 100644 index 0000000..a55579a --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotSettings.kt @@ -0,0 +1,48 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import org.foedusprogramme.alexandrite.channel.onebot.auth.Value +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotConfig +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig + +/** + * What one transport of one instance needs, gathered from the plugin's settings and the instance's own. + * + * A transport takes this rather than the two config classes, so that it never reads a setting its own instance + * cannot hold. + */ +internal class OneBotSettings( + /** The URL of the transport that calls its implementation, null for the ones that listen. */ + val endpoint: String?, + val listenHost: String, + val listenPort: Int, + val path: String, + val token: Value?, + val secret: Value?, + val selfId: String?, + val connectTimeoutMillis: Long, + val firstByteTimeoutMillis: Long, + val idleTimeoutMillis: Long, + val reconnectIntervalMillis: Long, + val eventCapacity: Int, +) { + /** The endpoint of a transport that calls its implementation, which one that listens does not hold. */ + fun requireEndpoint(): String = requireNotNull(endpoint) { "This instance listens for its implementation." } + + companion object { + /** The settings of [instance] below [plugin]. */ + fun of(plugin: OneBotConfig, instance: OneBotInstanceConfig): OneBotSettings = OneBotSettings( + endpoint = instance.endpoint, + listenHost = instance.listenHost, + listenPort = instance.listenPort, + path = instance.path, + token = Value.orNull(instance.accessToken), + secret = Value.orNull(instance.secret), + selfId = instance.selfId, + connectTimeoutMillis = instance.connectTimeoutMillis, + firstByteTimeoutMillis = instance.firstByteTimeoutMillis, + idleTimeoutMillis = instance.idleTimeoutMillis, + reconnectIntervalMillis = instance.reconnectIntervalMillis, + eventCapacity = plugin.eventQueueCapacity, + ) + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotWire.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotWire.kt new file mode 100644 index 0000000..e7b823e --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotWire.kt @@ -0,0 +1,33 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive + +/** + * The JSON of the OneBot wire. + * + * The standard says nothing about repeated keys or unknown ones, and an implementation adds fields of its own, so + * reading ignores what this version does not know and writing keeps the defaults out of the request. + */ +internal val OneBotWire: Json = Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + encodeDefaults = false +} + +/** The `echo` a call is answered under, which an implementation returns as it is. */ +internal fun echoOf(element: JsonElement?): String? = + (element as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull + +/** The text of [key] in this object, null when it holds none. */ +internal fun JsonObject.text(key: String): String? = + (this[key] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull + +/** The long of [key] in this object, null when it holds no number. */ +internal fun JsonObject.long(key: String): Long? = text(key)?.toLongOrNull() diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotChannelIntegrationTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotChannelIntegrationTest.kt new file mode 100644 index 0000000..57df520 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotChannelIntegrationTest.kt @@ -0,0 +1,183 @@ +package org.foedusprogramme.alexandrite.channel.onebot + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.sdk.channel.ChannelDirectory +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.key +import org.foedusprogramme.alexandrite.sdk.turn.Submission +import org.foedusprogramme.alexandrite.testkit.PluginHarness +import org.foedusprogramme.alexandrite.testkit.PluginHarness.Running +import org.foedusprogramme.alexandrite.testkit.RecordingTurnSubmitter +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The OneBot channel inside a real runtime. + * + * A harness builds the plugin set and the container, a peer built from the library serves the implementation, and the + * test drives what a user would do: the implementation reports a message, the agent is asked for a turn, and the + * answer the agent writes goes back out through the actions of the standard. + */ +class OneBotChannelIntegrationTest { + private val instance = ChannelInstanceId(ChannelType("onebot"), "main") + + private val directory = key() + + @Test + fun `a reported message reaches the agent and the answer goes back to the implementation`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection(), "the channel did not connect to the implementation") + peer.report(privateMessage()) + val submission = awaitSubmission(submitter) + val message = assertIs(submission) + assertEquals("private:12345678", message.message.chat.chat) + assertEquals("hello there", message.message.text) + + val channel = org.foedusprogramme.alexandrite.sdk.channel.ChannelDirectory::class.java + val delivery = sendTo(message.message.chat) + assertEquals("7", assertIs(delivery).messages.single().id) + val call = peer.awaitCall("send_private_msg") + assertTrue(call.contains("hello back"), "the implementation was not sent the reply: $call") + } + } + } finally { + peer.stop() + } + } + + private suspend fun org.foedusprogramme.alexandrite.testkit.PluginHarness.Running.awaitSubmission( + submitter: RecordingTurnSubmitter, + ): Submission { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + submitter.submissions.firstOrNull()?.let { return it } + kotlinx.coroutines.delay(20) + } + error("the agent was asked for no turn") + } + + private suspend fun Running.sendTo(chat: ChatAddress): Delivery { + val front = get(directory).channel(instance) ?: error("the instance has no channel") + val message = OutboundMessage.builder("hello back", MessageKind.REPLY).build() + return front.send(chat, message) + } + + private fun config(port: Int): String = """ + { + "settings": {"eventQueueCapacity": 64}, + "instances": { + "main": { + "transport": "ws", + "endpoint": "ws://127.0.0.1:$port", + "connectTimeoutMillis": 5000, + "firstByteTimeoutMillis": 5000, + "idleTimeoutMillis": 5000, + "reconnectIntervalMillis": 200 + } + } + } + """.trimIndent() + + private fun privateMessage(): String = """ + { + "time": 1515204254, + "self_id": 10001000, + "post_type": "message", + "message_type": "private", + "sub_type": "friend", + "message_id": 12, + "user_id": 12345678, + "message": "hello there", + "raw_message": "hello there", + "sender": {"user_id": 12345678, "nickname": "someone"} + } + """.trimIndent() + + /** An implementation that serves the API and reports what the test tells it to. */ + private class ImplementationPeer : WebSocketServer(InetSocketAddress("127.0.0.1", 0)) { + private val started = CountDownLatch(1) + private val connected = CountDownLatch(1) + private val calls = CopyOnWriteArrayList() + private val callWaiters = CopyOnWriteArrayList>() + + @Volatile + private var socket: WebSocket? = null + + override fun onStart() { + started.countDown() + } + + override fun onOpen(connection: WebSocket, handshake: ClientHandshake) { + socket = connection + connected.countDown() + } + + override fun onMessage(connection: WebSocket, message: String) { + calls += message + val echo = message.substringAfter("\"echo\":\"", "").substringBefore('"') + if (echo.isEmpty()) return + connection.send( + """{"status":"ok","retcode":0,"data":{"message_id":7},"echo":"$echo"}""", + ) + } + + override fun onClose(connection: WebSocket, code: Int, reason: String, remote: Boolean) { + if (socket === connection) socket = null + } + + override fun onError(connection: WebSocket?, ex: Exception) = Unit + + /** Starts listening and returns the port this peer took. */ + fun startAndPort(): Int { + start() + check(started.await(5, TimeUnit.SECONDS)) { "the peer did not start" } + return port + } + + /** Reports [json] as an event of the implementation. */ + fun report(json: String) { + val connection = socket ?: error("nothing is connected") + connection.send(json) + } + + /** Waits for the channel to connect and returns whether it did. */ + fun awaitConnection(): Boolean = connected.await(5, TimeUnit.SECONDS) + + /** Waits for a call of [action] and returns it, whether it arrived before or after this call. */ + fun awaitCall(action: String): String { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + calls.lastOrNull { it.contains("\"$action\"") }?.let { return it } + Thread.sleep(20) + } + error("no call of $action arrived, saw $calls") + } + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/auth/OneBotAuthTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/auth/OneBotAuthTest.kt new file mode 100644 index 0000000..22f5c24 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/auth/OneBotAuthTest.kt @@ -0,0 +1,74 @@ +package org.foedusprogramme.alexandrite.channel.onebot.auth + +import org.foedusprogramme.alexandrite.sdk.config.Secret +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class OneBotAuthTest { + private val token = Value.of(Secret("s3cr3t-token")) + private val secret = Value.of(Secret("signing-key")) + + @Test + fun `a token is presented as a bearer header and read back from either form`() { + assertEquals("Bearer s3cr3t-token", OneBotAuth.bearer(token)) + assertEquals("s3cr3t-token", OneBotAuth.tokenOf("Bearer s3cr3t-token")) + assertEquals("s3cr3t-token", OneBotAuth.tokenOf("bearer s3cr3t-token")) + assertEquals("s3cr3t-token", OneBotAuth.tokenOf("s3cr3t-token")) + assertEquals(null, OneBotAuth.tokenOf("")) + assertEquals(null, OneBotAuth.tokenOf(null)) + assertTrue(OneBotAuth.matchesToken(token, "s3cr3t-token")) + assertFalse(OneBotAuth.matchesToken(token, "s3cr3t-toke")) + } + + @Test + fun `a report is signed with hmac sha1 over its body`() { + val signature = OneBotAuth.signature(secret, """{"post_type":"meta_event"}""") + assertTrue(signature.startsWith(OneBotAuth.SIGNATURE_PREFIX)) + assertEquals(OneBotAuth.SIGNATURE_PREFIX.length + 40, signature.length) + assertTrue(OneBotAuth.matchesSignature(secret, """{"post_type":"meta_event"}""", signature)) + assertTrue(OneBotAuth.matchesSignature(secret, """{"post_type":"meta_event"}""", signature.uppercase())) + assertFalse(OneBotAuth.matchesSignature(secret, """{"post_type":"meta_event","x":1}""", signature)) + assertFalse(OneBotAuth.matchesSignature(secret, """{"post_type":"meta_event"}""", "sha1=00")) + assertFalse(OneBotAuth.matchesSignature(secret, """{"post_type":"meta_event"}""", null)) + } + + @Test + fun `the documented example signs its body the way the standard spells it`() { + val signature = OneBotAuth.signature(Value.of(Secret("some-secret")), "the body") + assertEquals("sha1=" + hex(hmac("some-secret", "the body")), signature) + } + + @Test + fun `a token is carried as a query parameter as well`() { + assertEquals( + "http://host/send_msg?access_token=a%20b", + OneBotAuth.withToken("http://host/send_msg", Value.of(Secret("a b"))), + ) + assertEquals( + "http://host/x?a=1&access_token=t", + OneBotAuth.withToken("http://host/x?a=1", Value.of(Secret("t"))), + ) + assertEquals("http://host/x", OneBotAuth.withToken("http://host/x", null)) + } + + @Test + fun `neither a token nor a secret reaches a message`() { + val text = "call failed with Bearer s3cr3t-token and signing-key" + val redacted = OneBotAuth.redact(text, listOf(token, secret)) + assertFalse(redacted.contains("s3cr3t-token")) + assertFalse(redacted.contains("signing-key")) + assertTrue(redacted.contains("***")) + assertEquals(text, OneBotAuth.redact(text, emptyList())) + assertFalse(Secret("s3cr3t-token").toString().contains("s3cr3t-token")) + } + + private fun hex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) } + + private fun hmac(key: String, message: String): ByteArray { + val mac = javax.crypto.Mac.getInstance("HmacSHA1") + mac.init(javax.crypto.spec.SecretKeySpec(key.toByteArray(), "HmacSHA1")) + return mac.doFinal(message.toByteArray()) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotHttpTransportTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotHttpTransportTest.kt new file mode 100644 index 0000000..a79bac5 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotHttpTransportTest.kt @@ -0,0 +1,209 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.auth.Value +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotHttpApi +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotWire +import org.foedusprogramme.alexandrite.channel.onebot.transport.envelopeResult +import org.foedusprogramme.alexandrite.sdk.config.Secret +import java.io.BufferedInputStream +import java.io.OutputStreamWriter +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.nio.charset.StandardCharsets +import kotlin.concurrent.thread +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The HTTP transport against a peer of this test, which reads the request line, the headers and the body and answers + * with what the test told it to. + */ +class OneBotHttpTransportTest { + private val peer = Peer() + private val token = Value.of(Secret("s3cr3t-token")) + private val client = OneBotHttpApi("http://127.0.0.1:${peer.port}", token, 5_000, 5_000, 5_000) + + @AfterTest + fun close() { + client.close() + peer.close() + } + + @Test + fun `a successful call reads the data of the answer`() { + peer.answer("""{"status":"ok","retcode":0,"data":{"user_id":10001000,"nickname":"someone"}}""") + val result = runBlocking { client.call("get_login_info", JsonObject(emptyMap())) } + val ok = + assertIs>(result) + assertEquals("10001000", ok.data["user_id"]?.jsonPrimitive?.content) + val request = peer.next() + assertTrue(request.startsWith("POST /get_login_info"), "the call was $request") + assertTrue(request.contains("Bearer s3cr3t-token"), "the token was not presented") + assertTrue(request.contains("\"params\"") || request.endsWith("{}"), "the body was $request") + } + + @Test + fun `a call carries its token both ways the standard allows`() { + peer.answer("""{"status":"ok","retcode":0,"data":{}}""") + + runBlocking { client.call("get_login_info", JsonObject(emptyMap())) } + + val request = peer.next() + assertTrue( + request.startsWith("POST /get_login_info?access_token=s3cr3t-token"), + "the token is not in the query, so an implementation that reads only the query would refuse: $request", + ) + assertTrue( + request.contains("Authorization: Bearer s3cr3t-token"), + "the token is not in the header, so one that reads only the header would refuse: $request", + ) + } + + @Test + fun `a refused call keeps the retcode of the implementation`() { + peer.answer("""{"status":"failed","retcode":1004,"message":"too fast"}""") + val result = runBlocking { client.call("send_private_msg", buildJsonObject { put("user_id", "1") }) } + val failed = + assertIs(result) + assertEquals(1004, failed.retcode) + assertEquals("too fast", failed.message) + } + + @Test + fun `an implementation that is not there is unreachable, not a refusal`() { + val absent = OneBotHttpApi("http://127.0.0.1:1", token, 1_000, 1_000, 1_000) + try { + val result = runBlocking { absent.call("get_status", JsonObject(emptyMap())) } + assertIs(result) + } finally { + absent.close() + } + } + + @Test + fun `a missing token is reported as an authentication failure`() { + peer.answer("""{"status":"failed","retcode":1401}""", status = 401) + val result = runBlocking { client.call("get_status", JsonObject(emptyMap())) } + val unreachable = + assertIs(result) + assertEquals( + org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailureKind.AUTHENTICATION, + unreachable.failure.kind, + ) + } + + @Test + fun `an answer this version cannot read is malformed, and the token stays out of it`() { + peer.answer("""{"hello":"world"}""") + val result = runBlocking { client.call("get_status", JsonObject(emptyMap())) } + val malformed = + assertIs(result) + assertTrue(!malformed.detail.contains("s3cr3t-token"), "the token leaked: ${malformed.detail}") + } + + @Test + fun `an answer that carries an array keeps it instead of an empty object`() { + // The standard answers `get_friend_list` and `get_group_list` with an array, so an answer read as an object + // alone became empty and every such call was reported as one this version cannot read. + val answer = OneBotWire.parseToJsonElement( + """{"status":"ok","retcode":0,"data":[{"user_id":10001000,"nickname":"someone"}]}""", + ) as JsonObject + + val ok = assertIs>( + envelopeResult(answer), + ) + assertEquals(1, assertIs(ok.data).size) + } + + @Test + fun `an answer that carries an object keeps it`() { + val answer = OneBotWire.parseToJsonElement("""{"status":"ok","retcode":0,"data":{"message_id":42}}""") + as JsonObject + + val ok = assertIs>( + envelopeResult(answer), + ) + assertEquals("42", (ok.data as? JsonObject)?.get("message_id")?.jsonPrimitive?.content) + } + + /** A peer that answers one request with what the test set. */ + private class Peer { + private val server = ServerSocket(0, 8, InetAddress.getLoopbackAddress()) + private val served = java.util.concurrent.LinkedBlockingQueue() + private val waiting = java.util.concurrent.LinkedBlockingQueue() + + @Volatile + private var status: Int = 200 + + @Volatile + private var answer: String = """{"status":"ok","retcode":0,"data":{}}""" + + val port: Int get() = server.localPort + + init { + thread(isDaemon = true) { + while (!server.isClosed) { + val socket = try { + server.accept() + } catch (e: Exception) { + return@thread + } + socket.use { exchange(it) } + } + } + } + + fun answer(json: String, status: Int = 200) { + this.answer = json + this.status = status + } + + /** The request the peer answered last. */ + fun next(): String = waiting.poll() ?: served.poll() ?: error("the peer was called by nobody") + + private fun exchange(socket: Socket) { + val input = BufferedInputStream(socket.getInputStream()) + val head = StringBuilder() + while (!head.endsWith("\r\n\r\n")) { + val byte = input.read() + if (byte < 0) return + head.append(byte.toChar()) + } + val length = head.lineSequence() + .firstOrNull { it.startsWith("Content-Length:", ignoreCase = true) } + ?.substringAfter(':')?.trim()?.toIntOrNull() ?: 0 + val body = ByteArray(length) + var read = 0 + while (read < length) { + val count = input.read(body, read, length - read) + if (count < 0) break + read += count + } + val request = head.toString().trimEnd() + "\n" + String(body, StandardCharsets.UTF_8) + served += request + waiting += request + val bytes = answer.toByteArray(StandardCharsets.UTF_8) + val writer = OutputStreamWriter(socket.getOutputStream(), StandardCharsets.US_ASCII) + writer.write("HTTP/1.1 $status OK\r\nContent-Type: application/json\r\nContent-Length: ${bytes.size}\r\n") + writer.write("Connection: close\r\n\r\n") + writer.flush() + socket.getOutputStream().write(bytes) + socket.getOutputStream().flush() + } + + fun close() { + runCatching { server.close() } + } + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessagesTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessagesTest.kt new file mode 100644 index 0000000..c71dd6e --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessagesTest.kt @@ -0,0 +1,265 @@ +package org.foedusprogramme.alexandrite.channel.onebot.mapping + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendPrivateMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEventCodec +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegmentCodec +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.DeliveryFailure +import org.foedusprogramme.alexandrite.sdk.channel.Markup +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OneBotMessagesTest { + private val instance = ChannelInstanceId(ChannelType("onebot"), "main") + + @Test + fun `a private message becomes a message of a direct chat`() { + val message = OneBotMessages.incoming(instance, privateEvent()) + assertEquals("private:12345678", message.chat.chat) + assertEquals("12", message.ref.id) + assertEquals("12345678", message.sender.address.user) + assertEquals(ChatKind.DIRECT, message.chatInfo.kind) + assertEquals("someone", message.sender.displayName) + assertEquals("hello there", message.text) + assertEquals(0, message.media.size) + } + + @Test + fun `a group message keeps its group and marks an administrator`() { + val message = OneBotMessages.incoming(instance, groupEvent(role = "admin")) + assertEquals("group:100100", message.chat.chat) + assertEquals(ChatKind.GROUP, message.chatInfo.kind) + assertTrue(message.sender.isAdmin) + assertEquals("the card", message.sender.displayName) + } + + @Test + fun `an instance admin is an admin even when the platform reports a plain member`() { + val message = OneBotMessages.incoming(instance, groupEvent(role = "member"), admins = setOf("12345678")) + assertTrue(message.sender.isAdmin) + } + + @Test + fun `an anonymous group message is a fact of the message`() { + val message = OneBotMessages.incoming(instance, groupEvent(anonymous = true)) + assertTrue(message.facts.any { it.label == "anonymous" && it.value == "no one" }) + } + + @Test + fun `the segments the SDK has no shape for become facts`() { + val message = OneBotMessages.incoming(instance, privateEvent(message = "[CQ:napcat_thing,key=value]")) + assertTrue(message.facts.any { it.label == "unsupported segment" && it.value == "napcat_thing" }) + } + + @Test + fun `a reply names the message it answers`() { + val message = OneBotMessages.incoming(instance, privateEvent(message = "[CQ:reply,id=99]hello")) + assertEquals("99", message.quote?.target?.id) + assertEquals("private:12345678", message.quote?.target?.chat?.chat) + } + + @Test + fun `an image becomes a media attachment and a fact`() { + val message = OneBotMessages.incoming( + instance, + privateEvent(message = "[CQ:image,file=1.jpg,url=http://x/1.jpg]"), + ) + assertEquals(1, message.media.size) + assertEquals("1.jpg", message.media.single().name) + assertTrue(message.facts.any { it.label == "image" }) + } + + @Test + fun `a temporary session keeps the group as its chat and the sender as its thread`() { + val event = + assertIs( + OneBotEventCodec.decode(parse(privateJson(subType = "group", groupId = 100100))), + ) + val chat = OneBotChats.addressOf(instance, event) + assertEquals("group:100100", chat.chat) + assertEquals("12345678", chat.thread) + assertEquals("12345678", OneBotChats.addressOf(instance, event).thread) + } + + @Test + fun `a temporary session answers its sender and not its group`() { + val event = + assertIs( + OneBotEventCodec.decode(parse(privateJson(subType = "group", groupId = 100100))), + ) + val chat = OneBotChats.addressOf(instance, event) + + // A reply to this chat belongs to the user who sent it: the group it came through is not where an answer to a + // private message goes, and the address says so by keeping the sender as its thread. + assertEquals(UserId("12345678"), OneBotMessages.privateTemporary(chat)) + assertNull(OneBotMessages.privateUser(chat), "the chat itself names the group, not the sender") + } + + @Test + fun `plain text leaves as one text segment`() { + val message = OutboundMessage.builder( + "hello", + org.foedusprogramme.alexandrite.sdk.channel.MessageKind.REPLY, + ).build() + val segments = OneBotMessages.outgoing(chat(), message).segments + assertEquals(listOf(OneBotSegment.Text("hello")), segments) + } + + @Test + fun `a message keeps the shape it was written in when it asks to be taken literally`() { + // A whole message as one piece of text is sent as text, so that `auto_escape` can say to take the CQ codes + // inside it as the characters they are. A message of segments is sent as segments and asks for no escaping, + // because an implementation that read the text of one as an escape would send something else. + val literal = SendPrivateMessage(UserId("1"), OneBotMessage.StringValue("hello [CQ:face]"), autoEscape = true) + assertEquals("hello [CQ:face]", literal.toJson()["message"]?.jsonPrimitive?.content) + assertEquals(true, literal.toJson()["auto_escape"]?.jsonPrimitive?.boolean) + + val segments = SendPrivateMessage(UserId("1"), OneBotMessage.ArrayValue(listOf(OneBotSegment.Text("hi")))) + assertIs(segments.toJson()["message"]) + assertNull(segments.toJson()["auto_escape"]) + } + + @Test + fun `markdown leaves its images as image segments`() { + val message = OutboundMessage.builder( + "see ![a](http://x/1.jpg) done", + org.foedusprogramme.alexandrite.sdk.channel.MessageKind.REPLY, + ) + .markup(Markup.MARKDOWN) + .build() + val segments = OneBotMessages.outgoing(chat(), message).segments + assertEquals(3, segments.size) + assertEquals("see ", assertIs(segments[0]).text) + assertEquals("http://x/1.jpg", assertIs(segments[1]).file) + assertEquals(" done", assertIs(segments[2]).text) + } + + @Test + fun `a reply to a message whose id is not one of the implementation is sent without the reply segment`() { + // The delivery of a message this version could not name is referenced as `sent` or `async`, which names no + // message of the implementation. Answering one has to send the answer rather than fail before sending it. + val message = OutboundMessage.builder("hi", org.foedusprogramme.alexandrite.sdk.channel.MessageKind.REPLY) + .replyTo(org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef(chat(), "sent")) + .build() + + val segments = OneBotMessages.outgoing(chat(), message).segments + assertEquals(listOf(OneBotSegment.Text("hi")), segments) + } + + @Test + fun `a reply to another message leads with the reply segment`() { + val message = OutboundMessage.builder("hi", org.foedusprogramme.alexandrite.sdk.channel.MessageKind.REPLY) + .replyTo(org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef(chat(), "42")) + .build() + val segments = OneBotMessages.outgoing(chat(), message).segments + assertEquals( + OneBotSegment.Reply(org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId("42")), + segments[0], + ) + assertEquals(OneBotSegment.Text("hi"), segments[1]) + } + + @Test + fun `a chat address is read back into the user or the group it names`() { + assertEquals("12345678", OneBotMessages.privateUser(chat())?.value) + assertNull(OneBotMessages.group(chat())) + assertEquals("100100", OneBotMessages.group(ChatAddress(instance, "group:100100"))?.value) + assertNull(OneBotMessages.privateUser(ChatAddress(instance, "group:100100"))) + assertTrue(OneBotMessages.isGroup(ChatAddress(instance, "group:100100"))) + } + + @Test + fun `a delivered message names what the implementation answered`() { + val answer = kotlinx.serialization.json.buildJsonObject { put("message_id", 7) } + val delivery = OneBotMessages.delivery(OneBotResult.Ok(answer), chat()) + assertEquals("7", assertIs(delivery).messages.single().id) + } + + @Test + fun `a refused send becomes the failure the retcode means`() { + val limited = OneBotMessages.delivery(OneBotResult.Failed(1004), chat()) + assertEquals(DeliveryFailure.RATE_LIMITED, assertIs(limited).kind) + val forbidden = OneBotMessages.delivery(OneBotResult.Failed(1003), chat()) + assertEquals(DeliveryFailure.FORBIDDEN, assertIs(forbidden).kind) + } + + @Test + fun `an unreachable peer is a transient failure`() { + val delivery = OneBotMessages.delivery( + OneBotResult.Unreachable( + org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailure( + org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailureKind.TIMEOUT, + "no answer", + ), + ), + chat(), + ) + val notDelivered = assertIs(delivery) + assertEquals(DeliveryFailure.TRANSIENT, notDelivered.kind) + assertTrue(notDelivered.retryable) + } + + @Test + fun `the capabilities of the channel name what it can do`() { + val capabilities = OneBotMessages.capabilities() + assertTrue(capabilities.markups.contains(Markup.PLAIN)) + assertTrue(capabilities.markups.contains(Markup.MARKDOWN)) + assertTrue(capabilities.proactive) + assertEquals(false, capabilities.streaming) + assertNull(capabilities.maxPartsPerReply) + } + + private fun chat(): ChatAddress = ChatAddress(instance, "private:12345678") + + /** The JSON of a report, as the codec reads it. */ + private fun parse(json: String): kotlinx.serialization.json.JsonElement = Json.parseToJsonElement(json) + + private fun privateEvent(message: String = "hello there"): OneBotEvent.Message = + assertIs(OneBotEventCodec.decode(parse(privateJson(message = message)))) + + private fun privateJson(subType: String = "friend", groupId: Int? = null, message: String = "hello there"): String = + buildString { + append("""{"time":1515204254,"self_id":10001000,"post_type":"message","message_type":"private",""") + append(""""sub_type":"$subType","message_id":12,"user_id":12345678,""") + groupId?.let { append(""""group_id":$it,""") } + append(""""message":"$message","raw_message":"$message",""") + append(""""sender":{"user_id":12345678,"nickname":"someone","sex":"male","age":18}}""") + } + + private fun groupEvent(role: String = "member", anonymous: Boolean = false): OneBotEvent.Message = assertIs( + OneBotEventCodec.decode( + parse( + buildString { + append( + """{"time":1515204254,"self_id":10001000,"post_type":"message","message_type":"group",""", + ) + append(""""sub_type":"normal","message_id":12,"group_id":100100,"user_id":12345678,""") + append(""""message":"hi","raw_message":"hi",""") + if (anonymous) append(""""anonymous":{"id":1,"name":"no one","flag":"abc"},""") + append( + """"sender":{"user_id":12345678,"nickname":"someone","card":"the card","role":"$role"}}""", + ) + }, + ), + ), + ) +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRegistryTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRegistryTest.kt new file mode 100644 index 0000000..47b90e2 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRegistryTest.kt @@ -0,0 +1,125 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.api + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OneBotRegistryTest { + /** The actions `api/public.md` lists, in its order. */ + private val documented = listOf( + "send_private_msg", + "send_group_msg", + "send_msg", + "delete_msg", + "get_msg", + "get_forward_msg", + "send_like", + "set_group_kick", + "set_group_ban", + "set_group_anonymous_ban", + "set_group_whole_ban", + "set_group_admin", + "set_group_anonymous", + "set_group_card", + "set_group_name", + "set_group_leave", + "set_group_special_title", + "set_friend_add_request", + "set_group_add_request", + "get_login_info", + "get_stranger_info", + "get_friend_list", + "get_group_info", + "get_group_list", + "get_group_member_info", + "get_group_member_list", + "get_group_honor_info", + "get_cookies", + "get_csrf_token", + "get_credentials", + "get_record", + "get_image", + "can_send_image", + "can_send_record", + "get_status", + "get_version_info", + "set_restart", + "clean_cache", + ) + + @Test + fun `the registry lists every public action of the standard`() { + assertEquals(38, documented.size) + assertEquals(documented, OneBotRegistry.actions.map { it.name }) + assertTrue(OneBotRegistry.actions.none { it.hidden }) + assertTrue(OneBotRegistry.actions.none { it.name.startsWith(".") }) + } + + @Test + fun `the registry lists the hidden action apart from the public ones`() { + assertTrue(OneBotRegistry.QUICK_OPERATION.hidden) + assertEquals(OneBotCategory.HIDDEN, OneBotRegistry.QUICK_OPERATION.category) + assertEquals(".handle_quick_operation", OneBotRegistry.QUICK_OPERATION.name) + assertEquals(listOf(OneBotRegistry.QUICK_OPERATION), OneBotRegistry.hiddenActions) + assertEquals(OneBotRegistry.QUICK_OPERATION, OneBotRegistry.action(".handle_quick_operation")) + } + + @Test + fun `every public action resolves under its own name`() { + for (name in documented) { + val call = OneBotRegistry.resolve(name) + assertTrue(call is OneBotCall.Known, "$name is not known") + assertEquals(name, call.action) + assertEquals(OneBotCallSuffix.NONE, call.suffix) + assertTrue(call.isSupported) + } + } + + @Test + fun `every public action resolves under the async and the rate limited suffix`() { + for (name in documented) { + for (suffix in listOf(OneBotCallSuffix.ASYNC, OneBotCallSuffix.RATE_LIMITED)) { + val call = OneBotRegistry.resolve(name + suffix.suffix) + assertTrue(call is OneBotCall.Known, "${name + suffix.suffix} is not known") + assertEquals(name, call.definition.name) + assertEquals(suffix, call.suffix) + assertTrue(call.isSupported, "${name + suffix.suffix} is not accepted") + } + } + } + + @Test + fun `the hidden action takes no derived suffix`() { + val call = OneBotRegistry.resolve(".handle_quick_operation_async") + assertTrue(call is OneBotCall.Known) + assertEquals(".handle_quick_operation", call.definition.name) + assertEquals(OneBotCallSuffix.ASYNC, call.suffix) + assertFalse(call.isSupported) + } + + @Test + fun `an action of an implementation is kept as it was named`() { + val call = OneBotRegistry.resolve("napcat_get_group_at_all_remain") + assertTrue(call is OneBotCall.Unknown) + assertEquals("napcat_get_group_at_all_remain", call.action) + assertEquals("napcat_get_group_at_all_remain", call.name) + assertEquals(OneBotCallSuffix.NONE, call.suffix) + assertFalse(OneBotRegistry.knows(call.action)) + + val async = OneBotRegistry.resolve("napcat_get_group_at_all_remain_async") + assertTrue(async is OneBotCall.Unknown) + assertEquals("napcat_get_group_at_all_remain_async", async.action) + assertEquals("napcat_get_group_at_all_remain", async.name) + assertEquals(OneBotCallSuffix.ASYNC, async.suffix) + } + + @Test + fun `an action name resolves to the action the registry holds`() { + assertEquals(OneBotRegistry.QUICK_OPERATION, OneBotRegistry.action(".handle_quick_operation")) + assertEquals("send_msg", OneBotRegistry.action("send_msg")?.name) + assertNull(OneBotRegistry.action("send_msg_async")) + assertNull(OneBotRegistry.action("no_such_action")) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodecTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodecTest.kt new file mode 100644 index 0000000..e8f6e09 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodecTest.kt @@ -0,0 +1,394 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.event + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.SelfId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OneBotEventCodecTest { + @Test + fun `a private message event keeps its sender and its message`() { + val event = assertIs(OneBotEventCodec.decode(privateMessage())) + assertEquals(1515204254L, event.time) + assertEquals(SelfId("10001000"), event.selfId) + assertEquals(MessageId("12"), event.messageId) + assertEquals(UserId("12345678"), event.userId) + assertEquals(OneBotEvent.Message.FRIEND, event.subType) + assertEquals("hello [CQ:at,qq=1]", event.rawMessage) + assertEquals(listOf(OneBotSegment.Text("hello "), OneBotSegment.At("1")), event.message.segments) + assertEquals("someone", event.sender.nickname) + assertEquals(18L, event.sender.age) + assertFalse(event.sender.isAdmin) + } + + @Test + fun `a group message event keeps the group and who sent it`() { + val raw = base("message") { + put("message_type", "group") + put("sub_type", "normal") + put("message_id", 12) + put("group_id", 100100) + put("user_id", 12345678) + put("message", "hi") + put("raw_message", "hi") + putJsonObject("sender") { + put("nickname", "someone") + put("card", "the card") + put("role", "admin") + } + } + val event = assertIs(OneBotEventCodec.decode(raw)) + assertEquals(GroupId("100100"), event.groupId) + assertEquals("the card", event.sender.card) + assertTrue(event.sender.isAdmin) + assertFalse(event.sender.isOwner) + assertNull(event.anonymous) + assertFalse(event.isAnonymous) + } + + @Test + fun `an anonymous group message keeps the flag the ban API takes`() { + val raw = base("message") { + put("message_type", "group") + put("sub_type", "anonymous") + put("group_id", 100100) + put("user_id", 12345678) + put("message", "hi") + putJsonObject("anonymous") { + put("id", 1) + put("name", "no one") + put("flag", "abc") + } + } + val event = assertIs(OneBotEventCodec.decode(raw)) + assertTrue(event.isAnonymous) + assertEquals("abc", event.anonymous?.flag) + assertEquals("no one", event.anonymous?.name) + } + + @Test + fun `an unknown field of an implementation stays in the raw event`() { + val raw = base("message") { + put("message_type", "private") + put("sub_type", "friend") + put("message_id", 12) + put("user_id", 12345678) + put("message", "hi") + put("napcat_extra", "kept") + } + val event = OneBotEventCodec.decode(raw) + assertEquals("kept", event.raw["napcat_extra"]?.let { assertIs(it).content }) + assertEquals(raw, event.raw) + } + + @Test + fun `every notice the standard lists decodes to its own type`() { + val group = { block: kotlinx.serialization.json.JsonObjectBuilder.() -> Unit -> + base("notice") { + put("group_id", 100100) + put("user_id", 12345678) + block() + } + } + + val upload = + group { + put("notice_type", "group_upload") + putJsonObject("file") { + put("id", "f") + put("name", "n") + put("size", 3) + put("busid", 1) + } + } + val uploadEvent = assertIs(OneBotEventCodec.decode(upload)) + assertEquals("f", uploadEvent.file.id) + assertEquals(3L, uploadEvent.file.size) + + val admin = group { + put("notice_type", "group_admin") + put("sub_type", "set") + } + assertTrue(assertIs(OneBotEventCodec.decode(admin)).isSet) + + val decrease = group { + put("notice_type", "group_decrease") + put("sub_type", "kick") + put("operator_id", 1) + } + assertEquals("kick", assertIs(OneBotEventCodec.decode(decrease)).subType) + + val increase = group { + put("notice_type", "group_increase") + put("sub_type", "approve") + put("operator_id", 1) + } + assertEquals("approve", assertIs(OneBotEventCodec.decode(increase)).subType) + + val ban = + group { + put("notice_type", "group_ban") + put("sub_type", "ban") + put("operator_id", 1) + put("duration", 60) + } + val banEvent = assertIs(OneBotEventCodec.decode(ban)) + assertTrue(banEvent.isBan) + assertEquals(60L, banEvent.duration) + + val lift = group { + put("notice_type", "group_ban") + put("sub_type", "lift_ban") + put("operator_id", 1) + } + assertFalse(assertIs(OneBotEventCodec.decode(lift)).isBan) + + val friend = base("notice") { + put("notice_type", "friend_add") + put("user_id", 1) + } + assertEquals(UserId("1"), assertIs(OneBotEventCodec.decode(friend)).userId) + + val recall = group { + put("notice_type", "group_recall") + put("operator_id", 1) + put("message_id", 7) + } + assertEquals( + MessageId("7"), + assertIs(OneBotEventCodec.decode(recall)).messageId, + ) + + val friendRecall = + base("notice") { + put("notice_type", "friend_recall") + put("user_id", 1) + put("message_id", 7) + } + assertEquals( + MessageId("7"), + assertIs(OneBotEventCodec.decode(friendRecall)).messageId, + ) + + val poke = group { + put("notice_type", "notify") + put("sub_type", "poke") + put("target_id", 2) + } + val pokeEvent = assertIs(OneBotEventCodec.decode(poke)) + assertEquals(OneBotEvent.Notice.POKE, pokeEvent.subType) + assertEquals(UserId("2"), pokeEvent.targetId) + assertNull(pokeEvent.honorType) + + val king = group { + put("notice_type", "notify") + put("sub_type", "lucky_king") + put("target_id", 2) + } + assertEquals( + OneBotEvent.Notice.LUCKY_KING, + assertIs(OneBotEventCodec.decode(king)).subType, + ) + + val honor = group { + put("notice_type", "notify") + put("sub_type", "honor") + put("honor_type", "talkative") + } + assertEquals("talkative", assertIs(OneBotEventCodec.decode(honor)).honorType) + + val other = group { + put("notice_type", "napcat_notice") + put("sub_type", "x") + } + val otherEvent = assertIs(OneBotEventCodec.decode(other)) + assertEquals("napcat_notice", otherEvent.noticeType) + assertEquals("x", otherEvent.subType) + } + + @Test + fun `every request the standard lists decodes to its own type`() { + val friend = base("request") { + put("request_type", "friend") + put("user_id", 1) + put("comment", "hi") + put("flag", "f") + } + val friendEvent = assertIs(OneBotEventCodec.decode(friend)) + assertEquals("f", friendEvent.flag) + assertEquals("hi", friendEvent.comment) + assertEquals(setOf("approve", "remark", "reason"), friendEvent.quickOperations) + + val add = base("request") { + put("request_type", "group") + put("sub_type", "add") + put("group_id", 100100) + put("user_id", 1) + put("flag", "f") + } + val addEvent = assertIs(OneBotEventCodec.decode(add)) + assertEquals(GroupId("100100"), addEvent.groupId) + assertEquals(OneBotEvent.Request.ADD, addEvent.subType) + + val invite = + base("request") { + put("request_type", "group") + put("sub_type", "invite") + put("group_id", 1) + put("user_id", 1) + } + assertEquals( + OneBotEvent.Request.INVITE, + assertIs(OneBotEventCodec.decode(invite)).subType, + ) + + val other = base("request") { + put("request_type", "napcat_request") + put("user_id", 1) + put("flag", "f") + } + assertEquals("napcat_request", assertIs(OneBotEventCodec.decode(other)).requestType) + } + + @Test + fun `every meta event the standard lists decodes to its own type`() { + val lifecycle = base("meta_event") { + put("meta_event_type", "lifecycle") + put("sub_type", "connect") + } + assertEquals( + OneBotEvent.Meta.CONNECT, + assertIs(OneBotEventCodec.decode(lifecycle)).subType, + ) + + val heartbeat = base("meta_event") { + put("meta_event_type", "heartbeat") + putJsonObject("status") { + put("online", true) + put("good", true) + } + put("interval", 15000) + } + val heartbeatEvent = assertIs(OneBotEventCodec.decode(heartbeat)) + assertEquals(15000L, heartbeatEvent.interval) + assertEquals(JsonPrimitive(true), heartbeatEvent.status["online"]) + + val other = base("meta_event") { put("meta_event_type", "napcat_meta") } + assertEquals("napcat_meta", assertIs(OneBotEventCodec.decode(other)).metaEventType) + } + + @Test + fun `an event of a kind this version does not model keeps its JSON`() { + val raw = base("napcat_post") { put("whatever", 1) } + val event = assertIs(OneBotEventCodec.decode(raw)) + assertEquals("napcat_post", event.postType) + assertEquals(raw, event.raw) + } + + @Test + fun `a message event of a type this version does not model stays unknown`() { + val raw = base("message") { + put("message_type", "channel") + put("message", "hi") + } + val event = assertIs(OneBotEventCodec.decode(raw)) + // `postType` is the top-level `post_type`, not the subtype this version cannot read, which stays in the raw + // JSON: a consumer that asks what kind of event this is has to be told that it is a message. + assertEquals("message", event.postType) + assertEquals("channel", (raw["message_type"] as? JsonPrimitive)?.content) + assertEquals(raw, event.raw) + } + + @Test + fun `the quick operations of a group message are the ones the standard lists`() { + val raw = + base("message") { + put("message_type", "group") + put("group_id", 1) + put("user_id", 1) + put("message", "hi") + } + val event = assertIs(OneBotEventCodec.decode(raw)) + assertEquals( + setOf("reply", "auto_escape", "at_sender", "delete", "kick", "ban", "ban_duration"), + event.quickOperations, + ) + } + + @Test + fun `a string message and an array message decode the same way`() { + val asString = + base("message") { + put("message_type", "private") + put("user_id", 1) + put("message", "hi [CQ:at,qq=2]") + } + val asArray = base("message") { + put("message_type", "private") + put("user_id", 1) + putJsonArray("message") { + add( + buildJsonObject { + put("type", "text") + putJsonObject("data") { put("text", "hi ") } + }, + ) + add( + buildJsonObject { + put("type", "at") + putJsonObject("data") { put("qq", "2") } + }, + ) + } + } + val expected = listOf(OneBotSegment.Text("hi "), OneBotSegment.At("2")) + assertEquals( + expected, + assertIs(OneBotEventCodec.decode(asString)).message.segments, + ) + assertEquals( + expected, + assertIs(OneBotEventCodec.decode(asArray)).message.segments, + ) + assertEquals(OneBotMessage.StringValue("hi [CQ:at,qq=2]").segments, expected) + } + + private fun base(postType: String, block: kotlinx.serialization.json.JsonObjectBuilder.() -> Unit): JsonObject = + buildJsonObject { + put("time", 1515204254L) + put("self_id", 10001000L) + put("post_type", postType) + block() + } + + private fun privateMessage(): JsonObject = base("message") { + put("message_type", "private") + put("sub_type", "friend") + put("message_id", 12) + put("user_id", 12345678) + put("message", "hello [CQ:at,qq=1]") + put("raw_message", "hello [CQ:at,qq=1]") + put("font", 456) + putJsonObject("sender") { + put("user_id", 12345678) + put("nickname", "someone") + put("sex", "male") + put("age", 18) + } + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCodeTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCodeTest.kt new file mode 100644 index 0000000..35768e4 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCodeTest.kt @@ -0,0 +1,273 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.message + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class CqCodeTest { + @Test + fun `plain text is one segment and is escaped`() { + assertEquals(listOf(OneBotSegment.Text("hello")), CqCode.decode("hello")) + assertEquals(listOf(OneBotSegment.Text("a[b]c")), CqCode.decode("a[b]c")) + // A comma means nothing in the text outside a code, so the entity is only a comma inside a parameter: plain + // text keeps `,` as the characters it is. + assertEquals(listOf(OneBotSegment.Text("x,y")), CqCode.decode("x,y")) + assertEquals(listOf(OneBotSegment.Text("x,y")), CqCode.decode("x,y")) + assertEquals(listOf(OneBotSegment.Text("a&b")), CqCode.decode("a&b")) + assertEquals(emptyList(), CqCode.decode("")) + } + + @Test + fun `a code becomes its segment and the text around it becomes text segments`() { + assertEquals( + listOf( + OneBotSegment.Text("hi "), + OneBotSegment.At("10001000"), + OneBotSegment.Text(" there"), + ), + CqCode.decode("hi [CQ:at,qq=10001000] there"), + ) + } + + @Test + fun `a code is escaped when it is written again`() { + assertEquals( + "hi [CQ:at,qq=10001000] there", + CqCode.encode(CqCode.decode("hi [CQ:at,qq=10001000] there")), + ) + assertEquals("[CQ:at,qq=all]", CqCode.encode(OneBotSegment.At(OneBotSegment.At.ALL))) + assertEquals("[CQ:face,id=123]", CqCode.encode(OneBotSegment.Face("123"))) + assertEquals("[CQ:reply,id=123456]", CqCode.encode(OneBotSegment.Reply(MessageId("123456")))) + } + + @Test + fun `a text keeps the CQ code entities in it`() { + val text = "look [CQ:face,id=1] and [CQ:not a code], ok&" + val decoded = CqCode.decode(text) + assertEquals(OneBotSegment.Text("look "), decoded[0]) + assertEquals(OneBotSegment.Face("1"), decoded[1]) + assertEquals(" and [CQ:not a code], ok&", assertIs(decoded[2]).text) + // A comma is not an entity of plain text, so writing this text escapes only its `&`, and reading that back + // returns the text the segments hold. + assertEquals(decoded, CqCode.decode(CqCode.encode(decoded))) + } + + @Test + fun `a code that names a type this version does not know keeps its parameters`() { + val decoded = CqCode.decode("[CQ:napcat_thing,key=value]") + val unknown = assertIs(decoded.single()) + assertEquals("napcat_thing", unknown.type) + assertEquals("value", unknown.data["key"]?.jsonPrimitive?.content) + assertEquals("[CQ:napcat_thing,key=value]", CqCode.encode(unknown)) + } + + @Test + fun `an unterminated code stays plain text`() { + assertEquals(listOf(OneBotSegment.Text("[CQ:at,qq=1")), CqCode.decode("[CQ:at,qq=1")) + } + + @Test + fun `the segments of the standard survive a CQ round trip`() { + for (type in OneBotSegment.KNOWN_TYPES) { + if (type == OneBotSegment.TEXT) continue + val segment = CqCode.decode(sample(type)).single() + assertEquals(type, segment.type, "$type did not decode to itself") + val encoded = CqCode.encode(segment) + val again = CqCode.decode(encoded).single() + assertEquals(segment, again, "$type did not survive its CQ code '$encoded'") + } + } + + @Test + fun `every segment type of the standard has a sample, except the plain text one`() { + val expected = OneBotSegment.KNOWN_TYPES.toSet() - OneBotSegment.TEXT + assertEquals(expected, OneBotSegment.KNOWN_TYPES.filter { sample(it).isNotEmpty() }.toSet()) + } + + private fun sample(type: String): String = when (type) { + OneBotSegment.TEXT -> "" + + OneBotSegment.FACE -> "[CQ:face,id=123]" + + OneBotSegment.IMAGE -> + "[CQ:image,file=1.jpg,type=flash,url=http://example.test/1.jpg,cache=1,proxy=0,timeout=5]" + + OneBotSegment.RECORD -> "[CQ:record,file=1.mp3,magic=1,url=http://example.test/1.mp3]" + + OneBotSegment.VIDEO -> "[CQ:video,file=1.mp4,url=http://example.test/1.mp4]" + + OneBotSegment.AT -> "[CQ:at,qq=10001000]" + + OneBotSegment.RPS -> "[CQ:rps]" + + OneBotSegment.DICE -> "[CQ:dice]" + + OneBotSegment.SHAKE -> "[CQ:shake]" + + OneBotSegment.POKE -> "[CQ:poke,type=126,id=2003,name=hello]" + + OneBotSegment.ANONYMOUS -> "[CQ:anonymous,ignore=1]" + + OneBotSegment.SHARE -> "[CQ:share,url=http://baidu.com,title=baidu]" + + OneBotSegment.CONTACT -> "[CQ:contact,type=qq,id=10001000]" + + OneBotSegment.LOCATION -> "[CQ:location,lat=39.9,lon=116.3]" + + OneBotSegment.MUSIC -> "[CQ:music,type=163,id=28949129]" + + OneBotSegment.REPLY -> "[CQ:reply,id=123456]" + + OneBotSegment.FORWARD -> "[CQ:forward,id=abc]" + + OneBotSegment.NODE -> "[CQ:node,user_id=10001000,nickname=someone,content=hello]" + + OneBotSegment.XML -> "[CQ:xml,data=body]" + + OneBotSegment.JSON -> "[CQ:json,data=body]" + + else -> "" + } + + @Test + fun `the codec reads the three shapes of a message`() { + val text = JsonPrimitive("hi [CQ:at,qq=1]") + val array = JsonArray( + listOf( + buildJsonObject { + put("type", "text") + putJsonObject("data") { put("text", "hi ") } + }, + buildJsonObject { + put("type", "at") + putJsonObject("data") { put("qq", "1") } + }, + ), + ) + val single = buildJsonObject { + put("type", "at") + putJsonObject("data") { put("qq", "1") } + } + + val string = assertIs(OneBotSegmentCodec.decodeMessage(text)) + assertEquals(listOf(OneBotSegment.Text("hi "), OneBotSegment.At("1")), string.segments) + + val list = assertIs(OneBotSegmentCodec.decodeMessage(array)) + assertEquals(listOf(OneBotSegment.Text("hi "), OneBotSegment.At("1")), list.segments) + + val one = assertIs(OneBotSegmentCodec.decodeMessage(single)) + assertEquals(OneBotSegment.At("1"), one.segment) + } + + @Test + fun `an optional parameter of a segment is left out when it is absent`() { + // The standard's request contract has no room for a parameter that is present and null: an implementation + // reading `type` as a string meets a null, and one reading `timeout` as a number does too. + val written = OneBotSegmentCodec.encodeSegment(OneBotSegment.Image(file = "1.jpg")) + + val data = assertIs(written["data"]) + assertEquals(setOf("file"), data.keys) + } + + @Test + fun `a segment keeps its data and writes type once`() { + val element = buildJsonObject { + put("type", "image") + putJsonObject("data") { + put("file", "1.jpg") + put("type", "flash") + put("url", "http://example.test/1.jpg") + } + } + val segment = assertIs(OneBotSegmentCodec.decodeSegment(element)) + assertEquals("1.jpg", segment.file) + assertEquals("flash", segment.subtype) + + val written = OneBotSegmentCodec.encodeSegment(segment) + assertEquals("image", written["type"]?.jsonPrimitive?.content) + val data = assertIs(written["data"]) + assertEquals("flash", data["type"]?.jsonPrimitive?.content) + assertEquals("1.jpg", data["file"]?.jsonPrimitive?.content) + } + + @Test + fun `a segment of an unknown type keeps its whole data object`() { + val element = buildJsonObject { + put("type", "napcat_thing") + putJsonObject("data") { + put("key", "value") + putJsonObject("nested") { put("deep", 1) } + } + } + val unknown = assertIs(OneBotSegmentCodec.decodeSegment(element)) + assertEquals("napcat_thing", unknown.type) + assertEquals(element, OneBotSegmentCodec.encodeSegment(unknown)) + + val message = OneBotSegmentCodec.decodeMessage(element) + assertEquals(listOf(unknown), message.segments) + } + + @Test + fun `an image message writes its segments as an array`() { + val message = OneBotMessage.text("hi") + val written = OneBotSegmentCodec.encodeMessageArray(message) + assertEquals(1, written.size) + assertTrue(assertIs(written[0]).containsKey("type")) + } + + @Test + fun `a string message stays a string in the shape it is written in`() { + val message = OneBotMessage.StringValue("[CQ:at,qq=1]") + assertEquals(JsonPrimitive("[CQ:at,qq=1]"), OneBotSegmentCodec.encodeMessage(message)) + assertEquals(listOf(OneBotSegment.At("1")), message.segments) + } + + @Test + fun `a node of a forwarded message keeps the message inside it`() { + val element = buildJsonObject { + put("type", "node") + putJsonObject("data") { + put("user_id", 10001000) + put("nickname", "someone") + put( + "content", + JsonArray( + listOf( + buildJsonObject { + put("type", "text") + putJsonObject("data") { put("text", "hi") } + }, + ), + ), + ) + } + } + val node = assertIs(OneBotSegmentCodec.decodeSegment(element)) + assertEquals(UserId("10001000"), node.userId) + assertEquals("someone", node.nickname) + assertEquals(listOf(OneBotSegment.Text("hi")), node.content?.segments) + } + + @Test + fun `a number of the platform is read as a string and written as a number`() { + val element = buildJsonObject { + put("type", "at") + putJsonObject("data") { put("qq", 10001000) } + } + val at = assertIs(OneBotSegmentCodec.decodeSegment(element)) + assertEquals("10001000", at.userId) + assertEquals(element, OneBotSegmentCodec.encodeSegment(at)) + assertEquals(GroupId("1").number, 1L) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiverTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiverTest.kt new file mode 100644 index 0000000..e3b0a84 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiverTest.kt @@ -0,0 +1,237 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.foedusprogramme.alexandrite.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.auth.Value +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.sdk.config.Secret +import java.net.InetAddress +import java.net.Socket +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class OneBotHttpPostReceiverTest { + private val secret = Value.of(Secret("signing-key")) + private val receiver = OneBotHttpPostReceiver(settings()).also { it.start() } + private val client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build() + + @AfterTest + fun close() { + receiver.close() + } + + @Test + fun `a signed report is taken and answered with no content`() { + val body = privateMessage() + val response = post(body, OneBotAuth.signature(secret, body), "/") + assertEquals(204, response.statusCode()) + assertEquals("", response.body()) + val event = runBlocking { withTimeout(5_000) { first() } } + assertEquals(1515204254L, event.time) + assertIs(event) + } + + @Test + fun `a report without a signature is refused and nothing is reported`() { + val response = post(privateMessage(), null, "/") + assertEquals(403, response.statusCode()) + assertTrue(response.body().contains("signature")) + assertEquals(0, receiver.reported()) + } + + @Test + fun `a report whose body changed after signing is refused`() { + val signature = OneBotAuth.signature(secret, privateMessage()) + val response = post("""{"time":1,"post_type":"meta_event","meta_event_type":"lifecycle"}""", signature, "/") + assertEquals(403, response.statusCode()) + assertEquals(0, receiver.reported()) + } + + @Test + fun `a report on another path is not this instance's`() { + val body = privateMessage() + val response = post(body, OneBotAuth.signature(secret, body), "/other") + assertEquals(404, response.statusCode()) + assertEquals(0, receiver.reported()) + } + + @Test + fun `a report that is no event is refused`() { + val body = "{ not json" + val response = post(body, OneBotAuth.signature(secret, body), "/") + assertEquals(400, response.statusCode()) + assertEquals(0, receiver.reported()) + } + + @Test + fun `a report that names no account is refused when the instance names one`() { + val body = privateMessage().replace("\"self_id\": 10001000,", "\"self_id\": 0,") + val response = postTo(receiver.port, body, OneBotAuth.signature(secret, body), "/", selfId = null) + + assertEquals(403, response.statusCode()) + assertEquals(0, receiver.reported()) + } + + @Test + fun `a report that names its account only in the header is taken`() { + // The header names of a request are folded to lower case when its head is read, so a lookup by the spelling of + // the standard finds nothing. The body therefore names no account: were the header ignored, this report would + // be refused, so being taken is the header path working and nothing else. + val body = privateMessage().replace("\"self_id\": 10001000,", "\"self_id\": 0,") + val response = post(body, OneBotAuth.signature(secret, body), "/") + + assertEquals(204, response.statusCode()) + assertEquals(1, receiver.reported()) + } + + @Test + fun `a report of an account is taken when the instance names none`() { + val open = OneBotHttpPostReceiver(settings(selfId = null)).also { it.start() } + try { + val body = privateMessage() + val response = postTo(open.port, body, OneBotAuth.signature(secret, body), "/", selfId = null) + + assertEquals(204, response.statusCode()) + assertEquals(1, open.reported()) + } finally { + open.close() + } + } + + @Test + fun `a report of another account is refused when the instance names one`() { + val named = OneBotHttpPostReceiver(settings(selfId = "10001000")).also { it.start() } + try { + val body = privateMessage().replace("\"self_id\": 10001000", "\"self_id\": 20002000") + val response = postTo( + named.port, + body, + OneBotAuth.signature(secret, body), + "/", + selfId = "20002000", + ) + assertEquals(403, response.statusCode()) + assertEquals(0, named.reported()) + } finally { + named.close() + } + } + + @Test + fun `a report never answers with the secret or the token`() { + val response = post(privateMessage(), "sha1=00", "/") + assertEquals(403, response.statusCode()) + assertFalse(response.body().contains("signing-key")) + assertFalse(response.body().contains("s3cr3t-token")) + } + + @Test + fun `a report larger than the limit is refused with its status rather than closed`() { + // The declared length is over the limit, so the body is never read. The implementation is still told why, + // which is what makes the refusal something its author can act on rather than a connection that dropped. + val body = privateMessage().padEnd(8 * 1024 * 1024 + 1, ' ') + val response = post(body, OneBotAuth.signature(secret, body), "/") + + assertEquals(413, response.statusCode()) + assertEquals(0, receiver.reported()) + } + + private fun OneBotHttpPostReceiver.reported(): Int = reportedCount + + @Test + fun `a report that arrives while nothing reads is dropped and counted`() { + // The fixture holds room for sixteen events and reads none of them, so a listener that keeps taking reports + // has to say how many it could not keep. A count that stayed at zero here would be the silence the class + // promises not to keep. + val body = privateMessage() + val signature = OneBotAuth.signature(secret, body) + repeat(20) { post(body, signature, "/") } + + assertEquals(20, receiver.reported(), "every report was taken") + assertEquals(4L, receiver.droppedEvents, "the reports past the room of the queue were dropped") + } + + @Test + fun `a report that is sent a byte at a time is cut off at the deadline`() { + // The bytes arrive faster than the idle timeout, so an idle timeout alone would read this for as long as the + // client keeps sending. The whole request carries a deadline, and past it its connection is closed. + val started = System.nanoTime() + Socket(InetAddress.getLoopbackAddress(), receiver.port).use { socket -> + val writer = socket.getOutputStream() + writer.write("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 100000\r\n\r\n".toByteArray()) + writer.flush() + runCatching { + // Sent for longer than the deadline, so that a listener without one would read it to the end and + // this case would fail rather than pass for the wrong reason. + repeat(120) { + Thread.sleep(100) + writer.write('x'.code) + writer.flush() + } + } + } + + val elapsed = (System.nanoTime() - started) / 1_000_000 + assertTrue(elapsed < 8_000, "the listener read a slow report for ${elapsed}ms") + assertEquals(0, receiver.reported()) + } + + private fun post(body: String, signature: String?, path: String): HttpResponse = + postTo(receiver.port, body, signature, path, selfId = "10001000") + + private fun postTo( + port: Int, + body: String, + signature: String?, + path: String, + selfId: String?, + ): HttpResponse { + val builder = HttpRequest.newBuilder(URI.create("http://127.0.0.1:$port$path")) + .header("Content-Type", "application/json") + .apply { selfId?.let { header(OneBotAuth.SELF_ID, it) } } + .POST(HttpRequest.BodyPublishers.ofString(body)) + signature?.let { builder.header(OneBotAuth.SIGNATURE, it) } + return client.send(builder.build(), HttpResponse.BodyHandlers.ofString()) + } + private suspend fun first(): OneBotEvent = receiver.events.first() + + private fun settings(selfId: String? = "10001000"): OneBotSettings = OneBotSettings( + endpoint = null, + listenHost = "127.0.0.1", + listenPort = 0, + path = "/", + token = Value.of(Secret("s3cr3t-token")), + secret = secret, + selfId = selfId, + connectTimeoutMillis = 5_000, + firstByteTimeoutMillis = 5_000, + idleTimeoutMillis = 5_000, + reconnectIntervalMillis = 100, + eventCapacity = 16, + ) + + private fun privateMessage(): String = """ + { + "time": 1515204254, + "self_id": 10001000, + "post_type": "message", + "message_type": "private", + "sub_type": "friend", + "message_id": 12, + "user_id": 12345678, + "message": "hello", + "raw_message": "hello", + "sender": {"nickname": "someone"} + } + """.trimIndent() +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/WsTransportTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/WsTransportTest.kt new file mode 100644 index 0000000..4f297d8 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/WsTransportTest.kt @@ -0,0 +1,256 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.foedusprogramme.alexandrite.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.auth.Value +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.sdk.config.Secret +import org.java_websocket.WebSocket +import org.java_websocket.client.WebSocketClient +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.handshake.ServerHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.net.URI +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The two Websocket transports, each against a peer built from the other side of the library. */ +class WsTransportTest { + private val token = Value.of(Secret("s3cr3t-token")) + + @Test + fun `a forward connection reads the answer to its call and the event beside it`() { + val peer = ImplementationServer() + val port = peer.startAndPort() + val connection = OneBotForwardWebSocket(settings("ws://127.0.0.1:" + port)) + try { + runBlocking { + connection.start() + val answer = connection.send("get_login_info", JsonObject(emptyMap())) + assertEquals( + "10001000", + answer["data"]?.let { + it as? JsonObject + }?.get("user_id")?.jsonPrimitive?.content, + "answer was " + answer, + ) + assertTrue(peer.called().contains("get_login_info"), "the peer was not called") + val event = withTimeout(5_000) { connection.events.first() } + assertIs(event) + } + } finally { + connection.close() + peer.stop() + } + } + + @Test + fun `a forward connection brings the token of its instance`() { + val peer = ImplementationServer() + val port = peer.startAndPort() + val connection = OneBotForwardWebSocket(settings("ws://127.0.0.1:" + port)) + try { + runBlocking { + connection.start() + assertEquals("Bearer s3cr3t-token", peer.offered) + } + } finally { + connection.close() + peer.stop() + } + } + + @Test + fun `a reverse connection takes the implementation that dials it`() { + val connection = OneBotReverseWebSocket(settings(null, port = 0)) + try { + runBlocking { + connection.start() + val peer = DialingImplementation(URI("ws://127.0.0.1:" + connection.port)) + assertTrue(peer.connectBlocking(5, TimeUnit.SECONDS), "the implementation did not connect") + assertTrue(connection.awaitPeer(), "not taken; state=" + connection.state.value) + val answer = connection.send("get_version_info", JsonObject(emptyMap())) + assertEquals( + "v11", + answer["data"]?.let { it as? JsonObject }?.get("protocol_version")?.jsonPrimitive?.content, + "answer was " + answer, + ) + peer.close() + } + } finally { + connection.close() + } + } + + @Test + fun `a reverse connection ends a waiting call when the implementation leaves`() { + val connection = OneBotReverseWebSocket(settings(null, port = 0)) + try { + runBlocking { + connection.start() + val peer = SilentImplementation(URI("ws://127.0.0.1:" + connection.port)) + assertTrue(peer.connectBlocking(5, TimeUnit.SECONDS), "the implementation did not connect") + assertTrue(connection.awaitPeer(), "not taken; state=" + connection.state.value) + + // The call goes out and is never answered, and then the implementation leaves. Its caller has to hear + // about that rather than wait for a timeout that would blame a slow implementation for a lost one. + val call = async { runCatching { connection.send("get_version_info", JsonObject(emptyMap())) } } + peer.awaitCall() + peer.close() + val ended = withTimeout(2_000) { call.await() } + assertTrue(ended.isFailure, "the call was answered by a connection that had left") + } + } finally { + connection.close() + } + } + + @Test + fun `a reverse listener serves the roles that carry calls and events both`() { + assertNull(refusedClientRole(null), "an implementation that names no role must keep working") + assertNull(refusedClientRole("Universal"), "universal carries the calls and the events both") + assertNull(refusedClientRole("Service"), "service does too") + assertNotNull(refusedClientRole("API"), "an API client sends no events this instance could take") + assertNotNull(refusedClientRole("Event"), "an Event client answers no call this instance would make") + } + + private fun settings(endpoint: String?, port: Int = 0): OneBotSettings = OneBotSettings( + endpoint = endpoint, + listenHost = "127.0.0.1", + listenPort = port, + path = "/", + token = token, + secret = null, + selfId = null, + connectTimeoutMillis = 5_000, + firstByteTimeoutMillis = 5_000, + idleTimeoutMillis = 5_000, + reconnectIntervalMillis = 100, + eventCapacity = 16, + ) + + /** An implementation that serves the API and pushes one event, as the standard's server does. */ + private class ImplementationServer : WebSocketServer(InetSocketAddress("127.0.0.1", 0)) { + private val ready = CountDownLatch(1) + private val calls = CountDownLatch(1) + private val received = CopyOnWriteArrayList() + + @Volatile + var offered: String? = null + private set + + override fun onStart() { + ready.countDown() + } + + override fun onOpen(connection: WebSocket, handshake: ClientHandshake) { + offered = handshake.getFieldValue(OneBotAuth.AUTHORIZATION) + } + + override fun onMessage(connection: WebSocket, message: String) { + received += message + calls.countDown() + val action = message.substringAfter("\"action\":\"", "").substringBefore('"') + val echo = message.substringAfter("\"echo\":\"", "").substringBefore('"') + val data = if (action == "get_login_info") "{\"user_id\":10001000}" else "{\"protocol_version\":\"v11\"}" + connection.send("{\"status\":\"ok\",\"retcode\":0,\"data\":" + data + ",\"echo\":\"" + echo + "\"}") + if (action == "get_login_info") connection.send(LIFECYCLE) + } + + override fun onClose(connection: WebSocket, code: Int, reason: String, remote: Boolean) = Unit + + override fun onError(connection: WebSocket?, ex: Exception) = Unit + + /** Starts listening and returns the port this peer took. */ + fun startAndPort(): Int { + start() + check(ready.await(5, TimeUnit.SECONDS)) { "the peer did not start" } + return port + } + + /** Waits for the first call and returns it. */ + fun called(): String { + check(calls.await(5, TimeUnit.SECONDS)) { "the peer was called by nobody" } + return received.first() + } + } + + /** An implementation that dials Alexandrite, as the standard's universal client does. */ + private class DialingImplementation(uri: URI) : + WebSocketClient( + uri, + mapOf( + OneBotAuth.AUTHORIZATION to OneBotAuth.bearer(Value.secretOf("s3cr3t-token")), + OneBotAuth.SELF_ID to "10001000", + OneBotAuth.CLIENT_ROLE to "Universal", + ), + ) { + @Volatile + var offered: String? = null + private set + + override fun onOpen(handshake: ServerHandshake) { + offered = handshake.getFieldValue(OneBotAuth.AUTHORIZATION) + } + + override fun onMessage(message: String) { + val echo = message.substringAfter("\"echo\":\"", "").substringBefore('"') + if (echo.isEmpty()) return + send("{\"status\":\"ok\",\"retcode\":0,\"data\":{\"protocol_version\":\"v11\"},\"echo\":\"" + echo + "\"}") + } + + override fun onClose(code: Int, reason: String, remote: Boolean) = Unit + + override fun onError(ex: Exception) = Unit + } + + /** An implementation that dials in, takes a call and never answers it, so that a caller is left waiting. */ + private class SilentImplementation(uri: URI) : + WebSocketClient( + uri, + mapOf( + OneBotAuth.AUTHORIZATION to OneBotAuth.bearer(Value.secretOf("s3cr3t-token")), + OneBotAuth.SELF_ID to "10001000", + OneBotAuth.CLIENT_ROLE to "Universal", + ), + ) { + private val called = CountDownLatch(1) + + override fun onOpen(handshake: ServerHandshake) = Unit + + override fun onMessage(message: String) { + if (message.contains("\"echo\"")) called.countDown() + } + + override fun onClose(code: Int, reason: String, remote: Boolean) = Unit + + override fun onError(ex: Exception) = Unit + + /** Waits until a call has arrived, and returns whether one did. */ + fun awaitCall(): Boolean = called.await(5, TimeUnit.SECONDS) + } + + private companion object { + const val LIFECYCLE = + "{\"time\":1,\"self_id\":10001000,\"post_type\":\"meta_event\"," + + "\"meta_event_type\":\"lifecycle\",\"sub_type\":\"connect\"}" + + const val EVENT = + "{\"time\":1,\"self_id\":10001000,\"post_type\":\"meta_event\"," + + "\"meta_event_type\":\"heartbeat\",\"interval\":15000}" + } +}