From fc18ad8f425a2655fca63f5a274367146989b973 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 01:31:17 +0800 Subject: [PATCH 01/37] onebot: Add the OneBot v11 protocol and transports Reads and writes the v11 event, action, message and result shapes, and serves forward WebSocket, reverse WebSocket and HTTP POST transports with token auth. --- gradle/libs.versions.toml | 2 + libraries/channels/onebot/build.gradle.kts | 16 + .../channel/onebot/OneBotPlugin.kt | 7 + .../channel/onebot/api/OneBotApi.kt | 168 ++++++ .../channel/onebot/api/OneBotApiImpl.kt | 231 +++++++++ .../channel/onebot/auth/OneBotAuth.kt | 116 +++++ .../channel/onebot/config/OneBotConfig.kt | 174 +++++++ .../channel/onebot/i18n/TextKeys.kt | 150 ++++++ .../channel/onebot/protocol/OneBotIds.kt | 119 +++++ .../channel/onebot/protocol/OneBotStatus.kt | 80 +++ .../onebot/protocol/api/OneBotAction.kt | 82 +++ .../onebot/protocol/api/OneBotApiTypes.kt | 170 ++++++ .../onebot/protocol/api/OneBotRegistry.kt | 109 ++++ .../onebot/protocol/api/OneBotRequests.kt | 443 ++++++++++++++++ .../onebot/protocol/event/OneBotEvent.kt | 490 ++++++++++++++++++ .../onebot/protocol/event/OneBotEventCodec.kt | 270 ++++++++++ .../channel/onebot/protocol/message/CqCode.kt | 363 +++++++++++++ .../onebot/protocol/message/OneBotMessage.kt | 49 ++ .../onebot/protocol/message/OneBotSegment.kt | 261 ++++++++++ .../protocol/message/OneBotSegmentCodec.kt | 292 +++++++++++ .../onebot/protocol/result/OneBotResult.kt | 139 +++++ .../channel/onebot/transport/Connection.kt | 127 +++++ .../transport/OneBotForwardWebSocket.kt | 183 +++++++ .../channel/onebot/transport/OneBotHttpApi.kt | 193 +++++++ .../transport/OneBotHttpPostReceiver.kt | 351 +++++++++++++ .../transport/OneBotReverseWebSocket.kt | 235 +++++++++ .../onebot/transport/OneBotSettings.kt | 48 ++ .../channel/onebot/transport/OneBotWire.kt | 33 ++ 28 files changed, 4901 insertions(+) create mode 100644 libraries/channels/onebot/build.gradle.kts create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotPlugin.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApi.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApiImpl.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/auth/OneBotAuth.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/config/OneBotConfig.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/i18n/TextKeys.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/OneBotIds.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/OneBotStatus.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotAction.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotApiTypes.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRegistry.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRequests.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEvent.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodec.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCode.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotMessage.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegment.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegmentCodec.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/result/OneBotResult.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/Connection.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotForwardWebSocket.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpApi.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiver.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotReverseWebSocket.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotSettings.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotWire.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fff99315..a0ced116 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 00000000..d7e977e3 --- /dev/null +++ b/libraries/channels/onebot/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("alexandrite.kotlin-library") + id("alexandrite.kotlin-serialization") +} + +dependencies { + implementation(project(":libraries:channel-common")) + 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")) + testImplementation(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 00000000..7a431d08 --- /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 00000000..4d595286 --- /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 00000000..556cceb1 --- /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 00000000..e140772c --- /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/config/OneBotConfig.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/config/OneBotConfig.kt new file mode 100644 index 00000000..b42c4711 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/config/OneBotConfig.kt @@ -0,0 +1,174 @@ +package org.foedusprogramme.alexandrite.channel.onebot.config + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +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(), + /** The chats this instance serves, by their key, such as `group:987654` or `private:12345678`. */ + public val allowedChats: List = emptyList(), + /** How long an approval request waits for an admin's answer, in minutes. */ + @SerialName("approvalMinutes") public val approvalMinutes: Int = DEFAULT_APPROVAL_MINUTES, + /** Whether this instance tells a chat what a turn is doing while it works. */ + public val progress: Boolean = true, + /** Whether this instance shows what the model says while a turn works on tools. */ + public val streaming: Boolean = true, +) { + /** 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." } + require(approvalMinutes > 0) { "An approval waits at least a minute, was $approvalMinutes." } + 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 DEFAULT_APPROVAL_MINUTES: Int = 10 + + public const val MAX_PORT: Int = 65_535 + } +} + +/** The config path of [key] in the config of [instance], of the instance itself when [key] is null. */ +internal fun configPath(instance: ChannelInstanceId, key: String? = null): String = + listOfNotNull("channels.onebot.instances.${instance.name}", key).joinToString(".") diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/i18n/TextKeys.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/i18n/TextKeys.kt new file mode 100644 index 00000000..0a855218 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/i18n/TextKeys.kt @@ -0,0 +1,150 @@ +package org.foedusprogramme.alexandrite.channel.onebot.i18n + +/** The keys of the OneBot channel's texts for people. */ +internal object TextKeys { + /** Takes `{id}`. */ + const val APPROVAL_TITLE: String = "approval.title" + + /** Takes `{agent}`. */ + const val APPROVAL_AGENT: String = "approval.agent" + + /** Takes `{chat}`. */ + const val APPROVAL_CHAT: String = "approval.chat" + + /** Takes `{requester}`. */ + const val APPROVAL_REQUESTER: String = "approval.requester" + + /** Takes `{reason}`. */ + const val APPROVAL_REASON: String = "approval.reason" + + /** Takes `{tool}` and `{risk}`. */ + const val APPROVAL_TOOL: String = "approval.tool" + + /** Takes `{path}`. */ + const val APPROVAL_FILE: String = "approval.file" + + /** Takes `{directory}`. */ + const val APPROVAL_DIRECTORY: String = "approval.directory" + + /** Takes `{program}`. */ + const val APPROVAL_PROGRAM: String = "approval.program" + + const val APPROVAL_PROGRAM_UNKNOWN: String = "approval.program_unknown" + const val APPROVAL_OPAQUE: String = "approval.opaque" + const val APPROVAL_ARGUMENTS: String = "approval.arguments" + + /** How an approver answers, which this channel takes as a command rather than as a press. */ + const val APPROVAL_ANSWER: String = "approval.answer" + + /** Takes `{rule}`. */ + const val APPROVAL_ALWAYS_RULE: String = "approval.always_rule" + + /** Takes `{id}` and `{name}`. */ + const val APPROVAL_OUTCOME_ALLOWED: String = "approval.outcome_allowed" + + /** Takes `{id}`, `{name}` and `{rule}`. */ + const val APPROVAL_OUTCOME_ALLOWED_ALWAYS: String = "approval.outcome_allowed_always" + + /** Takes `{id}` and `{name}`. */ + const val APPROVAL_OUTCOME_DENIED: String = "approval.outcome_denied" + + /** Takes `{id}`. */ + const val APPROVAL_OUTCOME_EXPIRED: String = "approval.outcome_expired" + + /** Takes `{id}`. */ + const val APPROVAL_OUTCOME_WITHDRAWN: String = "approval.outcome_withdrawn" + + const val APPROVAL_ADMINS_ONLY: String = "approval.admins_only" + const val APPROVAL_NOT_PENDING: String = "approval.not_pending" + const val APPROVAL_NOTHING_WAITS: String = "approval.nothing_waits" + const val APPROVAL_LIST_TITLE: String = "approval.list_title" + + /** Takes `{id}`, `{tool}` and `{reason}`. */ + const val APPROVAL_LIST_ENTRY: String = "approval.list_entry" + + const val APPROVAL_LIST_HINT: String = "approval.list_hint" + + /** Takes `{ids}`. */ + const val APPROVAL_SEVERAL: String = "approval.several" + + /** Takes `{code}` and `{minutes}`. */ + const val PAIRING_CODE: String = "pairing.code" + + /** Takes `{command}`. */ + const val REFUSED_ADMIN: String = "refused.admin" + + /** Takes `{command}`. */ + const val REFUSED_ADMIN_HERE: String = "refused.admin_here" + + /** What a step of work did and what it did it to, as the kinds of step the standard names. */ + const val ACTIVITY_VERB_READ_PRESENT: String = "activity.verb.read.present" + const val ACTIVITY_VERB_READ_PAST: String = "activity.verb.read.past" + const val ACTIVITY_VERB_LIST_PRESENT: String = "activity.verb.list.present" + const val ACTIVITY_VERB_LIST_PAST: String = "activity.verb.list.past" + const val ACTIVITY_VERB_SEARCH_PRESENT: String = "activity.verb.search.present" + const val ACTIVITY_VERB_SEARCH_PAST: String = "activity.verb.search.past" + const val ACTIVITY_VERB_EDIT_PRESENT: String = "activity.verb.edit.present" + const val ACTIVITY_VERB_EDIT_PAST: String = "activity.verb.edit.past" + const val ACTIVITY_VERB_DELETE_PRESENT: String = "activity.verb.delete.present" + const val ACTIVITY_VERB_DELETE_PAST: String = "activity.verb.delete.past" + const val ACTIVITY_VERB_MOVE_PRESENT: String = "activity.verb.move.present" + const val ACTIVITY_VERB_MOVE_PAST: String = "activity.verb.move.past" + const val ACTIVITY_VERB_EXECUTE_PRESENT: String = "activity.verb.execute.present" + const val ACTIVITY_VERB_EXECUTE_PAST: String = "activity.verb.execute.past" + const val ACTIVITY_VERB_FETCH_PRESENT: String = "activity.verb.fetch.present" + const val ACTIVITY_VERB_FETCH_PAST: String = "activity.verb.fetch.past" + const val ACTIVITY_VERB_THINK_PRESENT: String = "activity.verb.think.present" + const val ACTIVITY_VERB_THINK_PAST: String = "activity.verb.think.past" + const val ACTIVITY_VERB_OTHER_PRESENT: String = "activity.verb.other.present" + const val ACTIVITY_VERB_OTHER_PAST: String = "activity.verb.other.past" + const val ACTIVITY_NOUN_READ_ONE: String = "activity.noun.read.one" + const val ACTIVITY_NOUN_READ_MANY: String = "activity.noun.read.many" + const val ACTIVITY_NOUN_LIST_ONE: String = "activity.noun.list.one" + const val ACTIVITY_NOUN_LIST_MANY: String = "activity.noun.list.many" + const val ACTIVITY_NOUN_SEARCH_ONE: String = "activity.noun.search.one" + const val ACTIVITY_NOUN_SEARCH_MANY: String = "activity.noun.search.many" + const val ACTIVITY_NOUN_EDIT_ONE: String = "activity.noun.edit.one" + const val ACTIVITY_NOUN_EDIT_MANY: String = "activity.noun.edit.many" + const val ACTIVITY_NOUN_DELETE_ONE: String = "activity.noun.delete.one" + const val ACTIVITY_NOUN_DELETE_MANY: String = "activity.noun.delete.many" + const val ACTIVITY_NOUN_MOVE_ONE: String = "activity.noun.move.one" + const val ACTIVITY_NOUN_MOVE_MANY: String = "activity.noun.move.many" + const val ACTIVITY_NOUN_EXECUTE_ONE: String = "activity.noun.execute.one" + const val ACTIVITY_NOUN_EXECUTE_MANY: String = "activity.noun.execute.many" + const val ACTIVITY_NOUN_FETCH_ONE: String = "activity.noun.fetch.one" + const val ACTIVITY_NOUN_FETCH_MANY: String = "activity.noun.fetch.many" + const val ACTIVITY_NOUN_THINK_ONE: String = "activity.noun.think.one" + const val ACTIVITY_NOUN_THINK_MANY: String = "activity.noun.think.many" + const val ACTIVITY_NOUN_OTHER_ONE: String = "activity.noun.other.one" + const val ACTIVITY_NOUN_OTHER_MANY: String = "activity.noun.other.many" + const val ACTIVITY_VERB_SEND_PRESENT: String = "activity.verb.send.present" + const val ACTIVITY_VERB_SEND_PAST: String = "activity.verb.send.past" + const val ACTIVITY_NOUN_SEND_ONE: String = "activity.noun.send.one" + const val ACTIVITY_NOUN_SEND_MANY: String = "activity.noun.send.many" + + /** Takes `{noun}`. */ + const val ACTIVITY_SUMMARY_NOT_RUN: String = "activity.summary.not_run" + + /** Takes `{count}`. */ + const val ACTIVITY_OMITTED_ONE: String = "activity.omitted.one" + const val ACTIVITY_OMITTED_MANY: String = "activity.omitted.many" + + /** Takes `{tool}` and `{title}`. */ + const val ACTIVITY_CALL_WAITING_APPROVAL: String = "activity.call.waiting_approval" + const val ACTIVITY_CALL_NOT_RUN: String = "activity.call.not_run" + const val ACTIVITY_CALL_BACKGROUNDED: String = "activity.call.backgrounded" + + /** Takes `{code}`. */ + const val ACTIVITY_CALL_EXIT: String = "activity.call.exit" + + const val ACTIVITY_CALL_FAILED: String = "activity.call.failed" + + /** Takes `{added}` and `{removed}`. */ + const val ACTIVITY_CALL_DIFF: String = "activity.call.diff" + + /** Takes `{agent}`. */ + const val ACTIVITY_DELEGATE_PRESENT: String = "activity.delegate.present" + const val ACTIVITY_DELEGATE_PAST: String = "activity.delegate.past" + + const val ACTIVITY_CALL_BY_AGENT: String = "activity.call.by_agent" +} 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 00000000..65a3c2eb --- /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 00000000..ebb96f4b --- /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 00000000..c3142d12 --- /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 00000000..04907fc7 --- /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 00000000..bbcc0ee1 --- /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 00000000..7d79f413 --- /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 00000000..b2c1d2ca --- /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 00000000..130545ca --- /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 00000000..cc4714f0 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCode.kt @@ -0,0 +1,363 @@ +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.File -> parameters( + "file", + "file" to segment.file, + "name" to segment.name, + "url" to segment.url, + ) + + 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 00000000..8ff3abce --- /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 00000000..ba6d4eae --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegment.kt @@ -0,0 +1,261 @@ +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 file, which an implementation that offers the extension carries as a segment of its own. + * + * The standard sends a file through the actions for files rather than in a message, so this is a segment the + * standard does not declare: an implementation that reports one names the file and what to call it, and may name + * more about it than this version carries. + */ + public data class File( + /** How the implementation knows the file, which is also what an upload takes. */ + public val file: String, + /** What the file is called. */ + public val name: String? = null, + /** Where the file can be read, when the implementation names a place. */ + public val url: String? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.FILE + } + + /** 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 FILE: String = "file" + 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 00000000..ddf18541 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegmentCodec.kt @@ -0,0 +1,292 @@ +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.File -> putJsonObject("data") { + put("file", segment.file) + optional("name", segment.name) + optional("url", segment.url) + } + + 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.FILE -> OneBotSegment.File( + file = data.string("file").orEmpty(), + name = data.string("name"), + url = data.string("url"), + ) + + 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 00000000..92ce0059 --- /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 00000000..1820ff69 --- /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 00000000..33dac0d1 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotForwardWebSocket.kt @@ -0,0 +1,183 @@ +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 = send(action, params, null) + + /** + * Sends one call, waiting at most [waitMillis] for its answer where a caller asks for longer than the instance + * allows. + * + * A call that carries a file takes longer than one that only reads something, and its answer is what says whether + * it arrived. Null waits as the instance's own timeout says. + */ + suspend fun send(action: String, params: JsonObject, waitMillis: Long?): 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(waitMillis ?: 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 00000000..0be54bf8 --- /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 00000000..3fda22d2 --- /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 00000000..ceee7d96 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotReverseWebSocket.kt @@ -0,0 +1,235 @@ +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 = send(action, params, null) + + /** + * Sends one call, waiting at most [waitMillis] for its answer where a caller asks for longer than the instance + * allows. + * + * A call that carries a file takes longer than one that only reads something, and its answer is what says whether + * it arrived. Null waits as the instance's own timeout says. + */ + suspend fun send(action: String, params: JsonObject, waitMillis: Long?): 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(waitMillis ?: 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 00000000..a55579a7 --- /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 00000000..e7b823e5 --- /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() From 2b49ff27e99f87884383bb6956ac503050eb1de4 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 01:31:22 +0800 Subject: [PATCH 02/37] onebot: Map OneBot events and chats Maps v11 events, CQ codes and segments onto incoming messages, notices and quotes, and pairs chats with their OneBot ids. --- .../onebot/channel/OneBotAccessGate.kt | 143 ++++++ .../channel/onebot/channel/OneBotLink.kt | 129 +++++ .../onebot/channel/OneBotRecentMessages.kt | 49 ++ .../onebot/channel/OneBotSentMessages.kt | 36 ++ .../channel/onebot/mapping/OneBotMarkdown.kt | 252 ++++++++++ .../channel/onebot/mapping/OneBotMessages.kt | 448 ++++++++++++++++++ .../channel/onebot/mapping/OneBotNotices.kt | 210 ++++++++ .../onebot/pairing/OneBotPairingDesk.kt | 13 + 8 files changed, 1280 insertions(+) create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotAccessGate.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotLink.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotRecentMessages.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSentMessages.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdown.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotNotices.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/pairing/OneBotPairingDesk.kt diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotAccessGate.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotAccessGate.kt new file mode 100644 index 00000000..6dde0f1d --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotAccessGate.kt @@ -0,0 +1,143 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.common.pairing.InstancePairing +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.onebot.config.configPath +import org.foedusprogramme.alexandrite.channel.onebot.i18n.TextKeys +import org.foedusprogramme.alexandrite.channel.onebot.pairing.OneBotPairingDesk +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.foedusprogramme.alexandrite.sdk.i18n.Texts +import org.foedusprogramme.alexandrite.sdk.turn.AgentControl +import org.slf4j.LoggerFactory +import kotlin.coroutines.cancellation.CancellationException + +/** + * Lets the messages of paired chats through and offers each chat that is not paired its pairing code. + * + * A chat is paired when the config declares it or when the host approved a code it was offered. Nothing else of this + * channel runs for a chat that is not paired: no turn, no command, and no message the agent starts itself, which is + * what keeps a bot that was invited into a group from answering before somebody said it may. + */ +@ChannelInstanceScoped +internal class OneBotAccessGate( + private val instance: ChannelInstance, + private val config: OneBotInstanceConfig, + private val desk: OneBotPairingDesk, + private val sender: OneBotSender, + private val texts: Texts, + private val control: AgentControl?, +) : Lifecycle { + private val logged = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = size > LOGGED_CHATS + } + + @Volatile + private var pairing: InstancePairing? = null + + override suspend fun onStart() { + val pairing = desk.open(instance.id, config.allowedChats.map { ChatAddress(instance.id, it) }) + this.pairing = pairing + warnUnpaired(pairing) + } + + override fun onStop() { + desk.close(instance.id) + } + + /** Whether [chat], or the chat whose thread it is, is paired. */ + fun isPaired(chat: ChatAddress): Boolean = pairing?.isPaired(chat) == true + + /** + * Whether the message [trigger] from [requester] in [chat] may pass; when its chat is not paired and the message + * is [addressed] to the bot, the chat is offered its code. + */ + suspend fun admit( + chat: ChatAddress, + kind: ChatKind, + title: String?, + trigger: ChannelMessageRef, + requester: ChatUser?, + addressed: Boolean, + ): Boolean { + val pairing = checkNotNull(pairing) { "The access gate of ${instance.id} has not started." } + if (pairing.isPaired(chat)) return true + logFirstDrop(chat.parent, kind, title, requester) + if (!addressed || requester == null || requester.isBot) return false + val code = pairing.request(chat, kind, title, requester) ?: return false + val said = texts.text( + TextKeys.PAIRING_CODE, + null, + "code" to code.text, + "minutes" to desk.codeLifetime.inWholeMinutes, + ) + return try { + sender.send(chat, OutboundMessage.builder(said, MessageKind.REPLY).build()) + false + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn("{} cannot offer chat {} its pairing code: {}", instance.id, chat, e.toString()) + false + } + } + + /** Warns once of each home chat and linked chat of this instance that is not paired. */ + private fun warnUnpaired(pairing: InstancePairing) { + val control = control ?: return + val uses = LinkedHashMap>() + try { + for (agent in control.agents(instance.id)) { + agent.home?.let { uses.getOrPut(it.parent, ::LinkedHashSet) += "the home chat of agent ${agent.id}" } + } + for (group in control.links()) { + for (chat in (listOf(group.anchor) + group.members).filter { it.instance == instance.id }) { + uses.getOrPut(chat.parent, ::LinkedHashSet) += "linked at agent ${group.agent}" + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn("Cannot tell whether the home chats and linked chats of {} are paired: {}", instance.id, "$e") + return + } + for ((chat, use) in uses.filterKeys { !pairing.isPaired(it) }) { + logger.warn( + "Chat {} is {} but not paired, so the bot sends it nothing: add {} to {}, or approve the code it is " + + "offered when it addresses the bot with `alexandrite pair `", + chat, + use.joinToString(" and "), + chat.chat, + configPath(instance.id, "allowedChats"), + ) + } + } + + private fun logFirstDrop(chat: ChatAddress, kind: ChatKind, title: String?, requester: ChatUser?) { + val first = synchronized(logged) { logged.put(chat, Unit) == null } + if (!first) return + logger.info( + "Dropped a message of chat {} ({}{}) from user {}{}: the chat is not paired. Approve the code it was " + + "offered with `alexandrite pair `; `alexandrite pairings` lists the requests", + chat, + kind, + title?.let { " \"$it\"" }.orEmpty(), + requester?.address?.user ?: "unknown", + requester?.username?.let { " @$it" }.orEmpty(), + ) + } + + private companion object { + /** The chats whose first dropped message was logged, forgetting the least recent. */ + const val LOGGED_CHATS = 4096 + } +} + +private val logger = LoggerFactory.getLogger(OneBotAccessGate::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 00000000..c926008e --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotLink.kt @@ -0,0 +1,129 @@ +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. + * + * [waitMillis] asks the socket transports to wait longer than the instance allows for a call that takes longer, + * such as one carrying a file. The HTTP transports are given their timeouts when their client is built, so a + * caller's wait is not honoured there and the instance's own `firstByteTimeoutMillis` governs them. + */ + internal suspend fun call( + action: String, + params: JsonObject, + waitMillis: Long? = null, + ): OneBotResult = when { + http != null -> http.call(action, params) + + forward != null -> overSocket { forward.send(action, params, waitMillis) } + + reverse != null -> overSocket { reverse.send(action, params, waitMillis) } + + 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/channel/OneBotRecentMessages.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotRecentMessages.kt new file mode 100644 index 00000000..a89130fe --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotRecentMessages.kt @@ -0,0 +1,49 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.onebot.mapping.QuotedMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped + +/** + * The messages this instance read not long ago, so that a message answering one says what it answered. + * + * A report names the message it answers and says nothing about it, and reading it back from the implementation is a + * call for every answer. What the channel read as it arrived is kept instead, which is enough for what a chat normally + * answers: something said not long before. A message older than what is kept is read back from the implementation, and + * one it no longer knows is answered with its name alone. + * + * What is kept is bounded by the chats and by the messages in each, because a chat that runs for long says far more + * than any answer reaches back to. + */ +@ChannelInstanceScoped +internal class OneBotRecentMessages { + /** The messages of each chat, oldest first, with the chats least recently asked for forgotten first. */ + private val chats = object : LinkedHashMap>(16, 0.75f, true) { + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry>, + ) = size > MAX_CHATS + } + + /** Keeps [message] as what the message [id] of [chat] holds. */ + fun remember(chat: ChatAddress, id: String, message: QuotedMessage) { + if (id.isEmpty()) return + synchronized(chats) { + val held = chats.getOrPut(chat) { LinkedHashMap() } + held[id] = message + while (held.size > MAX_PER_CHAT) { + held.remove(held.keys.first()) + } + } + } + + /** What the message [id] of [chat] holds, null when it was not read or is no longer kept. */ + fun find(chat: ChatAddress, id: String): QuotedMessage? = synchronized(chats) { chats[chat]?.get(id) } + + private companion object { + /** How many chats' messages are kept. */ + const val MAX_CHATS = 256 + + /** How many messages of one chat are kept. */ + const val MAX_PER_CHAT = 200 + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSentMessages.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSentMessages.kt new file mode 100644 index 00000000..494d2532 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSentMessages.kt @@ -0,0 +1,36 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped + +/** + * The messages this instance sent, so that one answering the bot can be told from one answering somebody else. + * + * A reported message carries the id it answers and no author of it, and the only other way to learn that author is a + * call to the implementation for every answer in every group. What this instance sent is what it can answer for, so + * the ids of its own messages are what a reply is matched against. + * + * The ids are kept in the order they were sent and only so many of them, because a chat that runs for long sends far + * more messages than any answer can reach back to. + */ +@ChannelInstanceScoped +internal class OneBotSentMessages { + private val sent = ArrayDeque() + + /** Records that this instance sent the message [id]. */ + fun remember(id: String) { + if (id.isEmpty()) return + synchronized(sent) { + sent.remove(id) + sent.addLast(id) + while (sent.size > KEPT) sent.removeFirst() + } + } + + /** Whether [id] is a message this instance sent. */ + fun holds(id: String): Boolean = synchronized(sent) { id in sent } + + private companion object { + /** How many of the messages this instance sent are remembered. */ + const val KEPT = 512 + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdown.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdown.kt new file mode 100644 index 00000000..4e4b1023 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdown.kt @@ -0,0 +1,252 @@ +package org.foedusprogramme.alexandrite.channel.onebot.mapping + +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment + +/** + * Renders Markdown as the plain text a chat of this platform reads, in the segments a message takes. + * + * A message of this platform has no formatting of its own, so a marker of the formatting would arrive as the character + * it is written with. What the marker means is kept as words instead: emphasis keeps its text, a heading keeps its + * title, a code block keeps its lines and a link keeps its address beside its text. A list keeps its marker, which a + * chat already reads as one, and an image becomes an image segment. + */ +internal object OneBotMarkdown { + /** [markdown] as text segments and image segments, in the order it writes them. */ + fun segments(markdown: String): List = Renderer().also { it.render(markdown) }.segments() +} + +/** What a chat is told of one message, built as its lines are read. */ +private class Renderer { + private val text = StringBuilder() + private val images = mutableListOf() + + fun render(markdown: String) { + val lines = markdown.replace("\r\n", "\n").replace('\r', '\n').split('\n') + var at = 0 + while (at < lines.size) { + at = block(lines, at) + if (at < lines.size) text.append('\n') + } + } + + fun segments(): List { + val segments = mutableListOf() + var at = 0 + for (match in IMAGE_MARKER_IN_TEXT.findAll(text)) { + if (match.range.first > at) segments += OneBotSegment.Text(text.substring(at, match.range.first)) + images.getOrNull(match.groupValues[1].toInt())?.let { segments += OneBotSegment.Image(file = it) } + at = match.range.last + 1 + } + if (at < text.length) segments += OneBotSegment.Text(text.substring(at)) + return segments + } + + /** Reads the block that starts at line [at] and returns the line after it. */ + private fun block(lines: List, at: Int): Int { + val line = lines[at] + fenceOpening(line)?.let { return fence(lines, at, it) } + if (startsTable(lines, at)) return table(lines, at) + if (RULE.matches(line)) { + text.append(RULE_TEXT) + return at + 1 + } + HEADING.matchEntire(line)?.let { heading -> + inline(heading.groupValues[1]) + return at + 1 + } + QUOTE.matchEntire(line)?.let { quote -> + inline(quote.groupValues[1]) + return at + 1 + } + BULLET.matchEntire(line)?.let { item -> + text.append(item.groupValues[1]) + inline(item.groupValues[2]) + return at + 1 + } + ORDERED.matchEntire(line)?.let { item -> + text.append(item.groupValues[1]) + inline(item.groupValues[2]) + return at + 1 + } + inline(line.trimEnd()) + return at + 1 + } + + /** The lines of the fence that starts at [at], without the fence and what follows its opening. */ + private fun fence(lines: List, at: Int, fence: Fence): Int { + var end = at + 1 + while (end < lines.size && !fence.closedBy(lines[end])) end++ + text.append(lines.subList(at + 1, end).joinToString("\n") { fence.unindented(it) }) + return if (end < lines.size) end + 1 else end + } + + /** The rows of the table that starts at [at], without the row that only says how its cells line up. */ + private fun table(lines: List, at: Int): Int { + var end = at + 2 + while (end < lines.size && lines[end].isNotBlank() && '|' in lines[end]) end++ + text.append((listOf(lines[at]) + lines.subList(at + 2, end)).joinToString("\n") { cells(it) }) + return end + } + + /** The cells of one row of a table, which a chat reads apart by the pipe between them. */ + private fun cells(row: String): String = row.trim().trim('|').split('|').joinToString(" | ") { it.trim() } + + /** Appends [source] as a chat reads it: without the markers of its formatting and with its images kept apart. */ + private fun inline(source: String) { + val code = mutableListOf() + var work = protectCode(source.trimEnd(), code) + work = IMAGE.replace(work) { match -> + images += match.groupValues[2] + "$IMAGE_MARKER${images.lastIndex}$MARKER_END" + } + work = LINK.replace(work) { match -> + val label = match.groupValues[1].trim() + val url = match.groupValues[2] + when { + label.isEmpty() -> url + label == url -> url + else -> "$label ($url)" + } + } + work = stripEmphasis(work) + work = ESCAPED.replace(work) { it.value.substring(1) } + text.append(restoreCode(work, code)) + } + + /** [source] with each code span replaced by a marker, whose text is put back by [restoreCode]. */ + private fun protectCode(source: String, code: MutableList): String { + val out = StringBuilder() + var at = 0 + while (at < source.length) { + val open = source.indexOf('`', at) + if (open < 0) { + out.append(source, at, source.length) + break + } + val length = run(source, open, '`') + val closing = closingRun(source, open + length, length) + if (closing < 0) { + out.append(source, at, open + length) + at = open + length + continue + } + out.append(source, at, open) + code += codeText(source.substring(open + length, closing)) + out.append(CODE_MARKER).append(code.lastIndex).append(MARKER_END) + at = closing + length + } + return out.toString() + } + + /** The index of the run of [length] backticks that closes the span from [from] on, -1 when it never closes. */ + private fun closingRun(source: String, from: Int, length: Int): Int { + var search = from + while (search < source.length) { + val closing = source.indexOf('`', search) + if (closing < 0) return -1 + val closingLength = run(source, closing, '`') + if (closingLength == length) return closing + search = closing + closingLength + } + return -1 + } + + /** [source] with what emphasis and strikethrough wrap kept and their markers left out. */ + private fun stripEmphasis(source: String): String { + var result = source + var previous = "" + while (result != previous) { + previous = result + result = EMPHASIS.replace(result) { it.groupValues[2] } + result = UNDERSCORE_EMPHASIS.replace(result) { it.groupValues[1] } + } + return result + } + + /** [source] with the text kept by [protectCode] put back. */ + private fun restoreCode(source: String, code: List): String = + CODE_MARKER_IN_TEXT.replace(source) { code.getOrElse(it.groupValues[1].toInt()) { "" } } +} + +/** An opening fence of a code block: how far it is indented and what it is written with. */ +private class Fence(private val indent: Int, private val marker: Char, private val length: Int) { + /** Whether [line] is the fence that closes this one. */ + fun closedBy(line: String): Boolean { + val trimmed = line.trim() + return trimmed.length >= length && trimmed.all { it == marker } + } + + /** [line] without the indentation of the opening fence. */ + fun unindented(line: String): String { + var start = 0 + while (start < indent && start < line.length && (line[start] == ' ' || line[start] == '\t')) start++ + return line.substring(start) + } +} + +/** The fence that opens the code block [line] starts, null when the line starts none. */ +private fun fenceOpening(line: String): Fence? { + val match = FENCE.matchEntire(line) ?: return null + val (indent, marker, info) = match.destructured + // A backtick fence's info string may not hold a backtick, which the code of a span would write there. + if (marker[0] == '`' && '`' in info) return null + return Fence(indent.length, marker[0], marker.length) +} + +/** Whether the row that lines a table's cells up follows the row at [at]. */ +private fun startsTable(lines: List, at: Int): Boolean = + at + 1 < lines.size && '|' in lines[at] && '|' in lines[at + 1] && DELIMITER_ROW.matches(lines[at + 1]) + +/** How many of [char] start at [at]. */ +private fun run(source: String, at: Int, char: Char): Int { + var end = at + while (end < source.length && source[end] == char) end++ + return end - at +} + +/** The text of a code span: line breaks as spaces, without one space on each side. */ +private fun codeText(raw: String): String { + val text = raw.replace('\n', ' ') + val padded = text.length >= 2 && text.first() == ' ' && text.last() == ' ' && text.isNotBlank() + return if (padded) text.substring(1, text.length - 1) else text +} + +/** What a line that only says how a table's cells line up is written with. */ +private const val RULE_TEXT = "———" + +/** What a marker in the text being built starts with, and what ends it: not a character a message holds. */ +private const val IMAGE_MARKER = "\u0000I" +private const val CODE_MARKER = "\u0000C" +private const val MARKER_END = "\u0000" + +private val IMAGE_MARKER_IN_TEXT = Regex("\u0000I(\\d+)\u0000") +private val CODE_MARKER_IN_TEXT = Regex("\u0000C(\\d+)\u0000") + +/** Emphasis and strikethrough, which keep what they wrap and lose the marker that wraps it. */ +private val EMPHASIS = Regex("""(?[ \t]?)+(.*)$""") + +private val BULLET = Regex("""^([ \t]*[-*+][ \t]+)(.*)$""") + +private val ORDERED = Regex("""^([ \t]*\d{1,9}[.)][ \t]+)(.*)$""") + +private val DELIMITER_ROW = Regex("""^[ \t]*\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$""") + +/** An image, which becomes a segment of its own rather than text a chat cannot see. */ +private val IMAGE = Regex("""(?` 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. */ + // The kinds of turn event a chat is told of while a turn works. + private val TOOL_EVENTS: Set = + setOf(TurnEventKind.TOOL_CALL_STARTED, TurnEventKind.TOOL_CALL_UPDATED) + + public fun capabilities( + proactive: Boolean, + /** Whether this instance tells a chat what a turn is doing while it works. */ + progress: Boolean = true, + /** Whether this instance shows what the model says while a turn works on tools. */ + streaming: Boolean = true, + ): ChannelCapabilities = ChannelCapabilities.builder() + .markups(setOf(Markup.PLAIN, Markup.MARKDOWN)) + .streaming(streaming) + .finalReplacesPreview(false) + .proactive(proactive) + .events(if (progress) TOOL_EVENTS else emptySet()) + .maxPartsPerReply(null) + .files(true) + .maxFileBytes(MAX_FILE_BYTES) + .build() + +/** + * The bytes one file of a chat may hold, which is the ten gigabytes this platform takes. + * + * A larger one is refused rather than sent, the way the telegram channel refuses one past what a bot of its own + * platform may send. + */ + internal const val MAX_FILE_BYTES: Long = 10L * 1024 * 1024 * 1024 + + /** + * The message [event] is, where [fetched] is what the implementation reported about the message it answers. + * + * A report says which message is answered and nothing about it, so what it says is read from the implementation + * before this is called; without it the quote names the message that is answered and carries no text. + */ + public fun incoming( + instance: ChannelInstanceId, + event: OneBotEvent.Message, + admins: Set = emptySet(), + quoted: QuotedMessage? = null, + /** How the bytes of a reported image, voice or video are read, null when they cannot be. */ + reader: MediaReader? = null, + ): 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 }) + // The quoted message's own media too, since a person who quotes a picture and asks about it is asking + // about that picture: without this the quote reaches the model as the marker for an image and no image. + .media(media(segments, reader) + media(quoted?.segments.orEmpty(), reader)) + .quote(quote(event, chat, quoted)) + .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 -> OneBotMarkdown.segments(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 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 + } + + /** Who sent [event], as an operator of the instance when the report or the list of [admins] says so. */ + public fun senderOf(instance: ChannelInstanceId, event: OneBotEvent.Message, admins: Set): ChatUser = + sender(instance, event, admins) + + 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, reader: MediaReader?): 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 + val content = reader?.content(segment) ?: unread(reference) + MediaAttachment.builder(kind, mediaType(kind), content) + .name(reference.substringAfterLast('/').takeIf { it.isNotEmpty() }) + .build() + } + + /** + * The bytes of [reference], which this reader does not read. + * + * A segment is named whether or not its bytes can be read, so that the agent knows the message carried one: a + * read that throws is what tells it the file could not be loaded, where naming no attachment at all would hide + * that the message held anything. + */ + private fun unread(reference: String): MediaContent = MediaContent { + throw UnsupportedOperationException("Reading '$reference' is not implemented yet.") + } + + /** 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/*" + } + + /** + * What [event] is, as a message that another one can quote. + * + * Every report is read this way and kept for a while, because a report names the message it answers and says + * nothing about it, and reading it back from the implementation costs a call it usually does not need. + */ + public fun quotable(instance: ChannelInstanceId, event: OneBotEvent.Message): QuotedMessage { + val reported = event.sender + return QuotedMessage( + text = quotedText(event.message.segments), + authorName = reported.card?.takeIf { it.isNotEmpty() } ?: reported.nickname, + author = OneBotChats.user(instance, event.userId), + authorIsBot = reported.userId == event.selfId.value, + segments = event.message.segments, + ) + } + + /** What the implementation reported about a message is, as a message that another one can quote. */ + public fun quotable(instance: ChannelInstanceId, info: OneBotMessageInfo, selfId: String?): QuotedMessage { + val reported = info.sender + val segments = OneBotSegmentCodec.decodeMessage(info.message ?: JsonNull).segments + return QuotedMessage( + text = quotedText(segments), + authorName = reported?.card?.takeIf { it.isNotEmpty() } ?: reported?.nickname, + author = reported?.userId?.let { OneBotChats.user(instance, it) }, + authorIsBot = reported?.userId?.value != null && reported.userId.value == selfId, + segments = segments, + ) + } + + /** + * What [event] answers, where [quoted] is what was read about the message it answers. + * + * A report names the message it answers and says nothing else about it, so [quoted] is read separately and can be + * missing, since a message may be older than what the channel kept and no longer readable at all. The quote then + * still names the message that is answered, and says that it holds nothing. + */ + private fun quote(event: OneBotEvent.Message, chat: ChatAddress, quoted: QuotedMessage?): Quote? { + val reply = event.message.segments.filterIsInstance().firstOrNull() ?: return null + val target = ChannelMessageRef(chat, reply.id.value) + if (quoted == null) { + return Quote.builder("").target(target).trust(QuoteTrust.UNTRUSTED).build() + } + return Quote.builder(quoted.text) + .senderName(quoted.authorName) + .sender(quoted.author) + .senderIsBot(quoted.authorIsBot) + .target(target) + .trust(QuoteTrust.UNTRUSTED) + .build() + } + + /** + * What a quoted message says: its texts, and what its other parts are. + * + * A part that carries something a reader would know is named after that, because a quote of a card that showed + * only its type would say nothing of what the sender meant. + */ + private fun quotedText(segments: List): String = segments.joinToString("") { segment -> + when (segment) { + is OneBotSegment.Text -> segment.text + + is OneBotSegment.At -> "@${segment.userId}" + + is OneBotSegment.Image -> "[image]" + + is OneBotSegment.Record -> "[voice]" + + is OneBotSegment.Video -> "[video]" + + is OneBotSegment.Face -> "[face]" + + is OneBotSegment.Music -> { + val named = listOfNotNull(segment.title, segment.content).joinToString(" - ") + val where = segment.url ?: segment.audio + "[music${if (named.isEmpty()) "" else ": $named"}]${if (where == null) "" else " $where"}" + } + + is OneBotSegment.Share -> "[link: ${segment.title}] ${segment.url}" + + is OneBotSegment.Json -> "[json] ${segment.data.take(QUOTED_CHARS)}" + + is OneBotSegment.Xml -> "[xml] ${segment.data.take(QUOTED_CHARS)}" + + else -> "[${segment.type}]" + } + } + + 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 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 +} + +/** The characters of a quoted card that are kept, which is enough of it to know what it is. */ +private const val QUOTED_CHARS = 500 + +/** + * What a message holds, as the channel read it, so that a message answering it can say what it answered. + * + * This is what a channel keeps of the messages it read: a report names the message it answers and nothing about it, + * so what that message holds is either read when it arrives and kept, or read back from the implementation. + */ +public class QuotedMessage( + public val text: String, + public val authorName: String?, + public val author: UserAddress?, + public val authorIsBot: Boolean, + /** + * What the quoted message was reported to hold, which is kept beside its text so that the media of a quoted + * message reaches whoever answers it: a picture cannot be read out of a text, however it is written. + */ + public val segments: List = emptyList(), +) + +/** + * How the bytes a reported segment names are read. + * + * A report names an image or a voice message by what the implementation knows it as, and reading it needs this + * plugin's transport, which the mapping does not have: it is given one of these by whoever asks for the bytes. A + * segment it cannot read names no attachment, so the rest of the message is read either way. + */ +public fun interface MediaReader { + /** How to read [segment], null when this reader does not read that kind. */ + public fun content(segment: OneBotSegment): MediaContent? +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotNotices.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotNotices.kt new file mode 100644 index 00000000..54ffd9b1 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotNotices.kt @@ -0,0 +1,210 @@ +package org.foedusprogramme.alexandrite.channel.onebot.mapping + +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.sdk.channel.ChannelNotice +import org.foedusprogramme.alexandrite.sdk.channel.ChannelNoticeKind +import org.foedusprogramme.alexandrite.sdk.channel.DisplayFact +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.ChatUser +import org.foedusprogramme.alexandrite.sdk.chat.UserAddress +import java.time.Instant + +/** + * What a report says besides a message, as the channel contract's own notice. + * + * A report of an event this version does not model is published all the same, under the name the implementation gave + * it: a plugin written for an implementation's own vocabulary reads it without this mapping having to know it, and a + * subscriber sees that something happened rather than nothing at all. + */ +public object OneBotNotices { + /** The notice [event] reports, null when it reports none this channel publishes. */ + public fun of(instance: ChannelInstanceId, event: OneBotEvent, selfId: String): ChannelNotice? = when (event) { + is OneBotEvent.Notice.GroupIncrease -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.MEMBER_JOINED, + chat = OneBotChats.groupChat(instance, event.groupId), + actor = event.operatorId.takeIf { it != event.userId }?.let { user(instance, it, selfId) }, + target = user(instance, event.userId, selfId), + facts = listOfNotNull(subtype(event.subType)), + ) + + is OneBotEvent.Notice.GroupDecrease -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.MEMBER_LEFT, + chat = OneBotChats.groupChat(instance, event.groupId), + actor = event.operatorId.takeIf { it != event.userId }?.let { user(instance, it, selfId) }, + target = user(instance, event.userId, selfId), + facts = listOfNotNull(subtype(event.subType)), + ) + + is OneBotEvent.Notice.GroupBan -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.MEMBER_BANNED, + chat = OneBotChats.groupChat(instance, event.groupId), + actor = user(instance, event.operatorId, selfId), + target = user(instance, event.userId, selfId), + facts = listOfNotNull( + DisplayFact("muted", if (event.isBan) "yes" else "no"), + event.duration?.let { DisplayFact("seconds", "$it") }, + subtype(event.subType), + ), + ) + + is OneBotEvent.Notice.GroupAdmin -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.ROLE_CHANGED, + chat = OneBotChats.groupChat(instance, event.groupId), + target = user(instance, event.userId, selfId), + facts = listOf(DisplayFact("administrator", if (event.isSet) "yes" else "no")), + ) + + is OneBotEvent.Notice.GroupRecall -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.MESSAGE_RECALLED, + chat = OneBotChats.groupChat(instance, event.groupId), + actor = user(instance, event.operatorId, selfId), + target = user(instance, event.userId, selfId), + message = ChannelMessageRef(OneBotChats.groupChat(instance, event.groupId), event.messageId.value), + ) + + is OneBotEvent.Notice.FriendRecall -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.MESSAGE_RECALLED, + chat = OneBotChats.privateChat(instance, event.userId), + target = user(instance, event.userId, selfId), + message = ChannelMessageRef(OneBotChats.privateChat(instance, event.userId), event.messageId.value), + ) + + is OneBotEvent.Notice.FriendAdd -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.FRIEND_ADDED, + chat = OneBotChats.privateChat(instance, event.userId), + target = user(instance, event.userId, selfId), + facts = listOfNotNull(subtype(event.subType)), + ) + + is OneBotEvent.Notice.GroupUpload -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind("group.file_uploaded"), + chat = OneBotChats.groupChat(instance, event.groupId), + actor = user(instance, event.userId, selfId), + facts = listOfNotNull( + DisplayFact("file", event.file.name), + event.file.size?.let { DisplayFact("bytes", "$it") }, + ), + ) + + is OneBotEvent.Notice.Notify -> notify(instance, event, selfId) + + is OneBotEvent.Request.Friend -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.FRIEND_REQUESTED, + chat = OneBotChats.privateChat(instance, event.userId), + target = user(instance, event.userId, selfId), + facts = requestFacts(event.comment, event.flag, null), + ) + + is OneBotEvent.Request.Group -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.JOIN_REQUESTED, + chat = OneBotChats.groupChat(instance, event.groupId), + target = user(instance, event.userId, selfId), + facts = requestFacts(event.comment, event.flag, event.subType), + ) + + // Everything else keeps the name the implementation gave it, so that a vocabulary this version does not model + // reaches a subscriber written for that implementation. + is OneBotEvent.Notice.Other -> chatOf(instance, event)?.let { chat -> + notice( + instance = instance, + event = event, + kind = ChannelNoticeKind(event.noticeType), + chat = chat, + actor = event.userId()?.let { user(instance, it, selfId) }, + facts = listOfNotNull(subtype(event.subType)), + ) + } + + else -> null + } + + /** A poke, a lucky king or a member honor, which the implementation reports as one kind of notice. */ + private fun notify(instance: ChannelInstanceId, event: OneBotEvent.Notice.Notify, selfId: String): ChannelNotice { + val chat = event.groupId?.let { OneBotChats.groupChat(instance, it) } + ?: OneBotChats.privateChat(instance, event.userId) + return notice( + instance = instance, + event = event, + kind = ChannelNoticeKind("notice.${event.subType ?: "notify"}"), + chat = chat, + actor = user(instance, event.userId, selfId), + target = event.targetId?.let { user(instance, it, selfId) }, + facts = listOfNotNull(event.honorType?.let { DisplayFact("honor", it) }), + ) + } + + private fun notice( + instance: ChannelInstanceId, + event: OneBotEvent, + kind: ChannelNoticeKind, + chat: ChatAddress, + actor: ChatUser? = null, + target: ChatUser? = null, + message: ChannelMessageRef? = null, + facts: List = emptyList(), + ): ChannelNotice = ChannelNotice( + chat = chat, + kind = kind, + actor = actor, + target = target, + message = message, + receivedAt = Instant.ofEpochSecond(event.time), + facts = facts, + ) + + private fun requestFacts(comment: String, flag: String, subtype: String?): List = listOfNotNull( + DisplayFact("answer with", flag), + comment.takeIf { it.isNotBlank() }?.let { DisplayFact("said", it) }, + subtype(subtype), + ) + + private fun subtype(subtype: String?): DisplayFact? = subtype?.let { DisplayFact("how", it) } + + /** The chat of a notice this version does not model, which names a group only when its report carries one. */ + private fun chatOf(instance: ChannelInstanceId, event: OneBotEvent.Notice.Other): ChatAddress? { + val group = event.raw["group_id"]?.toString()?.trim('"')?.takeIf { it.isNotEmpty() && it != "null" } + val user = event.raw["user_id"]?.toString()?.trim('"')?.takeIf { it.isNotEmpty() && it != "null" } + return when { + group != null -> OneBotChats.groupChat(instance, GroupId(group)) + user != null -> OneBotChats.privateChat(instance, UserId(user)) + else -> null + } + } + + /** Whether an unmodelled notice names anybody, which is read from the report it kept. */ + private fun OneBotEvent.Notice.Other.userId(): UserId? = + raw["user_id"]?.toString()?.trim('"')?.takeIf { it.isNotEmpty() && it != "null" }?.let(::UserId) + + /** Who [id] is, as far as a report that names them without a profile says. */ + private fun user(instance: ChannelInstanceId, id: UserId, selfId: String): ChatUser = ChatUser( + address = UserAddress(instance, id.value), + displayName = id.value, + username = null, + isBot = id.value == selfId, + isAdmin = false, + ) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/pairing/OneBotPairingDesk.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/pairing/OneBotPairingDesk.kt new file mode 100644 index 00000000..7c95451b --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/pairing/OneBotPairingDesk.kt @@ -0,0 +1,13 @@ +package org.foedusprogramme.alexandrite.channel.onebot.pairing + +import org.foedusprogramme.alexandrite.channel.common.pairing.PairingDesk +import org.foedusprogramme.alexandrite.channel.common.pairing.Pairings +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.store.Records +import java.time.Clock + +/** The pairing of the chats of every OneBot channel instance. */ +@Singleton +@Contribute(PairingDesk::class) +internal class OneBotPairingDesk(clock: Clock, records: Records) : Pairings(clock, records) From 3bfa51bf175a7abd87e0cb944e75fa4043724b33 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 01:31:25 +0800 Subject: [PATCH 03/37] onebot: Deliver replies, approvals and progress Adds the channel front end and reply sink, the sender and reply registry, command permissions, approval questions, activity summaries and the restart-safe sent-message ledger. --- .../onebot/channel/OneBotActivities.kt | 31 ++ .../channel/onebot/channel/OneBotApis.kt | 34 ++ .../channel/onebot/channel/OneBotApprovals.kt | 458 ++++++++++++++++++ .../channel/onebot/channel/OneBotChannel.kt | 276 +++++++++++ .../onebot/channel/OneBotCommandRules.kt | 99 ++++ .../channel/onebot/channel/OneBotInbox.kt | 318 ++++++++++++ .../channel/onebot/channel/OneBotProgress.kt | 361 ++++++++++++++ .../channel/onebot/channel/OneBotSender.kt | 206 ++++++++ .../channel/onebot/channel/OneBotWords.kt | 68 +++ 9 files changed, 1851 insertions(+) create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotActivities.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApis.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApprovals.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotChannel.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRules.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotInbox.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgress.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSender.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotWords.kt diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotActivities.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotActivities.kt new file mode 100644 index 00000000..4a67dc74 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotActivities.kt @@ -0,0 +1,31 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import java.util.concurrent.ConcurrentHashMap + +/** + * What each chat has been told of the turns working in it, by chat. + * + * A turn that works on tools is not the only one speaking to a chat: a run moved to the background opens a reply of + * its own for the same chat, and the runtime hands each reply only its own turn's activity. A chat whose messages + * cannot be added to would then hear the same work twice, so the words of a stretch of steps are kept here, by the + * chat the turns speak to, and every reply of that chat tells what is waiting once. + */ +@Singleton +internal class OneBotActivities { + private val byChat = ConcurrentHashMap() + + /** What waits to be told in [chat], which a chat is told of once however many turns are working in it. */ + fun of(chat: ChatAddress): OneBotProgress = byChat.computeIfAbsent(chat) { OneBotProgress() } + + /** + * Forgets [chat] where it holds nothing to tell and no call still running. + * + * A turn that ends asks for this, so a chat that holds nothing is not kept for as long as the channel runs. A chat + * another turn is still working in holds something, and is left as it is. + */ + fun forget(chat: ChatAddress) { + byChat.computeIfPresent(chat) { _, progress -> if (progress.idle()) null else progress } + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApis.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApis.kt new file mode 100644 index 00000000..59e7f46e --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApis.kt @@ -0,0 +1,34 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.onebot.api.OneBotApi +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import java.util.concurrent.ConcurrentHashMap + +/** + * The API of every running OneBot instance, by the instance that serves a chat. + * + * A plugin that acts on a chat names the instance the chat belongs to, and an action of the standard is called on the + * API of that instance. The API itself belongs to one instance of one channel, because each holds its own connection + * and its own account, so a tool that runs once for the whole runtime reads the one it needs from here. + * + * An instance is here while it runs and gone once it stops, so a caller that finds none knows the channel is not + * serving that chat rather than that it never could. + */ +@Singleton +internal class OneBotApis { + private val apis = ConcurrentHashMap() + + /** Records that [instance] is served by [api]. */ + fun register(instance: ChannelInstanceId, api: OneBotApi) { + apis[instance] = api + } + + /** Forgets [instance], which stopped. */ + fun forget(instance: ChannelInstanceId) { + apis.remove(instance) + } + + /** The API of [instance], null when no such instance is running. */ + fun of(instance: ChannelInstanceId): OneBotApi? = apis[instance] +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApprovals.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApprovals.kt new file mode 100644 index 00000000..2f303c1c --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApprovals.kt @@ -0,0 +1,458 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.onebot.i18n.TextKeys +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotChats +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +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.OneBotSegment +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalAnswer +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalRequest +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +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.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatSettings +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.chat.LanguageTag +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.foedusprogramme.alexandrite.sdk.i18n.Texts +import org.foedusprogramme.alexandrite.sdk.tool.ReviewPreview +import org.slf4j.LoggerFactory +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.minutes + +/** + * Shows approval requests as messages an admin answers by replying to them, and takes the first answer within the + * configured time. + * + * The message that carries a request is what names it: the implementation reports what a message answers as an id, so + * the id of the message this desk sent is matched against that and nothing else. Several requests can wait in one chat + * without being confused, because each was carried by a message of its own. A request is also answered by writing its + * id, which is what a client that cannot reply with needs; an id that matches several requests is never guessed at. + * + * A chat other than the one a request was shown in cannot answer it, and a request that two admins answer at once is + * answered by the first of them. + */ +@ChannelInstanceScoped +internal class OneBotApprovals( + private val instance: ChannelInstance, + private val config: OneBotInstanceConfig, + private val sender: OneBotSender, + private val texts: Texts, + /** The language of each chat, null while the runtime has no agent to ask. */ + private val settings: ChatSettings?, +) : Lifecycle { + private val pending = ConcurrentHashMap() + private val work = SupervisorJob(instance.scope.coroutineContext[Job]) + private val scope = CoroutineScope(instance.scope.coroutineContext + work) + + /** The answer of the first admin who answers [request], else how the request ended. */ + suspend fun ask(request: ApprovalRequest): ApprovalAnswer { + val chats = recipients(request) + if (chats.isEmpty()) return UNREACHABLE + val waiting = Pending(request) + if (pending.putIfAbsent(request.id, waiting) != null) { + return ApprovalAnswer.Unavailable("another request has the id ${request.id}") + } + try { + // A chat nobody could be shown in is no reach, and what the platform said is the reason a call was refused + // rather than that no administrator exists. + val why = chats.mapNotNull { post(waiting, it, languageOf(waiting.request, it)) }.firstOrNull() + if (waiting.carried.isEmpty()) { + logger.warn("{} could not show approval {} in any chat: {}", instance.id, request.id, why) + return if (why == + null + ) { + UNREACHABLE + } else { + ApprovalAnswer.Unavailable("the platform took no message of it: $why") + } + } + logger.info( + "{} asks for approval {} of {} in turn {} in {} chat(s)", + instance.id, + request.id, + request.tool, + request.turn.id, + waiting.carried.size, + ) + withTimeoutOrNull(config.approvalMinutes.minutes) { waiting.ending.await() } + waiting.ending.complete(Ending.Expired) + val ending = waiting.ending.await() + conclude(waiting, ending) + return ending.answer + } catch (e: CancellationException) { + waiting.ending.complete(Ending.Withdrawn) + conclude(waiting, Ending.Withdrawn) + throw e + } finally { + pending.remove(request.id) + } + } + + /** Whether [name] is a command this desk answers. */ + fun answers(name: String): Boolean = name == APPROVE || name == DENY + + /** + * Takes the approval command [command] that [by] wrote in [event], and the text to reply with, null when the reply + * would say nothing. + */ + suspend fun answer(event: OneBotEvent.Message, command: OneBotCommands.Parsed, by: ChatUser): String? { + val chat = OneBotChats.addressOf(instance.id, event) + if (!by.isAdmin) return text(TextKeys.APPROVAL_ADMINS_ONLY, language(chat)) + val words = command.arguments.split(' ').filter { it.isNotBlank() } + val always = words.any { it.equals(ALWAYS, ignoreCase = true) } + val code = words.firstOrNull { !it.equals(ALWAYS, ignoreCase = true) } + return when (val resolution = resolve(chat, event, code)) { + Resolution.None -> text(TextKeys.APPROVAL_NOTHING_WAITS, language(chat)) + is Resolution.Listing -> listing(resolution.requests, language(chat)) + is Resolution.Ambiguous -> several(resolution.ids, language(chat)) + is Resolution.One -> decide(resolution.id, by, command.name == APPROVE, always, language(chat)) + } + } + + override suspend fun onDrain() { + work.children.toList().joinAll() + } + + override fun onStop() { + pending.values.forEach { it.ending.complete(Ending.Answered(ApprovalAnswer.Unavailable("the bot stopped"))) } + } + + /** The chats [request] is shown in: the chat of an admin's own turn, else the direct chats of the admins. */ + private fun recipients(request: ApprovalRequest): List { + val turn = request.turn + return if (turn.actorIsAdmin) { + listOf(turn.chat) + } else { + config.admins.distinct().map { OneBotChats.privateChat(instance.id, UserId(it)) } + } + } + + /** Sends the message with [waiting]'s request to [chat], and says what went wrong when it was not shown. */ + private suspend fun post(waiting: Pending, chat: ChatAddress, language: LanguageTag?): String? { + val text = render(waiting.request, language) + // An approval is worth more than one try: an implementation that times out on a message often takes the next + // one, and a request nobody was shown refuses the call for a reason that is nobody's. What the platform said + // is kept, so that a request that reached no chat is not answered as though no admin could be reached. + var why: String? = null + for (attempt in 1..APPROVAL_TRIES) { + val delivery = try { + sender.send(chat, OutboundMessage.builder(text, MessageKind.REPLY).build()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + why = e.message + logger.warn( + "{} could not show approval {} in chat {} (try {}): {}", + instance.id, + waiting.request.id, + chat, + attempt, + e.message, + ) + null + } + val delivered = delivery as? Delivery.Delivered + if (delivered != null) { + val carried = waiting.carried.getOrPut(chat) { ConcurrentHashMap.newKeySet() } + delivered.messages.forEach { carried += it.id } + return null + } + if (delivery != null) { + why = delivery.toString() + logger.warn( + "{} could not show approval {} in chat {} (try {}): {}", + instance.id, + waiting.request.id, + chat, + attempt, + delivery, + ) + } + if (attempt < APPROVAL_TRIES) delay(APPROVAL_RETRY_MILLIS) + } + return why ?: "the platform took no message of it" + } + + /** What the command of [chat] names, read from the message it replied to and else from its id. */ + private fun resolve(chat: ChatAddress, event: OneBotEvent.Message, code: String?): Resolution { + val replied = event.message.segments.filterIsInstance().firstOrNull()?.id?.value + if (replied != null) { + val held = pending.values.filter { replied in (it.carried[chat] ?: emptySet()) } + return when (held.size) { + 1 -> Resolution.One(held.single().request.id) + 0 -> Resolution.None + else -> Resolution.Ambiguous(held.map { it.request.id }) + } + } + val waiting = pending.values.filter { chat in it.carried.keys } + if (code == + null + ) { + return if (waiting.isEmpty()) Resolution.None else Resolution.Listing(waiting.map { it.request }) + } + // An id written out is matched exactly first: a request whose id is the whole of what was written is the one + // meant, even when the id of another request starts with the same letters. + waiting.firstOrNull { it.request.id == code }?.let { return Resolution.One(it.request.id) } + val matched = waiting.filter { it.request.id.startsWith(code) } + return when (matched.size) { + 1 -> Resolution.One(matched.single().request.id) + 0 -> Resolution.None + else -> Resolution.Ambiguous(matched.map { it.request.id }) + } + } + + /** + * Answers the request [id], and what to reply with, null when nothing is said. + * + * An answer that is taken is said by the outcome of the request, which goes to every chat it was shown in, so + * nothing is said in the chat the command was written in as well: that would say the same thing twice. Only an + * answer that could not be taken is said here, because nothing else reports it. + */ + private fun decide(id: String, by: ChatUser, allowed: Boolean, always: Boolean, language: LanguageTag?): String? { + val waiting = pending[id] ?: return text(TextKeys.APPROVAL_NOT_PENDING, language) + val request = waiting.request + val answer = if (allowed) { + ApprovalAnswer.Allowed(by, always && request.always != null) + } else { + ApprovalAnswer.Denied(by) + } + if (!waiting.ending.complete(Ending.Answered(answer))) { + return text(TextKeys.APPROVAL_NOT_PENDING, language) + } + return null + } + + /** The requests that wait in the chat the command was written in. */ + private fun listing(requests: List, language: LanguageTag?): String { + val entries = requests.map { + text( + TextKeys.APPROVAL_LIST_ENTRY, + language, + "id" to it.id, + "tool" to it.tool, + "reason" to it.reason, + ) + } + return listOf(text(TextKeys.APPROVAL_LIST_TITLE, language)) + .plus(entries) + .plus(text(TextKeys.APPROVAL_LIST_HINT, language)) + .joinToString("\n") + } + + /** The ids that a written id matched, which name no one request. */ + private fun several(ids: List, language: LanguageTag?): String = + text(TextKeys.APPROVAL_SEVERAL, language, "ids" to ids.joinToString(", ")) + + /** Says how [waiting] ended, in each chat that was shown it. */ + private fun conclude(waiting: Pending, ending: Ending) { + val request = waiting.request + logger.info("{}: approval {} of turn {} {}", instance.id, request.id, request.turn.id, ending.logged) + // The standard has no way to change a message, so how a request ended is said in a message of its own rather + // than written into the one that carried it. + val chats = waiting.carried.keys.toList() + scope.launch { + for (chat in chats) { + val line = outcome(request, ending, language(chat)) ?: continue + try { + sender.send(chat, OutboundMessage.builder(line, MessageKind.REPLY).build()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn( + "{} could not say how approval {} ended in {}: {}", + instance.id, + request.id, + chat, + e.message, + ) + } + } + } + } + + /** The line that tells how [request] ended in [language], null for no line. */ + private fun outcome(request: ApprovalRequest, ending: Ending, language: LanguageTag?): String? = when (ending) { + Ending.Expired -> text(TextKeys.APPROVAL_OUTCOME_EXPIRED, language, "id" to request.id) + + Ending.Withdrawn -> text(TextKeys.APPROVAL_OUTCOME_WITHDRAWN, language, "id" to request.id) + + is Ending.Answered -> when (val answer = ending.answer) { + is ApprovalAnswer.Allowed -> if (answer.always) { + text( + TextKeys.APPROVAL_OUTCOME_ALLOWED_ALWAYS, + language, + "id" to request.id, + "name" to named(answer.by), + "rule" to request.always?.display.orEmpty(), + ) + } else { + text( + TextKeys.APPROVAL_OUTCOME_ALLOWED, + language, + "id" to request.id, + "name" to named(answer.by), + ) + } + + is ApprovalAnswer.Denied -> text( + TextKeys.APPROVAL_OUTCOME_DENIED, + language, + "id" to request.id, + "name" to named(answer.by), + ) + + else -> null + } + } + + /** [request] as the message an approver reads in [language]. */ + private fun render(request: ApprovalRequest, language: LanguageTag?): String = buildString { + val turn = request.turn + append(text(TextKeys.APPROVAL_TITLE, language, "id" to request.id)).append('\n') + append(text(TextKeys.APPROVAL_AGENT, language, "agent" to turn.agent)).append('\n') + append(text(TextKeys.APPROVAL_CHAT, language, "chat" to turn.chat)).append('\n') + turn.actor?.let { + append(text(TextKeys.APPROVAL_REQUESTER, language, "requester" to it.displayName)).append('\n') + } + append(text(TextKeys.APPROVAL_REASON, language, "reason" to request.reason)).append('\n') + append( + text( + TextKeys.APPROVAL_TOOL, + language, + "tool" to request.tool, + "risk" to request.risk.name.lowercase(), + ), + ).append("\n\n") + append(body(request, language)) + append("\n\n").append(text(TextKeys.APPROVAL_ANSWER, language)) + request.always?.let { + append(' ').append(text(TextKeys.APPROVAL_ALWAYS_RULE, language, "rule" to it.display)) + } + } + + /** What [request] is about in [language]: its preview, else its arguments. */ + private fun body(request: ApprovalRequest, language: LanguageTag?): String = when (val preview = request.preview) { + is ReviewPreview.Diff -> + text(TextKeys.APPROVAL_FILE, language, "path" to preview.path) + "\n\n" + preview.unified + + is ReviewPreview.Command -> buildString { + append(text(TextKeys.APPROVAL_DIRECTORY, language, "directory" to preview.cwd)).append('\n') + append( + text( + TextKeys.APPROVAL_PROGRAM, + language, + "program" to (preview.executable ?: text(TextKeys.APPROVAL_PROGRAM_UNKNOWN, language)), + ), + ) + if (preview.opaque) append(' ').append(text(TextKeys.APPROVAL_OPAQUE, language)) + append("\n\n").append(commandLine(preview.argv)) + } + + is ReviewPreview.Text -> preview.text + + else -> text(TextKeys.APPROVAL_ARGUMENTS, language) + "\n" + request.arguments + } + + /** One request on its way, with the messages that carry it by the chat they are in. */ + private class Pending(val request: ApprovalRequest) { + val ending = CompletableDeferred() + + /** The ids of the messages that carry the request, by the chat each was sent to. */ + val carried = ConcurrentHashMap>() + } + + /** What a command names. */ + private sealed interface Resolution { + data object None : Resolution + + class One(val id: String) : Resolution + + class Ambiguous(val ids: List) : Resolution + + class Listing(val requests: List) : Resolution + } + + private sealed interface Ending { + val answer: ApprovalAnswer + val logged: String + + class Answered(override val answer: ApprovalAnswer) : Ending { + override val logged: String + get() = when (answer) { + is ApprovalAnswer.Allowed -> "is allowed by ${answer.by.address}" + is ApprovalAnswer.Denied -> "is denied by ${answer.by.address}" + else -> "ends: $answer" + } + } + + data object Expired : Ending { + override val answer: ApprovalAnswer = ApprovalAnswer.Expired + override val logged: String = "expired" + } + + data object Withdrawn : Ending { + override val answer: ApprovalAnswer = ApprovalAnswer.Unavailable("the request was withdrawn") + override val logged: String = "is withdrawn" + } + } + + private companion object { + const val APPROVE = "approve" + const val DENY = "deny" + const val ALWAYS = "always" + + val UNREACHABLE = ApprovalAnswer.Unavailable("no administrator can be reached") + + /** How often the message that carries a request is tried, and how long between two tries. */ + const val APPROVAL_TRIES: Int = 3 + + const val APPROVAL_RETRY_MILLIS: Long = 1_000 + } + + /** [key] in [language], with [args] put where they are named. */ + private fun text(key: String, language: LanguageTag?, vararg args: Pair): String = + texts.text(key, language, *args) + + /** The language of [chat], null when the runtime has no agent to ask and the host's language applies. */ + private suspend fun language(chat: ChatAddress): LanguageTag? = try { + settings?.language(chat) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.debug("The language of chat {} is unknown: {}", chat, e.toString()) + null + } + + /** The language [request] is shown in [chat] with: the chat of its own turn keeps the language of that turn. */ + private suspend fun languageOf(request: ApprovalRequest, chat: ChatAddress): LanguageTag? = + if (chat == request.turn.chat) request.turn.language else language(chat) +} + +/** [user]'s name as an approver sees it. */ +private fun named(user: ChatUser): String = user.displayName + user.username?.let { " (@$it)" }.orEmpty() + +/** [argv] as one command line, each word that a shell would split or expand in single quotes. */ +private fun commandLine(argv: List): String = argv.joinToString(" ") { word -> + val plain = word.isNotEmpty() && word.all { it.isLetterOrDigit() || it in PLAIN } + if (plain) word else "'${word.replace("'", "'\\''")}'" +} + +/** The characters a shell takes as part of a word. */ +private const val PLAIN = "-_./=:,+@%" + +private val logger = LoggerFactory.getLogger(OneBotApprovals::class.java) 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 00000000..baed1f12 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotChannel.kt @@ -0,0 +1,276 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +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.sdk.channel.ApprovalAnswer +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalRequest +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.DelegatedRun +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.Markup +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.channel.ReplyActivity +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.channel.ToolCallStatus +import org.foedusprogramme.alexandrite.sdk.channel.TurnEvent +import org.foedusprogramme.alexandrite.sdk.channel.rebuild +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.i18n.Texts +import org.foedusprogramme.alexandrite.sdk.transcript.AssistantEntry +import org.foedusprogramme.alexandrite.sdk.transcript.TextPart +import org.foedusprogramme.alexandrite.sdk.turn.TurnSubmitter +import org.slf4j.LoggerFactory +import kotlinx.coroutines.channels.Channel as Signal + +/** + * 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. + * + * What the model says while it works reaches a chat as whole messages, one per request of the turn: the hook of + * [OneBotWordsHook] hands each response to the reply of its turn as the response ends, because a message of this + * platform cannot be added to after it is sent. + */ +@ChannelInstanceScoped +@Contribute(Channel::class, name = "onebot") +internal class OneBotChannel( + private val instance: ChannelInstance, + private val config: OneBotInstanceConfig, + private val sender: OneBotSender, + /** The words this instance says to people. */ + private val texts: Texts, + + /** The approvals this instance asks its admins about. */ + private val approvals: OneBotApprovals, + /** The chats this instance serves, which decides whether it speaks unprompted. */ + private val gate: OneBotAccessGate, + /** The agent, null while the runtime has none. */ + private val submitter: TurnSubmitter?, + /** The replies of this instance's turns, which the words of each response are handed to. */ + private val replies: OneBotReplyRegistry, + /** What waits to be told in each conversation, which every reply of that conversation shares. */ + private val activities: OneBotActivities, + private val rules: OneBotCommandRules, +) : Channel { + override suspend fun capabilities(chat: ChatAddress): ChannelCapabilities = OneBotMessages.capabilities( + proactive = gate.isPaired(chat), + progress = config.progress, + streaming = config.streaming, + ) + + 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).also { replies.open(request.turn.id, it) } + + override suspend fun send(chat: ChatAddress, message: OutboundMessage): Delivery = sender.send(chat, message) + + override suspend fun askApproval(request: ApprovalRequest): ApprovalAnswer = approvals.ask(request) + + override fun mayRun(chat: ChatAddress, issuer: ChatUser, command: String, withArguments: Boolean): Boolean = + rules.mayRun(chat, issuer.isAdmin, command, withArguments) + + /** + * The reply of one turn, which delivers the words of each of the turn's requests and the final message. + * + * A message of this platform cannot be added to later, so [showResponse] sends a response whole, once, as the hook + * hands it over. [preview] keeps nothing: the growing text a channel that can change its messages shows is not what + * this one says. + */ + private class Sink(private val channel: OneBotChannel, private val request: ReplyRequest) : + ReplySink, + OneBotWords { + // The steps of every turn working in this chat wait here, so a chat hears them once. + private val progress = channel.activities.of(request.turn.chat) + + override suspend fun detachActivity(run: DelegatedRun): ReplyActivity? { + if (!channel.config.progress) return null + return Detached(run) + } + + /** The activity of a background run, told as the notices OneBot already uses for a working turn. */ + private inner class Detached(private val run: DelegatedRun) : ReplyActivity { + override suspend fun event(event: TurnEvent) { + val started = event as? TurnEvent.ToolCallStarted ?: return + if (started.run != run.run) return + post( + startedMessage( + ProgressCall(started.tool, started.activity, started.title, started.agent, started.run), + channel.texts, + request.turn.language, + ), + ) + } + + override suspend fun close(status: ToolCallStatus) = Unit + } + + /** What the sender beside the turn is to say next, under [lock] because an ending reply writes it too. */ + private val lock = Any() + private var waiting: String? = null + + /** Woken whenever something waits to be said. */ + private val wake = Signal(Signal.CONFLATED) + + /** The sender working beside the turn, null until something waits to be said. */ + private var sender: Job? = null + + override suspend fun preview(segment: Int, text: String) { + if (!channel.config.streaming) return + // The previews of a growing segment are frequent and are never sent, so only a reader of the debug log + // is told of them. + logger.debug( + "The preview of segment {} ({} characters) is not sent; the response is sent as it ends", + segment, + text.length, + ) + } + + override suspend fun showResponse(message: AssistantEntry) { + val said = message.parts.filterIsInstance().joinToString("\n\n") { it.text } + if (said.isBlank()) return + logger.info( + "The response of {} ({} characters) goes to {}", + request.turn.id, + said.length, + request.turn.chat, + ) + // The model writes Markdown, and a message of this platform shows none: the channel renders it as the + // plain text a chat reads, as it does for the answer that ends a turn. + say(said, Markup.MARKDOWN) + } + + override suspend fun event(event: TurnEvent) { + if (!channel.config.progress) return + when (event) { + is TurnEvent.ToolCallStarted -> { + val call = progress.started( + event.id, + event.activity, + event.agent, + tool = event.tool, + title = event.title, + run = event.run, + ) ?: return + logger.info("The first call of {} is told: {}", request.turn.id, event.tool) + post(startedMessage(call, channel.texts, request.turn.language)) + } + + is TurnEvent.ToolCallUpdated -> { + if (event.status !in ENDED) return + progress.ended(event.id, event.status, event.content, event.run) + logger.debug("The call {} ended, and {} steps wait to be told", event.id, progress.counted()) + val tally = progress.take() + if (tally == null) { + logger.debug("What ended is held back until the interval passes") + return + } + post(summaryMessage(tally, channel.texts, request.turn.language)) + } + + else -> return + } + } + + /** + * Hands [said] to the sender beside the turn, which this returns from at once. + * + * A turn waits for every event it is handed, and sending here would hold it for one call of the platform per + * message about its own work. The sender takes what waits and sends it beside the turn instead, so nothing the + * turn does waits on a message about it. + */ + private fun post(said: String) { + synchronized(lock) { waiting = said } + if (sender == null) { + sender = channel.instance.scope.launch(CoroutineName("onebot activity of ${request.turn.id}")) { + while (true) { + wake.receive() + while (true) { + val next = synchronized(lock) { waiting?.also { waiting = null } } ?: break + try { + say(next) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn( + "An activity of {} did not reach {}: {}", + request.turn.id, + request.turn.chat, + e.toString(), + ) + } + } + } + } + } + wake.trySend(Unit) + } + + /** Sends what the sender holds, and stops it, so that nothing of it is still on its way. */ + private suspend fun settle() { + val last = synchronized(lock) { waiting?.also { waiting = null } } + sender?.let { + it.cancel() + it.join() + } + sender = null + if (last != null) say(last) + } + + /** Sends [said] to the chat of this turn as a message of its own, in [markup]. */ + private suspend fun say(said: String, markup: Markup = Markup.PLAIN) { + val message = OutboundMessage.builder(said, MessageKind.REPLY).markup(markup).build() + channel.sender.send(request.turn.chat, message) + } + + override suspend fun complete(message: OutboundMessage): Delivery { + logger.info( + "The turn of {} ended: its {} waiting steps are told before the answer, and its reply closes", + request.turn.chat, + progress.counted(), + ) + settle() + progress.flush()?.let { say(summaryMessage(it, channel.texts, request.turn.language)) } + channel.replies.close(request.turn.id) + channel.activities.forget(request.turn.chat) + return channel.sender.send(request.turn.chat, withTrigger(message)) + } + + /** [message] as the answer to the message this turn answers, so the chat shows it as a quote reply. */ + private fun withTrigger(message: OutboundMessage): OutboundMessage { + val trigger = request.trigger ?: return message + if (message.replyTo != null) return message + return message.rebuild { replyTo(trigger) } + } + + override suspend fun abandon(end: ReplyEnd) { + logger.info( + "The turn of {} was abandoned: its {} waiting steps are told, and its reply closes", + request.turn.chat, + progress.counted(), + ) + settle() + progress.flush()?.let { say(summaryMessage(it, channel.texts, request.turn.language)) } + channel.replies.close(request.turn.id) + channel.activities.forget(request.turn.chat) + } + } +} + +private val logger = LoggerFactory.getLogger(OneBotChannel::class.java) diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRules.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRules.kt new file mode 100644 index 00000000..b4d54f05 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRules.kt @@ -0,0 +1,99 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.onebot.i18n.TextKeys +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.turn.AgentControl +import org.foedusprogramme.alexandrite.sdk.turn.CommandHandler +import org.foedusprogramme.alexandrite.sdk.turn.CommandInvocation +import java.util.Locale + +/** Who may run a command. */ +internal enum class Who { + ANYONE, + + /** Anyone in a direct chat, admins elsewhere. */ + DIRECT, + + /** Anyone in a direct chat linked with no other chat, admins elsewhere. */ + UNLINKED_DIRECT, + + ADMIN, +} + +/** Who may run a command: [bare] without arguments, [withArguments] with them. */ +internal class CommandRule(val bare: Who, val withArguments: Who = bare) + +/** + * Who may run each command that a handler claims, after [MATRIX]: admins alone for a claimed command it does not list. + * + * Nothing else checks this. The agent's control takes the operator of a change as a parameter and does not judge it, + * so a channel that does not ask this question lets anyone who can write in a chat change the agent's model, its + * language, and the agent a chat speaks to. + * + * The rules are the same ones the Telegram channel applies, and this is a copy of them rather than the same code: they + * belong with the rest of what every channel shares, and they are here until that is decided. + */ +@Singleton +internal class OneBotCommandRules(handlers: List, private val control: AgentControl?) { + private val claimed = handlers.flatMap { it.commands }.mapTo(HashSet()) { it.name.lowercase(Locale.ROOT) } + + /** The key of the refusal of [invocation] in a chat of [kind], null when its issuer may run it. */ + fun refusal(invocation: CommandInvocation, kind: ChatKind): String? { + val name = invocation.name.lowercase(Locale.ROOT) + if (invocation.issuer.isAdmin || name !in claimed) return null + val rule = MATRIX[name] ?: ADMIN_ONLY + val direct = kind == ChatKind.DIRECT + return when (if (invocation.arguments.isBlank()) rule.bare else rule.withArguments) { + Who.ANYONE -> null + Who.DIRECT -> TextKeys.REFUSED_ADMIN_HERE.takeUnless { direct } + Who.UNLINKED_DIRECT -> TextKeys.REFUSED_ADMIN_HERE.takeUnless { direct && !linked(invocation.chat) } + Who.ADMIN -> TextKeys.REFUSED_ADMIN + } + } + + /** Whether an admin, or a member when [admin] is false, may run [command] in [chat]. */ + fun mayRun(chat: ChatAddress, admin: Boolean, command: String, withArguments: Boolean): Boolean { + val name = command.lowercase(Locale.ROOT) + if (admin || name !in claimed) return true + val rule = MATRIX[name] ?: ADMIN_ONLY + val direct = !OneBotMessages.isGroup(chat) + return when (if (withArguments) rule.withArguments else rule.bare) { + Who.ANYONE -> true + Who.DIRECT -> direct + Who.UNLINKED_DIRECT -> direct && !linked(chat) + Who.ADMIN -> false + } + } + + private fun linked(chat: ChatAddress): Boolean = control?.links().orEmpty().any { group -> + (group.members + group.anchor).any { it.parent == chat.parent } + } + + internal companion object { + /** The rules of the default commands. */ + val MATRIX: Map = mapOf( + "help" to CommandRule(Who.ANYONE), + "settings" to CommandRule(Who.ANYONE), + "context" to CommandRule(Who.ANYONE), + "profiles" to CommandRule(Who.ANYONE), + "agent" to CommandRule(Who.ANYONE, Who.DIRECT), + "reasoning" to CommandRule(Who.ANYONE, Who.UNLINKED_DIRECT), + "language" to CommandRule(Who.ANYONE, Who.UNLINKED_DIRECT), + "model" to CommandRule(Who.ANYONE, Who.ADMIN), + "mode" to CommandRule(Who.ANYONE, Who.ADMIN), + "jobs" to CommandRule(Who.ANYONE, Who.ADMIN), + "new" to CommandRule(Who.UNLINKED_DIRECT), + "compact" to CommandRule(Who.UNLINKED_DIRECT), + "cancel" to CommandRule(Who.DIRECT), + "trust" to CommandRule(Who.ADMIN), + "untrust" to CommandRule(Who.ADMIN), + "allow" to CommandRule(Who.ADMIN), + "streaming" to CommandRule(Who.ANYONE, Who.DIRECT), + ) + + private val ADMIN_ONLY = CommandRule(Who.ADMIN) + } +} 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 00000000..f0ec57a2 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotInbox.kt @@ -0,0 +1,318 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.foedusprogramme.alexandrite.channel.onebot.api.OneBotApi +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotChats +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotNotices +import org.foedusprogramme.alexandrite.channel.onebot.mapping.QuotedMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotMessageInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotWire +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +import org.foedusprogramme.alexandrite.sdk.channel.ChannelPoints +import org.foedusprogramme.alexandrite.sdk.channel.IncomingMessage +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.foedusprogramme.alexandrite.sdk.hook.Hooks +import org.foedusprogramme.alexandrite.sdk.i18n.Texts +import org.foedusprogramme.alexandrite.sdk.turn.Admission +import org.foedusprogramme.alexandrite.sdk.turn.Capacity +import org.foedusprogramme.alexandrite.sdk.turn.CommandInvocation +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 the bot is addressed by, and a command wherever it appears, becomes a submission that the agent queues as + * a turn of its chat. Addressed means what it means to every channel of this project: a direct chat always, and a + * group when the message names the bot or answers one of its messages. Everything else a group says is not a turn, + * which is what keeps the traffic of a group out of the agent. + * + * 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, + /** What this instance sends, which answers an approval command. */ + private val sender: OneBotSender, + /** The approvals this instance asks its admins about. */ + private val approvals: OneBotApprovals, + /** The messages this instance sent, which tell a reply to the bot from a reply to somebody else. */ + private val sent: OneBotSentMessages, + /** What this instance read not long ago, which is what a message answering one says it answered. */ + private val recent: OneBotRecentMessages, + /** Who may run each command, which nothing else asks. */ + private val rules: OneBotCommandRules, + /** The chats this instance serves, and the code a chat it does not serve is offered. */ + private val gate: OneBotAccessGate, + /** The texts of this channel for people. */ + private val texts: Texts, + /** What reads the bytes of an image or a voice message the implementation reported. */ + private val media: OneBotMedia, + /** Where a report that is not a message is published for the plugins that subscribe to it. */ + private val hooks: Hooks, + /** The API of this instance, which a tool of the runtime reads to act on a chat of it. */ + private val api: OneBotApi, + /** Where this instance is served from, for whoever acts on its chats. */ + private val apis: OneBotApis, + /** The agent, null while the runtime has none. */ + private val submitter: TurnSubmitter?, +) : Lifecycle { + private var reading: Job? = null + + override suspend fun onStart() { + // The instance is served from here on, whatever else it does: a tool that acts on a chat of it finds its API + // whether or not the runtime has an agent to take its messages. + apis.register(instance.id, api) + 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 + apis.forget(instance.id) + } + + private suspend fun read() { + link.events.collect { event -> + // A report of anything but a message is published for whoever cares: it is not a turn, and nothing else + // would carry it to a plugin. It is published whether or not the chat is served, because a chat that is not + // served yet is where somebody asks to join. + if (event !is OneBotEvent.Message) { + OneBotNotices.of(instance.id, event, event.selfId.value)?.let { hooks.fire(ChannelPoints.NOTICE, it) } + return@collect + } + val chat = OneBotChats.addressOf(instance.id, event) + // Every message is kept as it arrives, whether or not it is meant for the bot: what a later message answers + // is usually one of these, and the one it answers need not have been meant for the bot at all. + recent.remember(chat, event.messageId.value, OneBotMessages.quotable(instance.id, event)) + // A command is run wherever it is written; anything else has to be meant for the bot, or a group's talk + // would each become a turn of the agent. + val command = OneBotCommands.of(event) + // A reply names the message it answers, and after a restart the sent ids are gone; reading the message + // back tells whether the bot wrote it, so a restart does not make its old messages unrecognizable. + val answered = quoted(chat, event) + val addressed = addressed(event, answered) + if (command == null && !addressed) { + logger.debug("{}: a message in {} names nobody of this instance, and is no command", instance.id, chat) + return@collect + } + // A message that only names the bot says nothing to answer: the Telegram channel ignores one, and so does + // this one, unless it also carries media, quotes a message, or asks for a command. + if (command == null && !saysSomething(event)) { + logger.debug("{}: a message in {} names the bot and says nothing; no turn is asked", instance.id, chat) + return@collect + } + // Nothing of a chat that is not paired passes, commands included: a bot that was invited somewhere answers + // and obeys nobody until somebody says it may. + val asked = OneBotMessages.senderOf(instance.id, event, config.admins) + val kind = if (event is OneBotEvent.Message.Private) ChatKind.DIRECT else ChatKind.GROUP + val ref = ChannelMessageRef(chat, event.messageId.value) + if (!gate.admit(chat, kind, null, ref, asked, addressed)) return@collect + // An approval is answered here rather than by the agent: the command registry of the runtime knows nothing + // of it, and a command it does not claim is refused to everyone but an operator. + if (command != null && approvals.answers(command.name)) { + val answer = approvals.answer(event, command, asked) + if (answer != null) { + sender.send(chat, said(answer)) + } + return@collect + } + val message = OneBotMessages.incoming(instance.id, event, config.admins, answered, media) + if (command != null) { + // Who may run a command is the channel's question to ask: the agent's control takes the operator of a + // change as a parameter and does not judge it. + val invocation = invocationOf(command, message) + val refusal = rules.refusal(invocation, message.chatInfo.kind) + if (refusal != null) { + sender.send(chat, said(text(refusal, "command" to invocation.name.lowercase()))) + return@collect + } + } + val submission = if (command == null) Submission.Message(message) else commandOf(command, message) + logger.info( + "{}: {} in {} from {} becomes a turn for the agent", + instance.id, + if (command == null) "a message" else command.name, + chat, + asked.address, + ) + report(message, submitter?.submit(submission)) + } + } + + /** Whether [event] carries anything but the mention that addressed the bot: text, media, or a quote. */ + private fun saysSomething(event: OneBotEvent.Message): Boolean = event.message.segments.any { segment -> + when (segment) { + is OneBotSegment.Text -> segment.text.isNotBlank() + is OneBotSegment.Image, is OneBotSegment.Record, is OneBotSegment.Video, is OneBotSegment.File -> true + is OneBotSegment.Reply -> true + else -> false + } + } + + /** + * What [event] answers, as the channel read it, null when it answers nothing. + * + * What the channel kept is read first, which is what a chat normally answers. Only a message older than what is + * kept is read back from the implementation, and that read can fail: a message may be withdrawn, or unknown to the + * implementation, and a chat goes on whether or not what a message quoted can be read. + */ + private suspend fun quoted(chat: ChatAddress, event: OneBotEvent.Message): QuotedMessage? { + val id = event.message.segments.filterIsInstance().firstOrNull()?.id ?: return null + recent.find(chat, id.value)?.let { return it } + val result = try { + link.call("get_msg", GetMessage(id).toJson()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.debug("{} cannot read what message {} answers: {}", instance.id, event.messageId, e.toString()) + return null + } + val data = (result as? OneBotResult.Ok)?.data ?: return null + return try { + val info = OneBotWire.decodeFromJsonElement(OneBotMessageInfo.serializer(), data) + OneBotMessages.quotable(instance.id, info, event.selfId.value).also { recent.remember(chat, id.value, it) } + } catch (e: Exception) { + logger.debug("{} cannot read the report about message {}: {}", instance.id, id, e.toString()) + null + } + } + + /** The message that says [text] as an answer to a command rather than as the answer of a turn. */ + private fun said(text: String): OutboundMessage = OutboundMessage.builder(text, MessageKind.REPLY).build() + + /** [text] in the host's language, with [args] put where they are named. */ + private fun text(key: String, vararg args: Pair): String = texts.text(key, null, *args) + + /** The invocation [command] makes in [message]. */ + private fun invocationOf(command: OneBotCommands.Parsed, message: IncomingMessage): CommandInvocation = + CommandInvocation.builder(message.chat, command.name, message.sender) + .arguments(command.arguments) + .trigger(message.ref) + .build() + + /** The submission of [command] in [message], which an operator's commands pass the queue with. */ + private fun commandOf(command: OneBotCommands.Parsed, message: IncomingMessage): Submission.Command = + Submission.Command( + invocation = invocationOf(command, message), + message = message, + capacity = if (message.sender.isAdmin) Capacity.EXEMPT else Capacity.COUNTED, + ) + + /** Whether the bot of this instance is addressed by [event], where [answered] is what it answered. */ + private fun addressed(event: OneBotEvent.Message, answered: QuotedMessage?): Boolean = OneBotCommands.addressed( + event = event, + isDirect = event is OneBotEvent.Message.Private, + namesTheBot = { selfId -> event.message.segments.any { it is OneBotSegment.At && it.names(selfId) } }, + answersTheBot = { id -> sent.holds(id) || (answered?.authorIsBot == true) }, + ) + + /** + * Reports a submission the agent refused. + * + * A message the agent refuses never becomes a turn, so it is said here rather than dropped in silence: the reason + * is what tells an operator that the chat is not linked, which nothing else reports. + */ + private fun report(message: IncomingMessage, admission: Admission?) { + if (admission !is Admission.Refused) return + logger.warn( + "{}: instance {} took a message of {} no further: {}", + PLUGIN, + instance.id, + message.chat, + admission.reason, + ) + } + + private companion object { + const val PLUGIN = "alexandrite-channel-onebot" + + val logger: Logger = LoggerFactory.getLogger(OneBotInbox::class.java) + } +} + +/** + * The commands of a message, and whether the bot is addressed by one. + * + * A command starts a message and is named after a `/`, which is how every channel of this project spells one: the + * prefix is configurable nowhere yet, so it is not configurable here either. The name is written as one is on the + * platform the other channel serves: ASCII letters, digits and underscores, one to 32 of them, optionally followed by + * `@target`. Text that starts with a slash but holds no such name is not a command, so a group message like + * `/随便说点什么` is not heard by the bot unless it names the bot otherwise, as it is not on that platform. + */ +internal object OneBotCommands { + /** The prefix a command is named after. */ + const val PREFIX = "/" + + /** A command token: the prefix, a name, and optionally the target a name belongs to. */ + private val TOKEN = Regex(PREFIX + "([A-Za-z0-9_]{1,32})(?:@[A-Za-z0-9_]{1,32})?") + + /** The command [event] carries, null when its text names none. */ + fun of(event: OneBotEvent.Message): Parsed? { + val text = textOf(event).trimStart() + val token = text.takeWhile { !it.isWhitespace() } + val match = TOKEN.matchEntire(token) ?: return null + return Parsed(match.groupValues[1], text.removePrefix(token).trim()) + } + + /** + * Whether [event] is a message the bot is addressed by. + * + * A direct chat is the bot's. A group is the bot's when the message names it or answers one of its messages, and + * what it answers is told by [answersTheBot] rather than by the message, which names the id it answers and not + * the author of it. + */ + fun addressed( + event: OneBotEvent.Message, + isDirect: Boolean, + namesTheBot: (String) -> Boolean, + answersTheBot: (String) -> Boolean, + ): Boolean { + if (isDirect) return true + val selfId = event.selfId.value + if (namesTheBot(selfId)) return true + val answered = event.message.segments.filterIsInstance().firstOrNull() ?: return false + return answersTheBot(answered.id.value) + } + + /** The text of [event], which is every text segment of it and nothing else. */ + private fun textOf(event: OneBotEvent.Message): String = + event.message.segments.filterIsInstance().joinToString("") { it.text } + + /** The parts of a command: its [name] and its [arguments], which may be empty. */ + internal class Parsed(val name: String, val arguments: String) +} + +/** Whether [this] mention names [selfId], or names everyone. */ +private fun OneBotSegment.At.names(selfId: String): Boolean = isAll || userId == selfId diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgress.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgress.kt new file mode 100644 index 00000000..d27401a5 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgress.kt @@ -0,0 +1,361 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.onebot.i18n.TextKeys +import org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus +import org.foedusprogramme.alexandrite.sdk.channel.TurnEvent +import org.foedusprogramme.alexandrite.sdk.chat.AgentId +import org.foedusprogramme.alexandrite.sdk.chat.LanguageTag +import org.foedusprogramme.alexandrite.sdk.chat.RunId +import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId +import org.foedusprogramme.alexandrite.sdk.i18n.Texts +import org.foedusprogramme.alexandrite.sdk.tool.ToolActivity + +/** The statuses a call has ended with, after which it is spoken of in the past tense. */ +internal val ENDED: Set = + setOf(ToolCallStatus.COMPLETED, ToolCallStatus.FAILED, ToolCallStatus.BACKGROUNDED) + +/** + * The statuses a call is worth a line of its own for: the ones a summary line cannot say. + * + * A chat whose messages cannot be added to is told of its work in messages of its own, so a line per call would say + * what the summary above it already said, once per window. What a summary cannot say, and so is told, is that a call + * failed, was moved to the background, waits for an approval or did not run at all. + */ +private val NOTABLE: Set = + setOf(ToolCallStatus.FAILED, ToolCallStatus.BACKGROUNDED, ToolCallStatus.WAITING_APPROVAL, ToolCallStatus.NOT_RUN) + +/** The tool of a call that runs a sub-agent, whose line names the run rather than what it did. */ +private const val DELEGATE_TOOL: String = "agent.delegate" + +/** The characters one message about a stretch of steps may hold before its oldest lines are left out. */ +internal const val MAX_ACTIVITY_CHARS: Int = 1500 + +/** What separates the work of one agent from the work of another in the summary line of a message. */ +private const val AGENT_SEPARATOR: String = " / " + +/** What separates two kinds of work of the same agent in the summary line of a message. */ +private const val KIND_SEPARATOR: String = " · " + +/** + * One call of a turn, as much of it as a chat is told: what it is, how it is doing, and what it left behind. + * + * [run] is the sub-agent run a call belongs to, which a delegation is named by so that a chat tells two of them apart. + */ +internal class ProgressCall( + val tool: String, + val activity: ToolActivity, + val title: String, + val agent: AgentId?, + var run: RunId? = null, +) { + /** How the call is doing, which the update of the call sets. */ + var status: ToolCallStatus = ToolCallStatus.PENDING + + /** What the call left behind, which the update of the call sets. */ + var content: TurnEvent.Content? = null + + /** Whether the call delegates its task to a sub-agent, whose run its title names. */ + val delegates: Boolean get() = tool == DELEGATE_TOOL + + /** Whether the line of this call belongs to a sub-agent rather than to the turn itself. */ + val by: AgentId? get() = agent +} + +/** How many calls of each kind a conversation ended since the last message about it. */ +internal class ProgressTally(val steps: List) + +/** + * What a conversation has done since the last thing said about it. + * + * A chat whose messages cannot be rewritten is told of the work in messages of its own, each a line that sums up the + * kinds of call in it and a line for each call, the newest kept and the oldest left out when the message would be too + * long. A call is told as it starts and again as it ends, at most once per interval. + */ +internal class OneBotProgress( + private val intervalMillis: Long = DEFAULT_INTERVAL_MS, + private val now: () -> Long = System::currentTimeMillis, +) { + private var toldAt: Long? = null + + /** How long this conversation waits now, which grows while it keeps working and starts over when it falls quiet. */ + private var interval: Long = intervalMillis + private var opened = false + private val calls = HashMap() + private val ended = ArrayList() + + /** + * Notes that the call [id] of [activity] started, taken by [agent], which is null for a call of the turn itself. + * + * The call is returned when it is the first of its conversation, which is the one told at once, and null after. + */ + fun started( + id: ToolCallId, + activity: ToolActivity, + agent: AgentId?, + tool: String = "", + title: String = "", + run: RunId? = null, + ): ProgressCall? { + val call = ProgressCall(tool, activity, title, agent, run) + calls[id] = call + if (opened) return null + opened = true + toldAt = now() + return call + } + + /** Notes that the call [id] ended with [status] and [content], and keeps it to be told. */ + fun ended( + id: ToolCallId, + status: ToolCallStatus = ToolCallStatus.COMPLETED, + content: TurnEvent.Content? = null, + run: RunId? = null, + ) { + val call = calls.remove(id) ?: return + call.status = status + call.content = content + call.run = run ?: call.run + ended += call + } + + /** + * What waits to be told whether or not the interval has passed, for a reply that is ending. + * + * Nothing waits once the reply is over, so its last steps are told rather than kept for a window that will not + * come. + */ + fun flush(): ProgressTally? { + if (ended.isEmpty()) return null + toldAt = now() + return ProgressTally(ended.toList()).also { ended.clear() } + } + + /** What waits to be told, when it is worth telling: null while nothing waits or the interval has not passed. */ + fun take(): ProgressTally? { + if (ended.isEmpty()) return null + val told = toldAt + val at = now() + if (told != null && at - told < interval) return null + // A conversation that keeps working is told less often, up to a ceiling; one that fell quiet starts over. A + // burst of sub-agents then does not answer with a message every few seconds, and a slow turn is not left + // silent for a minute. + interval = if (told != null && at - told > interval * 2) { + DEFAULT_INTERVAL_MS + } else { + minOf(interval * 2, DEFAULT_MAX_INTERVAL_MS) + } + toldAt = at + return ProgressTally(ended.toList()).also { ended.clear() } + } + + /** How many calls ended and wait to be told. */ + fun counted(): Int = ended.size + + /** + * Whether this conversation holds nothing to tell and no call still running. + * + * Only the pacing of what is told is kept besides, so a conversation that holds nothing may be forgotten rather + * than kept for as long as the channel runs. + */ + fun idle(): Boolean = calls.isEmpty() && ended.isEmpty() + + internal companion object { + /** How long a conversation works quietly before what it has ended since is told again. */ + const val DEFAULT_INTERVAL_MS: Long = 8_000 + + /** The longest a conversation that keeps working waits between two messages about it. */ + const val DEFAULT_MAX_INTERVAL_MS: Long = 60_000 + } +} + +/** What one call says as it starts: the present tense of what the channel has words for, and the call's own line. */ +internal fun startedMessage(call: ProgressCall, texts: Texts, language: LanguageTag?): String = + activityMessage(listOf(call), texts, language) + +/** + * What the calls of [tally] say once they ended: one line that sums up their kinds, then a line for each call. + * + * Lines the message cannot hold are left out from the oldest, which the line above them counts. + */ +internal fun summaryMessage(tally: ProgressTally, texts: Texts, language: LanguageTag?): String = + activityMessage(tally.steps, texts, language) + +/** [calls] as one message: their summary line, then their own lines. */ +internal fun activityMessage(calls: List, texts: Texts, language: LanguageTag?): String { + val summary = summaryLine(calls, texts, language) + val lines = calls + // A delegation has no line of its own: the summary already names the run it stands for and says whether it + // still runs, so a line about the call would only say the same thing again in the words of the tool. + .filter { it.status in NOTABLE && !it.delegates } + .mapNotNull { callLine(it, texts, language) } + .filter { it.isNotBlank() } + if (lines.isEmpty()) return summary + val room = MAX_ACTIVITY_CHARS - summary.length - 1 + var used = 0 + var kept = 0 + for (line in lines.asReversed()) { + val next = used + line.length + if (kept > 0) 1 else 0 + val omitted = lines.size - kept - 1 + val notice = if (omitted > 0) omittedNotice(omitted, texts, language).length + 1 else 0 + if (next + notice > room) break + used = next + kept++ + } + val omitted = lines.size - kept + val shown = lines.takeLast(kept) + val details = (if (omitted > 0) listOf(omittedNotice(omitted, texts, language)) + shown else shown) + .joinToString("\n") + return "$summary\n$details" +} + +/** + * The kinds of [calls] in the order each first appears, each delegation alone and each agent apart. + * + * A chat reads who did what rather than one count of everything a turn and its sub-agents did together, so the calls + * of a sub-agent are counted on their own. + */ +private fun groupedByKind(calls: List): Collection> { + val byKind = LinkedHashMap, MutableList>() + for (call in calls) { + val kind: Any = if (call.delegates) call else call.activity + byKind.getOrPut(call.by to kind) { mutableListOf() } += call + } + return byKind.values +} + +/** + * One phrase per kind of [calls], the work of each agent kept together, as `使用了 2 个工具 · 读取了 3 个文件`. + * + * The kinds of one agent are joined by a middle dot and the work of another agent stands beside them after a slash, + * so that a chat reads where the turn's own work ends and a sub-agent's begins. + */ +private fun summaryLine(calls: List, texts: Texts, language: LanguageTag?): String { + val byAgent = LinkedHashMap>>() + for (group in groupedByKind(calls)) byAgent.getOrPut(group.first().by) { mutableListOf() } += group + return byAgent.entries.joinToString(AGENT_SEPARATOR) { (agent, kinds) -> + val said = kinds.joinToString(KIND_SEPARATOR) { group -> + val first = group.first() + if (first.delegates) delegation(first, texts, language) else counted(group, texts, language) + } + // A delegation already names its run, where the turn's own calls need no name: only a sub-agent's other calls + // are named, so that a chat reads whose they were. + if (agent == null) { + said + } else { + texts.text(TextKeys.ACTIVITY_CALL_BY_AGENT, language, "agent" to agent.value, "line" to said) + } + } +} + +/** What a kind of [group] says: its verb in the tense of the group, and how many of it there were. */ +private fun counted(group: List, texts: Texts, language: LanguageTag?): String { + val activity = group.first().activity + val ran = group.filterNot { it.status == ToolCallStatus.NOT_RUN } + if (ran.isEmpty()) { + val noun = noun(activity, group.size, texts, language) + return texts.text(TextKeys.ACTIVITY_SUMMARY_NOT_RUN, language, "noun" to noun) + } + val past = ran.all { it.status in ENDED } + val verb = texts.text("activity.verb.${kind(activity)}.${if (past) "past" else "present"}", language) + return "$verb ${noun(activity, ran.size, texts, language)}" +} + +/** The delegation [call] stood for, named by the run it belongs to rather than by what it did. */ +private fun delegation(call: ProgressCall, texts: Texts, language: LanguageTag?): String { + val key = if (call.status in ENDED) TextKeys.ACTIVITY_DELEGATE_PAST else TextKeys.ACTIVITY_DELEGATE_PRESENT + return texts.text(key, language, "agent" to named(call)) +} + +/** What [call] is named by: its title, with the run beside it where the title does not already name that run. */ +private fun named(call: ProgressCall): String { + val run = call.run ?: return call.title + return if (call.title.contains(run.value)) call.title else "${call.title} (${run.value})" +} + +/** What one call did to something, as the words of its kind and the count of it. */ +private fun noun(activity: ToolActivity, count: Int, texts: Texts, language: LanguageTag?): String = + texts.text("activity.noun.${kind(activity)}.${if (count == 1) "one" else "many"}", language, "count" to count) + +/** One call's own line: what it is, and what became of it. */ +private fun callLine(call: ProgressCall, texts: Texts, language: LanguageTag?): String { + val title = call.title.take(MAX_TITLE_CHARS) + val line = when (call.status) { + ToolCallStatus.WAITING_APPROVAL -> + texts.text(TextKeys.ACTIVITY_CALL_WAITING_APPROVAL, language, "tool" to call.tool, "title" to title) + + ToolCallStatus.NOT_RUN -> + texts.text(TextKeys.ACTIVITY_CALL_NOT_RUN, language, "tool" to call.tool, "title" to title) + + ToolCallStatus.BACKGROUNDED -> + texts.text(TextKeys.ACTIVITY_CALL_BACKGROUNDED, language, "tool" to call.tool, "title" to title) + + else -> title + suffix(call, texts, language) + } + val agent = call.by ?: return line + return texts.text(TextKeys.ACTIVITY_CALL_BY_AGENT, language, "agent" to agent.value, "line" to line) +} + +/** What an ended call's line adds after its title: an exit code, a diff's counts, or that it failed. */ +private fun suffix(call: ProgressCall, texts: Texts, language: LanguageTag?): String { + when (val content = call.content) { + is TurnEvent.Content.Output -> EXIT_CODE.find(content.tail)?.groupValues?.get(1)?.let { + return " — " + texts.text(TextKeys.ACTIVITY_CALL_EXIT, language, "code" to it) + } + + is TurnEvent.Content.Diff -> lineCounts(content.unified)?.let { (added, removed) -> + return " (" + texts.text(TextKeys.ACTIVITY_CALL_DIFF, language, "added" to added, "removed" to removed) + + ")" + } + + else -> Unit + } + return if (call.status == ToolCallStatus.FAILED) " — " + texts.text(TextKeys.ACTIVITY_CALL_FAILED, language) else "" +} + +/** The line that counts the [omitted] calls a message could not hold. */ +private fun omittedNotice(omitted: Int, texts: Texts, language: LanguageTag?): String = texts.text( + if (omitted == + 1 + ) { + TextKeys.ACTIVITY_OMITTED_ONE + } else { + TextKeys.ACTIVITY_OMITTED_MANY + }, + language, + "count" to omitted, +) + +/** The added and removed line counts of the unified diff [text], null when it changes nothing. */ +private fun lineCounts(text: String): Pair? { + var added = 0 + var removed = 0 + for (line in text.lineSequence()) { + when { + line.startsWith("+++") || line.startsWith("---") -> Unit + line.startsWith("+") -> added++ + line.startsWith("-") -> removed++ + } + } + return (added to removed).takeIf { added > 0 || removed > 0 } +} + +/** The kind of [activity] the channel has words for; a kind this version does not know is work with tools. */ +private fun kind(activity: ToolActivity): String = when (activity) { + ToolActivity.READ -> "read" + ToolActivity.LIST -> "list" + ToolActivity.SEARCH -> "search" + ToolActivity.EDIT -> "edit" + ToolActivity.DELETE -> "delete" + ToolActivity.MOVE -> "move" + ToolActivity.EXECUTE -> "execute" + ToolActivity.FETCH -> "fetch" + ToolActivity.SEND -> "send" + ToolActivity.THINK -> "think" + else -> "other" +} + +/** The characters of a call's title a message shows. */ +private const val MAX_TITLE_CHARS: Int = 80 + +/** The exit code the tail of a call's output names. */ +private val EXIT_CODE = Regex("""Exit code: (-?\d+)""") diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSender.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSender.kt new file mode 100644 index 00000000..f7a4da45 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSender.kt @@ -0,0 +1,206 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages.MAX_FILE_BYTES +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendGroupMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendPrivateMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegmentCodec +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.DeliveryFailure +import org.foedusprogramme.alexandrite.sdk.channel.OutboundFile +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.slf4j.LoggerFactory +import java.util.Base64 + +/** + * What this instance sends, and the record of what it sent. + * + * Every delivery goes through here: the answer of a turn, the notice of a refusal, and the message that shows an + * approval request. The ids that come back are remembered as they arrive, because what answers the bot is told from + * them and from nothing else. + */ +@ChannelInstanceScoped +internal class OneBotSender(private val link: OneBotLink, private val sent: OneBotSentMessages) { + private val logger = LoggerFactory.getLogger(OneBotSender::class.java) + + /** + * 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. + */ + 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. */ + fun params(chat: ChatAddress, message: OutboundMessage): 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 + } + } + + /** + * Sends [message] to [chat], remembering the ids of what it delivered. + * + * A message that carries files goes as its files alone: this platform shows each file's own name and size, so a + * line of text beside them says nothing the file does not. A file past [MAX_FILE_BYTES] is refused before any of + * them is sent, so a chat is never told half of what a turn meant to say. + */ + suspend fun send(chat: ChatAddress, message: OutboundMessage): Delivery { + message.files.firstOrNull { it.size > MAX_FILE_BYTES }?.let { file -> + return Delivery.NotDelivered( + DeliveryFailure.UNKNOWN, + "The file holds ${file.size} bytes, more than the $MAX_FILE_BYTES bytes a chat of this platform takes.", + ) + } + val target = action(chat) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + var delivery: Delivery? = null + if (message.files.isEmpty() && message.text.isNotBlank()) { + val params = params(chat, message) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + delivery = remember(OneBotMessages.delivery(link.call(target.first, params), chat)) + log(message.kind.toString(), chat, delivery, said(message.text)) + } + for (file in message.files) { + val (action, params) = upload(chat, file) ?: continue + // An upload is not a message, so the implementation names no message of it. The answer is matched to this + // call rather than to the chat and is not remembered: what an answer is matched against is the id of a + // message a chat can answer, and an upload names none. + delivery = try { + OneBotMessages.delivery(link.call(action, params, UPLOAD_WAIT_MILLIS), chat) + } catch (e: CancellationException) { + // The wait asked for ran out while the platform was still taking the file in, and an implementation + // goes on taking it: the file may arrive after the turn was told it did not, so a reader of this is + // told to look at the chat before sending it again. A turn that gave up itself travels as it is. + if (!currentCoroutineContext().isActive) throw e + Delivery.NotDelivered( + DeliveryFailure.UNKNOWN, + "The platform did not answer in time about '${file.name}', which it may still be taking in; " + + "look at the chat before sending it again.", + ) + } + if (delivery is Delivery.Delivered) { + logger.info("Uploaded {} of {} bytes to {}", file.name, file.size, chat) + } else { + val failure = delivery as? Delivery.NotDelivered + logger.warn( + "Could not upload {} to {}: {}: {}", + file.name, + chat, + failure?.kind, + failure?.detail.orEmpty(), + ) + } + } + return delivery ?: Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + } + + /** + * Logs what became of [delivery] of [what] to [chat], which [about] describes where it arrived. + * + * What came back is written as it is: a delivery that did not happen is logged as a failure with the reason the + * platform gave, never as a send, which is what a reader of the log took for one. + */ + private fun log(what: String, chat: ChatAddress, delivery: Delivery, about: String) { + if (delivery is Delivery.Delivered) { + val ids = delivery.messages.joinToString { it.id } + logger.info("Sent {} to {} as [{}]: {}", what, chat, ids, about) + } else { + val failure = delivery as? Delivery.NotDelivered + logger.warn( + "Could not send {} to {}: {}: {}", + what, + chat, + failure?.kind, + failure?.detail.orEmpty(), + ) + } + } + + /** + * The action and parameters that upload [file] to [chat], null when this instance serves no such chat. + * + * The bytes go as `base64://`, which is how a file the implementation cannot reach itself is handed over, and the + * name is what the chat shows. A temporary session of a group uploads to the sender, which is whom its address + * means, as its replies do. + */ + private fun upload(chat: ChatAddress, file: OutboundFile): Pair? { + val carried = BYTES_PREFIX + Base64.getEncoder().encodeToString(file.bytes()) + val temporary = OneBotMessages.privateTemporary(chat) + val group = OneBotMessages.group(chat) + val user = OneBotMessages.privateUser(chat) + return when { + group != null -> "upload_group_file" to buildJsonObject { + put("group_id", OneBotSegmentCodec.number(group.value)) + put("file", carried) + put("name", file.name) + } + + temporary != null -> "upload_private_file" to buildJsonObject { + put("user_id", OneBotSegmentCodec.number(temporary.value)) + put("file", carried) + put("name", file.name) + } + + user != null -> "upload_private_file" to buildJsonObject { + put("user_id", OneBotSegmentCodec.number(user.value)) + put("file", carried) + put("name", file.name) + } + + else -> null + } + } + + /** [text] as a log line shows it: one line, cut short, so that a long answer cannot flood the console. */ + private fun said(text: String): String { + val one = text.replace("\n", " ") + return if (one.length <= SAID_LIMIT) one else one.take(SAID_LIMIT) + "…" + } + + /** Remembers the ids of what [delivery] delivered, which is what a reply is matched against. */ + private fun remember(delivery: Delivery): Delivery { + (delivery as? Delivery.Delivered)?.messages?.forEach { sent.remember(it.id) } + return delivery + } +} + +/** How much of a message a log line shows. */ +private const val SAID_LIMIT: Int = 120 + +/** + * What the platform is told a file's bytes are, which is how it is handed one it cannot reach itself. + * + * A file of a chat is uploaded by the implementation rather than read from this machine, so its bytes travel in the + * call. The tools of this channel hand one over the same way. + */ +private const val BYTES_PREFIX: String = "base64://" + +/** + * How long an upload of a file waits for the platform's answer. + * + * An implementation takes in a large file before it answers, which is longer than the instance's own timeout of a call + * allows: a file of thirty megabytes has taken about a minute. A call that gives up sooner is answered as a failure + * while the upload goes on, so the file arrives and the turn is told it did not. + */ +private const val UPLOAD_WAIT_MILLIS: Long = 10 * 60 * 1000 diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotWords.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotWords.kt new file mode 100644 index 00000000..59b62fb1 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotWords.kt @@ -0,0 +1,68 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.TurnId +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.hook.Hook +import org.foedusprogramme.alexandrite.sdk.hook.HookDecision +import org.foedusprogramme.alexandrite.sdk.hook.InterceptorHook +import org.foedusprogramme.alexandrite.sdk.hook.InterceptorPoint +import org.foedusprogramme.alexandrite.sdk.transcript.AssistantEntry +import org.foedusprogramme.alexandrite.sdk.transcript.ToolCallPart +import org.foedusprogramme.alexandrite.sdk.turn.ModelReply +import org.foedusprogramme.alexandrite.sdk.turn.TurnPoints +import java.util.concurrent.ConcurrentHashMap + +/** The reply of a turn, as the words of a model's request reach it. */ +internal interface OneBotWords { + /** Shows [message], the response of one request of this turn. */ + suspend fun showResponse(message: AssistantEntry) +} + +/** + * The replies of the turns of this instance that are open, by turn. + * + * A reply opens when the agent dequeues a turn and closes as the turn ends, so the words of a response reach the reply + * of its own turn and no other. A turn whose reply is not here is one this instance does not speak for, such as a + * delegated turn whose reply goes to the agent that asked for it. + */ +@Singleton +internal class OneBotReplyRegistry { + private val open = ConcurrentHashMap() + + /** Remembers [words] as the reply of [turn]. */ + fun open(turn: TurnId, words: OneBotWords) { + open[turn] = words + } + + /** Forgets the reply of [turn], which nothing speaks for afterwards. */ + fun close(turn: TurnId) { + open.remove(turn) + } + + /** The reply of [turn], null when this instance speaks for no such turn. */ + fun of(turn: TurnId): OneBotWords? = open[turn] +} + +/** + * Tells the reply of a turn the words of each of its requests, whole and once, as the request ends. + * + * This is what the platform needs where a message cannot be added to later: a channel that can change its messages + * shows a growing preview and has no use for this point, while a chat of this one is told each response in full at the + * moment the model finished saying it. + */ +@Singleton +@Contribute(Hook::class) +internal class OneBotWordsHook(private val replies: OneBotReplyRegistry) : InterceptorHook { + override val point: InterceptorPoint get() = TurnPoints.LLM_RESPONSE + + override suspend fun intercept(payload: ModelReply): HookDecision { + // A turn this instance speaks for has a reply here; a delegated turn has none, and its words go to its caller. + val words = replies.of(payload.turn.id) ?: return HookDecision.Continue + // A response that calls no tool is the answer of the turn, which the message that ends it carries: telling it + // here as well would say it twice. + if (payload.response.message.parts.none { it is ToolCallPart }) return HookDecision.Continue + words.showResponse(payload.response.message) + return HookDecision.Continue + } +} From 8c0a9d50a24efde7f1b3503162b190e77e8d43ca Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 01:31:29 +0800 Subject: [PATCH 04/37] onebot: Add platform tools and media Adds the onebot.read and onebot.act tools, upload and group-file support, and the media reader that turns files and images into what a turn can use. --- .../channel/onebot/channel/OneBotMedia.kt | 118 ++++++ .../channel/onebot/tools/OneBotActionTool.kt | 354 ++++++++++++++++++ .../channel/onebot/tools/OneBotActions.kt | 346 +++++++++++++++++ .../channel/onebot/tools/OneBotToolSupport.kt | 64 ++++ .../channel/onebot/tools/OneBotUpload.kt | 121 ++++++ 5 files changed, 1003 insertions(+) create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotMedia.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionTool.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActions.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolSupport.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUpload.kt diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotMedia.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotMedia.kt new file mode 100644 index 00000000..be96919e --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotMedia.kt @@ -0,0 +1,118 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.foedusprogramme.alexandrite.channel.onebot.mapping.MediaReader +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetImage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetRecord +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.sdk.channel.MediaContent +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.slf4j.LoggerFactory +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +/** + * The bytes a reported segment names, which the agent reads as the input of a turn. + * + * A report names an image by an address it can be read from, and sometimes by a name the implementation keeps it + * under. The address is read directly; a name is turned into an address by asking the implementation for it. + * + * What cannot be read is left out of the turn with a note rather than failing it, which is what the agent does with a + * read that throws. A read stops at the size the agent allows, and a body larger than that is not read further. + */ +@ChannelInstanceScoped +internal class OneBotMedia(private val link: OneBotLink) : MediaReader { + private val client: HttpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(CONNECT_SECONDS)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + + override fun content(segment: OneBotSegment): MediaContent? = when (segment) { + is OneBotSegment.Image -> content(segment.url, segment.file, "get_image") + is OneBotSegment.Record -> content(segment.url, segment.file, "get_record") + is OneBotSegment.Video -> content(segment.url, segment.file, null) + else -> null + } + + /** The bytes of [url], or of the [file] the implementation keeps under [action] when no [url] is reported. */ + private fun content(url: String?, file: String?, action: String?): MediaContent? { + if (url.isNullOrEmpty() && (file.isNullOrEmpty() || action == null)) return null + return MediaContent { maxBytes -> read(url, file, action, maxBytes) } + } + + private suspend fun read(url: String?, file: String?, action: String?, maxBytes: Long): ByteArray { + val address = url?.takeIf { it.isHttp() } ?: file?.takeIf { it.isHttp() } ?: ask(action, file) + return fetch(address, maxBytes) + } + + /** The address the implementation keeps [file] at, which may be one this host cannot read. */ + private suspend fun ask(action: String?, file: String?): String { + require(!file.isNullOrEmpty() && action != null) { "The report names no address and no file to ask about." } + val params = when (action) { + "get_image" -> GetImage(file).toJson() + else -> GetRecord(file, RECORD_FORMAT).toJson() + } + val result = link.call(action, params) + val data = (result as? OneBotResult.Ok)?.data + ?: throw IllegalStateException("The implementation did not report where '$file' is kept.") + val kept = data.jsonObject["file"]?.jsonPrimitive?.content + return kept?.takeIf { it.isHttp() } + ?: throw IllegalStateException("The implementation keeps '$file' where this host cannot read it.") + } + + /** At most [maxBytes] of what [address] answers. */ + private suspend fun fetch(address: String, maxBytes: Long): ByteArray = withContext(Dispatchers.IO) { + val request = HttpRequest.newBuilder(URI.create(address)) + .timeout(Duration.ofSeconds(READ_SECONDS)) + .GET() + .build() + val answer = try { + client.send(request, HttpResponse.BodyHandlers.ofInputStream()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + throw IllegalStateException("Cannot read $address", e) + } + if (answer.statusCode() !in 200..299) { + throw IllegalStateException("Reading $address answered ${answer.statusCode()}.") + } + val size = answer.headers().firstValueAsLong("content-length").orElse(-1) + if (size > maxBytes) throw IllegalStateException("$address is larger than $maxBytes bytes.") + answer.body().use { body -> body.readAtMost(maxBytes) } + } + + /** [maxBytes] of [this], and no more: one byte past it says the body is longer than that. */ + private fun java.io.InputStream.readAtMost(maxBytes: Long): ByteArray { + val kept = java.io.ByteArrayOutputStream() + val piece = ByteArray(PIECE_BYTES) + while (true) { + val read = read(piece) + if (read < 0) break + if (kept.size() + read > maxBytes) throw IllegalStateException("The body is larger than $maxBytes bytes.") + kept.write(piece, 0, read) + } + return kept.toByteArray() + } + + private companion object { + const val CONNECT_SECONDS = 10L + + const val READ_SECONDS = 30L + + const val PIECE_BYTES = 16 * 1024 + + /** What a voice message is converted to when its bytes are read, which every implementation takes. */ + const val RECORD_FORMAT = "mp3" + } +} + +/** Whether [this] is an address this host can read. */ +private fun String.isHttp(): Boolean = startsWith("http://") || startsWith("https://") diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionTool.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionTool.kt new file mode 100644 index 00000000..f738750d --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionTool.kt @@ -0,0 +1,354 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.tool.FloorCheck +import org.foedusprogramme.alexandrite.sdk.tool.HardFloor +import org.foedusprogramme.alexandrite.sdk.tool.Tool +import org.foedusprogramme.alexandrite.sdk.tool.ToolContext +import org.foedusprogramme.alexandrite.sdk.tool.ToolDefinition +import org.foedusprogramme.alexandrite.sdk.tool.ToolResult +import org.foedusprogramme.alexandrite.sdk.tool.ToolRisk +import java.nio.file.Files +import java.nio.file.Path +import java.util.Base64 +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +/** + * Reads what the platform knows about a chat, a member or a message this bot acts in. + * + * One tool serves every action it may be asked for rather than one tool per action, because an action is a row of + * [OneBotActions] and not a class: the platform's surface is large, and a row says what an action takes and what it + * is worth. What a call may ask for is the table, so an action the table leaves out cannot be named at all, which is + * where the actions that hand out the account's credentials and the ones that restart the implementation are kept. + */ +@Singleton +@Contribute(Tool::class) +internal class OneBotReadTool(private val apis: OneBotApis, private val floor: HardFloor?) : Tool { + override val definition: ToolDefinition = ToolDefinition( + name = "onebot.read", + description = describe("Reads what the QQ platform knows", OneBotActions.rows.filterNot { it.changes }), + parameters = schema(), + risk = ToolRisk.READ_ONLY, + ) + + override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult = + call(apis, OneBotActions.rows.filterNot { it.changes }, floor, arguments, context) +} + +/** Changes something of a chat or a member of the platform this bot acts in. */ +@Singleton +@Contribute(Tool::class) +internal class OneBotActTool(private val apis: OneBotApis, private val floor: HardFloor?) : Tool { + override val definition: ToolDefinition = ToolDefinition( + name = "onebot.act", + description = describe( + "Changes something of a QQ chat this bot acts in. A call that ends without an answer may still have " + + "been carried out, so look at what that chat or group holds before calling again", + OneBotActions.rows.filter { it.changes }, + ), + parameters = schema(), + risk = ToolRisk.EXTERNAL_ACTION, + timeout = actTimeout, + ) + + override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult = + call(apis, OneBotActions.rows.filter { it.changes }, floor, arguments, context) +} + +/** The arguments every call of these tools takes, which is the action and whatever that action takes. */ +private fun schema(): JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("action") { + put("type", "string") + put("description", "The action to call, named as one of the actions the description lists.") + } + putJsonObject("params") { + put("type", "object") + put("description", "That action's arguments, by the names the description gives them.") + put("additionalProperties", true) + } + } + put("required", JsonArray(listOf(JsonPrimitive("action")))) + put("additionalProperties", false) +} + +/** What the model is told these tools are for: every action they may be asked for, and what it takes. */ +private fun describe(header: String, rows: List): String = buildString { + append(header) + append(". It calls one action per call, named as 'action', with that action's arguments under 'params'. ") + append("The actions it may be asked for, with a ? on the arguments that may be left out:\n") + rows.forEach { row -> + append("- ").append(row.name).append('(') + append(row.parameters.joinToString(", ") { it.name + (if (it.required) "" else "?") }) + append(") \u2014 ").append(row.said).append('\n') + row.parameters.forEach { parameter -> + append(" ").append(parameter.name).append(": ").append(parameter.description).append('\n') + } + } +} + +/** + * Calls [name] of [rows] in the chat of [context], and says what the platform answered. + * + * An action the call may not ask for is refused rather than passed on, and only the arguments its row declares are + * sent, so nothing a call writes under `params` can reach the platform unless the action takes it by that name. + */ +private suspend fun call( + apis: OneBotApis, + rows: List, + floor: HardFloor?, + arguments: JsonObject, + context: ToolContext, +): ToolResult { + val name = arguments.text("action") + ?: return ToolResult("A call names an action, as action.", isError = true) + val row = rows.firstOrNull { it.name == name } + ?: return ToolResult( + "'$name' is no action this call may ask for. It may ask for: " + + rows.joinToString(", ") { it.name } + ".", + isError = true, + ) + val given = arguments["params"] as? JsonObject ?: JsonObject(emptyMap()) + val params = when (val prepared = prepare(row, given, context.trigger?.id)) { + is Prepared.Refused -> return ToolResult(prepared.said, isError = true) + is Prepared.Ready -> prepared.params + } + // A file of the workspace is read here rather than where the arguments are written, because whether this call may + // read one is the floor's to say, which a function of the row and the arguments has no way to ask. + val carried = when ( + val files = + withFiles(row, params, floor, context) { call -> apis.serving(context.turn.chat).raw(UPLOAD_STREAM, call) } + ) { + is Prepared.Refused -> return ToolResult(files.said, isError = true) + is Prepared.Ready -> files.params + } + val api = apis.serving(context.turn.chat) + val result = api.raw(name, carried) + // What a read answers is what other people wrote, so a turn that carries it carries content this bot cannot vouch + // for, and the conversation is marked the way any other content read from outside is marked. + if (!row.changes && result is OneBotResult.Ok) { + context.untrusted("the platform answered what '$name' asked for") + } + return ToolResult(result.outcome(row.said, answered = !row.changes)) +} + +/** What a call to an action amounts to: the arguments to send, or why they cannot be sent. */ +internal sealed interface Prepared { + /** The arguments to send, which are the ones [OneBotActions.Row.parameters] declares. */ + class Ready(val params: JsonObject) : Prepared + + /** Why nothing is sent. */ + class Refused(val said: String) : Prepared +} + +/** + * The arguments [row] sends when a call gave [given], with [messageId] standing in for the id of the message of the + * turn where the row allows it to be left out. + * + * Every argument is written the way the standard asks for it, since a model writes them all as text: one that names a + * message, a group or a length is sent as the number it names, and one that asks whether to do something is sent as + * yes or no. An argument that must be given and was not, or one that cannot be read as what the row says it is, is + * refused, naming it and what it is for, rather than sent as something the platform would answer without saying what + * the call got wrong. + */ +internal fun prepare(row: OneBotActions.Row, given: JsonObject, messageId: String?): Prepared { + val params = buildJsonObject { + row.parameters.forEach { parameter -> + val value = given[parameter.name] + if (value != null && value !is JsonNull) { + when (parameter.kind) { + OneBotActions.Kind.TEXT -> put(parameter.name, value) + + OneBotActions.Kind.NUMBER -> put(parameter.name, number(value)) + + OneBotActions.Kind.FILE -> put(parameter.name, value) + + OneBotActions.Kind.FLAG -> { + val flag = boolean(value) + ?: return Prepared.Refused( + "The action '${row.name}' takes ${parameter.name} as yes or no, not '$value': " + + parameter.description, + ) + put(parameter.name, flag) + } + } + return@forEach + } + // An action about a message means the message of this turn when the call named none. A turn no message + // started leaves nothing to mean, and a call that had to name one is refused rather than sent without it. + if (parameter.name == OneBotActions.MESSAGE_ID && parameter.optional) { + val fallback = messageId ?: return Prepared.Refused( + "The action '${row.name}' needs ${parameter.name}: ${parameter.description}", + ) + put(parameter.name, number(JsonPrimitive(fallback))) + return@forEach + } + if (parameter.required) { + return Prepared.Refused( + "The action '${row.name}' needs ${parameter.name}: ${parameter.description}", + ) + } + } + } + return Prepared.Ready(params) +} + +/** + * [value] as yes or no, null when it says neither. + * + * A model writes an argument as text even when it asks whether to do something, so both the words it may write and + * the values a call may carry are read, and anything else is refused rather than sent as something the platform + * would answer without saying what the call got wrong. + */ +private fun boolean(value: JsonElement): Boolean? { + val primitive = value as? JsonPrimitive ?: return null + primitive.booleanOrNull?.let { return it } + return when (primitive.contentOrNull?.trim()?.lowercase()) { + "yes", "y", "1" -> true + "no", "n", "0" -> false + else -> null + } +} + +/** [value] as the number it names, or as the text it is when it names no number. */ +private fun number(value: JsonElement): JsonElement { + val text = (value as? JsonPrimitive)?.contentOrNull ?: return value + return text.toLongOrNull()?.let { JsonPrimitive(it) } ?: value +} + +/** + * [params] with every argument that names a file of this workspace carrying the file itself instead. + * + * A path of this machine means nothing to an implementation on another one, so the file is read and handed over as + * the bytes the platform takes, written the way it takes them. What may be read is the floor's to say: a path it + * denies is refused here with the reason it gave, and one it allows is read at the path it names, which is the path a + * link is followed to. A runtime with no floor is one where nothing decides what may be read, so a call that needs a + * file is refused rather than read of its own accord. + * + * A file larger than a call should carry goes to the implementation in pieces instead, which names it in answer, and + * what the action carries is that name: a file of any size can be sent, and neither side ever holds it whole. [send] + * is one call of the action that takes a file in pieces. + */ +internal suspend fun withFiles( + row: OneBotActions.Row, + params: JsonObject, + floor: HardFloor?, + context: ToolContext, + send: suspend (JsonObject) -> OneBotResult, +): Prepared { + val named = row.parameters.filter { it.kind == OneBotActions.Kind.FILE } + if (named.isEmpty()) return Prepared.Ready(params) + val reading = floor ?: return Prepared.Refused( + "The action '${row.name}' cannot read a file of your workspace: nothing in this runtime decides what may " + + "be read, so no file is sent.", + ) + val carried = buildJsonObject { + params.forEach { (key, value) -> put(key, value) } + named.forEach { parameter -> + val given = params[parameter.name]?.jsonPrimitive?.contentOrNull + ?: return Prepared.Refused( + "The action '${row.name}' needs ${parameter.name}: ${parameter.description}", + ) + // A file tool takes a path that is absolute, one relative to the workspace or one under the home + // directory, and asks the floor about the path that comes of it. What a call may name is the same, so the + // path is resolved the same way before the floor is asked, which is told of an absolute path alone. + val expanded = when { + given == "~" -> home + given.startsWith("~/") || given.startsWith("~\\") -> "$home${given.substring(1)}" + else -> given + } + val path = try { + Path.of(expanded) + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + return Prepared.Refused("'$given' is no path: ${parameter.description}") + } + val absolute = when { + path.isAbsolute -> path.normalize() + + context.workspace == null -> return Prepared.Refused( + "'$given' is a relative path, and this call has no workspace to resolve it against: " + + "give an absolute path.", + ) + + else -> try { + context.openWorkspace().resolve(path).normalize() + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + return Prepared.Refused("'$given' could not be resolved against your workspace: ${e.message}") + } + } + val allowed = when (val check = reading.checkRead(absolute, context.turn.agent)) { + is FloorCheck.Denied -> return Prepared.Refused( + "The action '${row.name}' may not read '$given': ${check.reason}.", + ) + + is FloorCheck.Allowed -> check.canonical + } + val size = try { + withContext(Dispatchers.IO) { Files.size(allowed) } + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + return Prepared.Refused("The action '${row.name}' could not read '$given': ${e.message}") + } + if (size > FILE_LIMIT) { + // Too large to carry in one request, so it goes in pieces and what is carried is the name the + // implementation gives it. A file it did not take whole is never sent on as if it were. + val called = params["name"]?.jsonPrimitive?.contentOrNull?.takeIf(String::isNotEmpty) + ?: allowed.fileName?.toString() + ?: "file" + val name = streamFile(allowed, called, send) ?: return Prepared.Refused( + "The action '${row.name}' could not send '$given': the implementation took no file in pieces, " + + "or named none to send on by.", + ) + context.touched(allowed) + put(parameter.name, name) + return@forEach + } + val bytes = try { + withContext(Dispatchers.IO) { Files.readAllBytes(allowed) } + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + return Prepared.Refused("The action '${row.name}' could not read '$given': ${e.message}") + } + context.touched(allowed) + put(parameter.name, BYTES_PREFIX + Base64.getEncoder().encodeToString(bytes)) + } + } + return Prepared.Ready(carried) +} + +/** What the platform is told a file's bytes are, which is how it is handed one it cannot reach itself. */ +private const val BYTES_PREFIX: String = "base64://" + +/** + * How long a change of this channel may take, which a file of the workspace makes long. + * + * A file larger than one request carries is sent to the implementation in pieces, each of them a call of its own, + * and is then sent on, so the timeout a turn takes by default is too short for one and ends the call with nothing + * known of what became of it. This is a ceiling rather than a wait: a call that is done sooner ends sooner. + */ +private val actTimeout: Duration = 5.minutes + +/** The home directory, which a path of a call may name with a leading `~` the way a file tool takes one. */ +private val home: String = System.getProperty("user.home").orEmpty() diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActions.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActions.kt new file mode 100644 index 00000000..0a29fdd6 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActions.kt @@ -0,0 +1,346 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotRegistry +import org.foedusprogramme.alexandrite.sdk.tool.ToolRisk + +/** + * The actions of the platform this bot may be asked for, each declared once. + * + * One row says what an action is for, which arguments it takes, what they mean and how each is written. A tool reads + * the row rather than a class of its own, so an action is added by adding a row, and the ones that must never be + * called are left out of the table rather than guarded in each tool. + * + * An action the standard declares is checked against the registry, so a row naming one that does not exist, or one + * the standard hides, fails where the table is read rather than at the platform. An action the standard does not + * declare at all is a row that says so, which is how an implementation's own actions are reached. + * + * The names of the arguments are the ones the platform takes, which are the keys its own requests are written with. + */ +internal object OneBotActions { + /** How the standard writes an argument, which a model writes as text either way. */ + internal enum class Kind { + /** A name, a title, a flag it was given, a reason: the text as it was written. */ + TEXT, + + /** A message, a group, a member, a length: the number the text names. */ + NUMBER, + + /** Whether to do something: yes or no. */ + FLAG, + + /** + * A file of the agent's own workspace, named by its path. + * + * The platform is usually on another machine, where a path of this one means nothing, so the tool reads the + * file and hands it over as the bytes the platform takes. What may be read is not decided here: the floor + * that decides what the tools for files may read decides it, where a runtime has one. + */ + FILE, + } + + /** + * What an action is, and what calling it takes. + * + * [parameters] names the arguments the platform takes, in the order they are shown. An argument that must be given + * says so, and the one an action about a message takes may be left out, in which case the message of this turn is + * meant: a prompt does not name the id of the message it answers, so a call about it must not have to. + */ + internal class Row( + val name: String, + val said: String, + val parameters: List, + /** Whether the standard declares this action at all. An implementation may have actions it does not. */ + val extension: Boolean = false, + ) { + /** Whether this action changes something rather than only reading. */ + val changes: Boolean get() = this in changing + + /** Whether the id of the message of this turn stands in for an argument that was left out. */ + val takesAMessage: Boolean get() = parameters.any { it.name == MESSAGE_ID && it.optional } + } + + /** One argument of an action. */ + internal class Parameter( + val name: String, + val description: String, + val kind: Kind = Kind.TEXT, + val required: Boolean = true, + ) { + /** Whether this argument may be left out. */ + val optional: Boolean get() = !required + } + + /** The argument that names the message an action is about. */ + const val MESSAGE_ID: String = "message_id" + + private val reading: List = listOf( + Row( + name = "get_msg", + said = "The message was read.", + parameters = listOf(message()), + ), + Row( + name = "get_forward_msg", + said = "The forwarded messages were read.", + parameters = listOf(Parameter("id", "The id of the forwarded message to read.")), + ), + Row( + name = "get_stranger_info", + said = "The account was read.", + parameters = listOf( + Parameter("user_id", "The account to read.", Kind.NUMBER), + Parameter("no_cache", "Whether to read it afresh rather than from the cache.", Kind.FLAG, false), + ), + ), + Row( + name = "get_group_info", + said = "The group was read.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("no_cache", "Whether to read it afresh rather than from the cache.", Kind.FLAG, false), + ), + ), + Row( + name = "get_group_member_info", + said = "The member was read.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("user_id", "The member to read.", Kind.NUMBER), + Parameter("no_cache", "Whether to read them afresh rather than from the cache.", Kind.FLAG, false), + ), + ), + Row( + name = "get_group_member_list", + said = "The members were read.", + parameters = listOf(Parameter("group_id", "The group.", Kind.NUMBER)), + ), + Row( + name = "get_group_honor_info", + said = "The honors were read.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("type", "Which honors to read, such as all, talkative or performer."), + ), + ), + Row( + name = "get_file", + said = "The file was read.", + parameters = listOf( + Parameter("file", "How the implementation knows the file."), + Parameter( + "type", + "Which kind of reference to answer with, where the implementation asks.", + required = false, + ), + ), + extension = true, + ), + Row( + name = "get_group_file_url", + said = "Where the group file can be read was read.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("file_id", "The id the group file is known by."), + Parameter("busid", "Which part of the group's files the file is in.", Kind.NUMBER), + ), + extension = true, + ), + ) + + private val changing: List = listOf( + Row( + name = "delete_group_file", + said = "The group file was deleted.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("file_id", "The id the group file is known by, which listing its files gives."), + Parameter( + "busid", + "Which part of the group's files the file is in, which listing its files gives.", + Kind.NUMBER, + ), + ), + extension = true, + ), + Row( + name = "delete_msg", + said = "The message was taken back.", + parameters = listOf(message("The message to take back.")), + ), + Row( + name = "set_msg_emoji_like", + said = "The emoji response was added.", + parameters = listOf( + message("The message to respond to."), + Parameter("emoji_id", "The number of the emoji, such as 128077 for a thumbs up.", Kind.NUMBER), + ), + extension = true, + ), + Row( + name = "send_like", + said = "The account was given a like.", + parameters = listOf( + Parameter("user_id", "The account to like.", Kind.NUMBER), + Parameter("times", "How many likes to give.", Kind.NUMBER, false), + ), + ), + Row( + name = "set_friend_add_request", + said = "The friend request was answered.", + parameters = listOf( + Parameter("flag", "The flag the request reported."), + Parameter("approve", "Whether to accept the request.", Kind.FLAG), + Parameter("remark", "The remark to keep with a friend the call accepts.", required = false), + ), + ), + Row( + name = "set_group_add_request", + said = "The request was answered.", + parameters = listOf( + Parameter("flag", "The flag the request reported."), + Parameter("sub_type", "add when somebody asks to join, invite when the bot is invited."), + Parameter("approve", "Whether to accept the request.", Kind.FLAG), + Parameter("reason", "The reason to give for a request the call refuses.", required = false), + ), + ), + Row( + name = "set_group_kick", + said = "The member was removed from the group.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("user_id", "The member to remove.", Kind.NUMBER), + Parameter("reject_add_request", "Whether to refuse their requests to join again.", Kind.FLAG, false), + ), + ), + Row( + name = "set_group_ban", + said = "The mute was set.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("user_id", "The member to mute.", Kind.NUMBER), + Parameter("duration", "How many seconds the mute lasts, 0 to lift it.", Kind.NUMBER), + ), + ), + Row( + name = "set_group_whole_ban", + said = "The mute of the whole group was set.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("enable", "Whether every member is muted.", Kind.FLAG), + ), + ), + Row( + name = "set_group_admin", + said = "Whether the member is an administrator was set.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("user_id", "The member.", Kind.NUMBER), + Parameter("enable", "Whether they administer the group.", Kind.FLAG), + ), + ), + Row( + name = "set_group_card", + said = "The name the member goes by in the group was set.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("user_id", "The member.", Kind.NUMBER), + Parameter("card", "The name they go by in the group, empty to clear it."), + ), + ), + Row( + name = "set_group_name", + said = "The name of the group was set.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("group_name", "The name to give the group."), + ), + ), + Row( + name = "set_group_leave", + said = "The bot left the group.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("is_dismiss", "Whether to dismiss the group rather than only leave it.", Kind.FLAG, false), + ), + ), + Row( + name = "set_group_special_title", + said = "The title was set.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("user_id", "The member.", Kind.NUMBER), + Parameter("special_title", "The title to give them, empty to clear it."), + Parameter("duration", "How many seconds the title lasts.", Kind.NUMBER, false), + ), + ), + Row( + name = "upload_group_file", + said = "The file was uploaded to the group.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("file", "The path of the file to upload, in your workspace.", Kind.FILE), + Parameter("name", "What to call the file in the group."), + Parameter("folder", "Which folder of the group's files to put it in.", required = false), + ), + extension = true, + ), + Row( + name = "upload_private_file", + said = "The file was uploaded to the chat.", + parameters = listOf( + Parameter("user_id", "The account to send the file to.", Kind.NUMBER), + Parameter("file", "The path of the file to upload, in your workspace.", Kind.FILE), + Parameter("name", "What to call the file."), + ), + extension = true, + ), + ) + + /** An action that is about the message of this turn when a call names none. */ + private fun message(what: String = "The message to read."): Parameter = + Parameter(MESSAGE_ID, "$what Default: the message this turn is about.", Kind.NUMBER, required = false) + + /** What a group's files are, which an implementation that offers the extension reports. */ + private val fileReading: List = listOf( + Row( + name = "get_group_root_files", + said = "The files of the group were read.", + parameters = listOf(Parameter("group_id", "The group.", Kind.NUMBER)), + extension = true, + ), + Row( + name = "get_group_files_by_folder", + said = "The files of that folder were read.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("folder_id", "The folder to read, as the group names it."), + ), + extension = true, + ), + Row( + name = "get_group_file_system_info", + said = "What the group's files hold was read.", + parameters = listOf(Parameter("group_id", "The group.", Kind.NUMBER)), + extension = true, + ), + ) + + /** Everything this bot may be asked for, the reads first. */ + val rows: List = reading + fileReading + changing + + private val byName: Map = rows.associateBy(Row::name) + + init { + rows.forEach { row -> + if (!row.extension) { + val declared = OneBotRegistry.action(row.name) + require(declared != null) { "The platform declares no action '${row.name}'." } + require(!declared.hidden) { "The action '${row.name}' is one the standard hides." } + } + } + require(byName.size == rows.size) { "An action is declared twice." } + } + + /** The row of [name], null when this bot may not be asked for it. */ + fun of(name: String): Row? = byName[name] +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolSupport.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolSupport.kt new file mode 100644 index 00000000..bbd494a5 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolSupport.kt @@ -0,0 +1,64 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.api.OneBotApi +import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress + +/** The API of the instance that serves [chat], which fails when no such instance runs. */ +internal fun OneBotApis.serving(chat: ChatAddress): OneBotApi = + of(chat.instance) ?: throw IllegalStateException("No OneBot channel instance serves ${chat.instance}.") + +/** How much of an answer is shown, since an answer to a read may be far longer than a turn should carry. */ +internal const val ANSWER_LIMIT: Int = 4_000 + +/** + * How large a file a call carries in one request, above which the file goes to the implementation in pieces. + * + * The bytes are written the way the platform takes them, which grows them by about a third, and an implementation + * refuses what is larger than it takes without saying so in a way a model can act on, so a call is refused here + * instead, naming the limit it passed. + */ +internal const val FILE_LIMIT: Long = 8L * 1024 * 1024 + +/** + * What [what] says when the call was taken, and what the implementation said when it was not. + * + * A call that only reads is answered with what the platform gave it, because what was asked for *is* that answer: a + * call told only that its action was carried out leaves the model with nothing to read, which is what a read that + * discards the answer amounts to. A call that changes something is answered with what it did, since the platform's + * answer to it carries nothing the model can use. + */ +internal fun OneBotResult<*>.outcome(what: String, answered: Boolean = false): String = when (this) { + is OneBotResult.Ok -> if (answered) answer() else what + is OneBotResult.Async -> "$what The implementation answers later." + is OneBotResult.Failed -> "The implementation refused it: ${message ?: "it gave no reason"} (retcode $retcode)." + is OneBotResult.Unreachable -> "The implementation could not be reached: ${failure.message}." + is OneBotResult.Malformed -> "The implementation answered something this channel cannot read." +} + +/** What the platform answered, as the text it is, cut where it is longer than a turn should carry. */ +private fun OneBotResult.Ok<*>.answer(): String { + val shown = data?.toString().orEmpty() + return when { + shown.isEmpty() || shown == "null" -> "The implementation answered with nothing." + shown.length <= ANSWER_LIMIT -> shown + else -> shown.take(ANSWER_LIMIT) + " … (the answer was longer and stops here.)" + } +} + +/** The text of [name], null when the call gave none. */ +internal fun JsonObject.text(name: String): String? = + this[name]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + +/** [value] as the number it is, or as the text it is when it names no number. */ +internal fun JsonObjectBuilder.putId(name: String, value: String) { + val number = value.toLongOrNull() + if (number == null) put(name, JsonPrimitive(value)) else put(name, number) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUpload.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUpload.kt new file mode 100644 index 00000000..32401072 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUpload.kt @@ -0,0 +1,121 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest +import java.util.Base64 +import java.util.UUID + +/** + * How much of a file one call of the streaming upload carries. + * + * The implementation takes a file in pieces so that neither side holds all of it, and this is the piece it is given: + * the size the reference implementation of this protocol sends, which the platform takes. + */ +internal const val STREAM_CHUNK: Int = 256 * 1024 + +/** + * How long the implementation keeps the file it was sent, in milliseconds. + * + * The file is streamed to the implementation and then sent on to a chat by the reference it answers with, so it has to + * outlive the streaming by however long sending takes. + */ +internal const val STREAM_RETENTION_MS: Long = 300_000 + +/** The action that takes a file in pieces, which the standard does not declare and an implementation may have. */ +internal const val UPLOAD_STREAM: String = "upload_file_stream" + +/** Where an implementation names the file it was sent, which is what sending it on refers to. */ +private val STREAM_REFERENCE: List = listOf("file_path", "file", "path", "url") + +/** + * [path] sent to the implementation in pieces, answered by what the file can then be sent on by. + * + * The file is read twice and never held whole: once to say how large it is and what its digest is, which every piece + * is sent with, and once to send the pieces themselves. An implementation that refuses a piece, or that answers + * without naming the file, ends the sending, and nothing is sent on afterwards, so a file that was only partly + * streamed is never sent as if it were whole. + * + * [send] is one call of the action, which is what the tests stand in for rather than speaking to an implementation. + */ +internal suspend fun streamFile( + path: Path, + name: String, + send: suspend (JsonObject) -> OneBotResult, +): String? = withContext(Dispatchers.IO) { + val size = Files.size(path) + val digest = digest(path) + val total = ((size + STREAM_CHUNK - 1) / STREAM_CHUNK).toInt() + val stream = UUID.randomUUID().toString() + Files.newInputStream(path).use { input -> + val buffer = ByteArray(STREAM_CHUNK) + var index = 0 + while (index < total) { + val read = input.readNBytes(buffer, 0, STREAM_CHUNK) + if (read <= 0) break + val piece = if (read == buffer.size) buffer else buffer.copyOf(read) + val call = buildJsonObject { + put("stream_id", stream) + put("chunk_data", Base64.getEncoder().encodeToString(piece)) + put("chunk_index", index) + put("total_chunks", total) + put("file_size", size) + put("expected_sha256", digest) + put("filename", name) + put("file_retention", STREAM_RETENTION_MS) + } + if (send(call) !is OneBotResult.Ok) return@withContext null + index++ + } + } + val completed = send( + buildJsonObject { + put("stream_id", stream) + put("is_complete", true) + put("file_retention", STREAM_RETENTION_MS) + }, + ) + (completed as? OneBotResult.Ok)?.data?.reference() +} + +/** + * The name [data] gives the file it was sent, null when it names none. + * + * The reference is a field of what the implementation answered, which is where the reference implementation of this + * protocol reads it. One that answers under a field of its own is read as well, since what the file is called is the + * implementation's to decide. + */ +private fun JsonElement.reference(): String? { + val fields = this as? JsonObject ?: return null + return fields.reference() ?: (fields["data"] as? JsonObject)?.reference() +} + +/** The first of the names an implementation may give the file under, which is how the file is referred to after. */ +private fun JsonObject.reference(): String? = STREAM_REFERENCE.firstNotNullOfOrNull { name -> + this[name]?.jsonPrimitive?.contentOrNull?.takeIf(String::isNotEmpty) +} + +/** What [path] holds, as the digest the pieces of it are sent with. */ +private fun digest(path: Path): String { + val sha = MessageDigest.getInstance("SHA-256") + Files.newInputStream(path).use { input -> + val buffer = ByteArray(STREAM_CHUNK) + while (true) { + val read = input.read(buffer) + if (read < 0) break + sha.update(buffer, 0, read) + } + } + return sha.digest().joinToString("") { "%02x".format(it) } +} From 9c3acb6350ecdd962880357ab0eebab2dcdb9fe3 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 01:31:34 +0800 Subject: [PATCH 05/37] sdk: Add channel notices, points and the trigger message Adds the notice and points a channel can offer, and gives a tool the message its turn answers so it can act on that message by id. --- .../alexandrite/sdk/channel/ChannelNotice.kt | 84 +++++++++++++++++++ .../alexandrite/sdk/channel/ChannelPoints.kt | 20 +++++ .../alexandrite/sdk/tool/Tool.kt | 10 +++ .../alexandrite/testkit/PublicApiTest.kt | 1 + 4 files changed, 115 insertions(+) create mode 100644 libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/channel/ChannelNotice.kt create mode 100644 libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/channel/ChannelPoints.kt diff --git a/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/channel/ChannelNotice.kt b/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/channel/ChannelNotice.kt new file mode 100644 index 00000000..59aebf5f --- /dev/null +++ b/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/channel/ChannelNotice.kt @@ -0,0 +1,84 @@ +package org.foedusprogramme.alexandrite.sdk.channel + +import dev.drewhamilton.poko.Poko +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import java.time.Instant + +/** + * Something a channel was told that is not a message anybody wrote. + * + * A channel reports the messages its chats send as turns, and everything else it is told — somebody joined, somebody + * left, a message was taken back, a friendship was asked for — as one of these. What a plugin does with one is its own + * business, which is why the kind is a name rather than a closed set: every platform has events the others do not, and + * a subscriber that does not know a kind can still see that something happened. + * + * The texts here are what the platform reported, escaped where they are shown to a model. + */ +@Poko +public class ChannelNotice( + public val chat: ChatAddress, + public val kind: ChannelNoticeKind, + /** Who caused it, null when the platform did not say. */ + public val actor: ChatUser? = null, + /** Whom it concerns besides [actor], such as the member who joined or was removed. */ + public val target: ChatUser? = null, + /** The message it is about, for the kinds that name one, such as a message taken back. */ + public val message: ChannelMessageRef? = null, + public val receivedAt: Instant, + /** What else the platform said, in the order it said it. */ + public val facts: List = emptyList(), +) + +/** + * What kind of [ChannelNotice] this is. + * + * A channel reports the kinds it knows under these names, and may report one of its own under a name of its own, which + * is what keeps a platform's whole vocabulary reachable without this list growing for every platform. + */ +@JvmInline +public value class ChannelNoticeKind(public val id: String) { + override fun toString(): String = id + + public companion object { + /** Somebody joined the chat. */ + public val MEMBER_JOINED: ChannelNoticeKind = ChannelNoticeKind("member.joined") + + /** Somebody left the chat, whether by leaving or by being removed. */ + public val MEMBER_LEFT: ChannelNoticeKind = ChannelNoticeKind("member.left") + + /** Somebody was silenced or unsilenced. */ + public val MEMBER_BANNED: ChannelNoticeKind = ChannelNoticeKind("member.banned") + + /** Somebody's standing in the chat changed, such as becoming an administrator. */ + public val ROLE_CHANGED: ChannelNoticeKind = ChannelNoticeKind("role.changed") + + /** A message was taken back. */ + public val MESSAGE_RECALLED: ChannelNoticeKind = ChannelNoticeKind("message.recalled") + + /** Somebody was added as a friend, or asked to be. */ + public val FRIEND_ADDED: ChannelNoticeKind = ChannelNoticeKind("friend.added") + + /** Somebody asked to join. */ + public val JOIN_REQUESTED: ChannelNoticeKind = ChannelNoticeKind("join.requested") + + /** Somebody asked to be a friend. */ + public val FRIEND_REQUESTED: ChannelNoticeKind = ChannelNoticeKind("friend.requested") + + /** The kinds this version knows. */ + public val entries: List = listOf( + MEMBER_JOINED, + MEMBER_LEFT, + MEMBER_BANNED, + ROLE_CHANGED, + MESSAGE_RECALLED, + FRIEND_ADDED, + JOIN_REQUESTED, + FRIEND_REQUESTED, + ) + + /** The kind of [id], which keeps an id this version does not know. */ + public fun of(id: String): ChannelNoticeKind = entries.firstOrNull { it.id == id } ?: ChannelNoticeKind(id) + } +} diff --git a/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/channel/ChannelPoints.kt b/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/channel/ChannelPoints.kt new file mode 100644 index 00000000..cc47edcc --- /dev/null +++ b/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/channel/ChannelPoints.kt @@ -0,0 +1,20 @@ +package org.foedusprogramme.alexandrite.sdk.channel + +import org.foedusprogramme.alexandrite.sdk.hook.ObserverHook +import org.foedusprogramme.alexandrite.sdk.hook.ObserverPoint + +/** + * The hook points of the channels. + * + * A channel fires these for what its chats were told besides messages. A subscriber contributes an [ObserverHook] of + * the point, and the runtime hands it every notice of every channel that fires it, which is what lets a plugin act on + * an event of a platform it was not written for. + */ +public object ChannelPoints { + /** + * Fires for every report that is not a message, in the order the channel read them. + * + * A notice is not a turn and nothing waits for a subscriber: one that fails is logged and the channel goes on. + */ + public val NOTICE: ObserverPoint = ObserverPoint("channel.notice") +} diff --git a/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/tool/Tool.kt b/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/tool/Tool.kt index 158695e9..1d777bb7 100644 --- a/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/tool/Tool.kt +++ b/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/tool/Tool.kt @@ -3,6 +3,7 @@ package org.foedusprogramme.alexandrite.sdk.tool import dev.drewhamilton.poko.Poko import kotlinx.serialization.json.JsonObject import org.foedusprogramme.alexandrite.sdk.InternalAlexandriteApi +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId import org.foedusprogramme.alexandrite.sdk.chat.TurnInfo import org.foedusprogramme.alexandrite.sdk.di.ContributedSpi @@ -100,6 +101,15 @@ public interface ToolContext { /** What let the call run, null while it is being reviewed. */ public val clearance: Clearance? + /** + * The message this turn was started by, null when no message started it. + * + * A tool that acts on what a person just said reads it from here, because a prompt does not name the message it + * answers: a call that needs an id it was never shown would otherwise have to be told one by the person who wrote + * the message, which is what a turn about that message should not require. + */ + public val trigger: ChannelMessageRef? get() = null + /** Reports that the call brought untrusted content into the conversation, for [reason]. */ public fun untrusted(reason: String) diff --git a/libraries/testkit/src/test/kotlin/org/foedusprogramme/alexandrite/testkit/PublicApiTest.kt b/libraries/testkit/src/test/kotlin/org/foedusprogramme/alexandrite/testkit/PublicApiTest.kt index cc16918e..b34cd298 100644 --- a/libraries/testkit/src/test/kotlin/org/foedusprogramme/alexandrite/testkit/PublicApiTest.kt +++ b/libraries/testkit/src/test/kotlin/org/foedusprogramme/alexandrite/testkit/PublicApiTest.kt @@ -277,6 +277,7 @@ class PublicApiTest { return listOf( mandate, result, tool.definition, tool.calls, tool.cancelled, call.arguments, call.turn, call.call, checked, + context.trigger, context.turn, context.call, context.model, context.workspace, context.touchedPaths, context.openWorkspace(), defined, context.clearance, context.untrustedReasons, context.conversationDirectory, context.openConversationDirectory(), quotaRefusal, background.toString(), From addbe2bab1cadd1cff9707f0ad8ade035b43424e Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 01:31:34 +0800 Subject: [PATCH 06/37] agent: Frame and pass the message a turn answers Names the message id in the prompt, names the quoted message's id, and carries the trigger through the tool context, with the golden files updated. --- .../alexandrite/agent/prompt/MessageFraming.kt | 8 +++++++- .../alexandrite/agent/tool/AgentToolContext.kt | 5 +++++ .../alexandrite/agent/tool/ToolExecutor.kt | 5 ++++- .../alexandrite/agent/turn/AgentTurnRunner.kt | 12 ++++++++++-- .../alexandrite/agent/job/JobsTest.kt | 2 +- .../agent/permission/PermissionStatusToolTest.kt | 1 + .../alexandrite/agent/tool/ToolExecutorTest.kt | 4 ++-- .../alexandrite/agent/turn/FirstAnswerTest.kt | 3 ++- .../src/test/resources/golden/message.v1.direct.txt | 3 ++- .../src/test/resources/golden/message.v1.full.txt | 3 ++- 10 files changed, 36 insertions(+), 10 deletions(-) diff --git a/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/prompt/MessageFraming.kt b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/prompt/MessageFraming.kt index f1add254..c5937424 100644 --- a/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/prompt/MessageFraming.kt +++ b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/prompt/MessageFraming.kt @@ -51,6 +51,9 @@ internal fun messageFraming(message: IncomingMessage, zone: ZoneId, linked: Bool appendLine() appendLine("Operator: ${if (sender.isAdmin) "yes" else "no"}") appendLine("Received: ${TIME.format(message.receivedAt.atZone(zone))}") + // The id of this message, which is what a tool that acts on it needs and what a person can be given to trace it by: + // a prompt naming no id left a call about the message just written with nothing to name it with. + appendLine("Message id: ${oneLine(message.ref.id)}") append("Chat: ").append(message.chatInfo.kind) message.chatInfo.title?.let { append(" \"").append(oneLine(it)).append('"') } if (linked) append(" on ").append(message.chat.instance) @@ -58,7 +61,10 @@ internal fun messageFraming(message: IncomingMessage, zone: ZoneId, linked: Bool message.forwarded?.let { appendLine("Forwarded from: ${oneLine(it.name ?: UNKNOWN)} (${it.kind})") } message.quote?.let { quote -> val trust = if (quote.trust.live) "verified" else "unverified" - appendLine("Quoting: ${oneLine(quote.senderName ?: UNKNOWN)} ($trust)") + // The id of the message that was quoted, so that a call can name it: what a message answers is reported as an id + // and nothing else, and a person quoting a message is asking about that one. + val quoted = quote.target?.let { " [id ${oneLine(it.id)}]" }.orEmpty() + appendLine("Quoting: ${oneLine(quote.senderName ?: UNKNOWN)} ($trust)$quoted") lines(quote.text).forEach { appendLine(if (it.isEmpty()) ">" else "> $it") } } message.facts.forEach { appendLine("${factLabel(it.label)}: ${oneLine(it.value)}") } diff --git a/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/AgentToolContext.kt b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/AgentToolContext.kt index 95152c22..8b3d5c7f 100644 --- a/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/AgentToolContext.kt +++ b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/AgentToolContext.kt @@ -7,6 +7,7 @@ import org.foedusprogramme.alexandrite.agent.directory.ConversationDirectories import org.foedusprogramme.alexandrite.agent.permission.Principal import org.foedusprogramme.alexandrite.agent.permission.TurnTaint import org.foedusprogramme.alexandrite.agent.workspace.Workspaces +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId import org.foedusprogramme.alexandrite.sdk.chat.TurnInfo import org.foedusprogramme.alexandrite.sdk.chat.TurnKind @@ -28,6 +29,8 @@ internal class AgentToolContext( private val directories: ConversationDirectories, clearance: Clearance?, val taint: TurnTaint, + /** The message of the channel this turn answers, which a tool that acts on it is given. */ + trigger: ChannelMessageRef?, /** What a sub-agent run that the call starts takes from its turn, null outside a turn. */ val delegating: DelegatingTurn? = null, /** Whether the call's review asked for it to run as a background job from its start. */ @@ -72,6 +75,8 @@ internal class AgentToolContext( override val workspace: Path? get() = workspaces.paths[turn.agent] + override val trigger: ChannelMessageRef? = trigger + override val conversationDirectory: Path get() = directories.path(turn) diff --git a/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutor.kt b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutor.kt index 8bd35c41..37bd066d 100644 --- a/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutor.kt +++ b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutor.kt @@ -27,6 +27,7 @@ import org.foedusprogramme.alexandrite.agent.prompt.Escaper import org.foedusprogramme.alexandrite.agent.workspace.Workspaces import org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus import org.foedusprogramme.alexandrite.sdk.channel.TurnEvent +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef import org.foedusprogramme.alexandrite.sdk.chat.TurnInfo import org.foedusprogramme.alexandrite.sdk.di.Singleton import org.foedusprogramme.alexandrite.sdk.tool.ReviewPreview @@ -88,6 +89,7 @@ internal class ToolExecutor( callable: Set, taint: TurnTaint, calls: TurnCalls, + trigger: ChannelMessageRef?, ): ToolCallEnd { val tool = tools[call.name] ?: return ended(call, "No tool named '${call.name}' is offered.", NotRunReason.UNKNOWN_TOOL, calls) @@ -97,7 +99,7 @@ internal class ToolExecutor( val arguments = call.parseArguments() ?: return ended(call, "The arguments are not a JSON object.", NotRunReason.INVALID_ARGUMENTS, calls) val limit = tool.definition.timeout?.let { maxOf(timeout, it + DECLARED_TIMEOUT_GRACE) } ?: timeout - val reviewing = AgentToolContext(turn, call.id, workspaces, directories, null, taint) + val reviewing = AgentToolContext(turn, call.id, workspaces, directories, null, taint, trigger) val admission = try { gate.admit(GateCall(turn, call, tool, arguments, limit, taint, reviewing, calls)) } finally { @@ -120,6 +122,7 @@ internal class ToolExecutor( directories, admission.clearance, taint, + trigger, delegating, startsInBackground = admission.background && tool !is InTurnTool, granting = { grants.grant(turn, tool) }, diff --git a/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/turn/AgentTurnRunner.kt b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/turn/AgentTurnRunner.kt index 1bfadc00..29ed4288 100644 --- a/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/turn/AgentTurnRunner.kt +++ b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/turn/AgentTurnRunner.kt @@ -816,8 +816,16 @@ internal class AgentTurnRunner( for (call in calls) { launch { slots.withPermit { - val end = - executor.execute(turn, call, tools.offered, timeout, tools.callable, taint, tracked) + val end = executor.execute( + turn, + call, + tools.offered, + timeout, + tools.callable, + taint, + tracked, + plan.trigger, + ) reads.reason(turn, call.name, end.touched)?.let { taint.mark(it) } taint.record() val found = projects.inspect(turn, end.touched, budget) diff --git a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/job/JobsTest.kt b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/job/JobsTest.kt index bc44769b..e7da5ce1 100644 --- a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/job/JobsTest.kt +++ b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/job/JobsTest.kt @@ -98,7 +98,7 @@ class JobsTest { { _, _ -> null }, ) val name = tool.definition.name - return executor.execute(turn, call, mapOf(name to tool), timeout, setOf(name), taint, DirectCalls) + return executor.execute(turn, call, mapOf(name to tool), timeout, setOf(name), taint, DirectCalls, null) } private suspend fun recorded(): Set = JobRecords(records.of("alexandrite-agent")).all().keys diff --git a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/permission/PermissionStatusToolTest.kt b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/permission/PermissionStatusToolTest.kt index 0d26a0c8..dc8d1e9c 100644 --- a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/permission/PermissionStatusToolTest.kt +++ b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/permission/PermissionStatusToolTest.kt @@ -86,6 +86,7 @@ class PermissionStatusToolTest { ConversationDirectories(ConventionalAgentPlaces(hostPaths(null)), settings("{}")), null, taint, + null, ) @Test diff --git a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutorTest.kt b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutorTest.kt index c26856ba..59555e6f 100644 --- a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutorTest.kt +++ b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutorTest.kt @@ -126,7 +126,7 @@ class ToolExecutorTest { private suspend fun ToolExecutor.executed(tool: Tool, timeout: Duration = 120.seconds): ToolCallEnd { val name = tool.definition.name - return execute(turn, call, mapOf(name to tool), timeout, setOf(name), taint, DirectCalls) + return execute(turn, call, mapOf(name to tool), timeout, setOf(name), taint, DirectCalls, null) } private fun ToolResultEntry.text(): String = content.joinToString("") { (it as TextPart).text } @@ -158,7 +158,7 @@ class ToolExecutorTest { val edits = ToolCallPart(ToolCallId("call-1"), "fs.edit", "{}") executor( gate, - ).execute(turn, edits, mapOf("fs.edit" to edit), 120.seconds, setOf("fs.edit"), taint, calls) + ).execute(turn, edits, mapOf("fs.edit" to edit), 120.seconds, setOf("fs.edit"), taint, calls, null) ended.single() } diff --git a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/turn/FirstAnswerTest.kt b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/turn/FirstAnswerTest.kt index e264fd87..dadb0378 100644 --- a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/turn/FirstAnswerTest.kt +++ b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/turn/FirstAnswerTest.kt @@ -96,13 +96,14 @@ class FirstAnswerTest { private suspend fun end(turn: TurnId): TurnEndKind? = store.conversations.turn(turn)?.end - private fun framing(chat: String = "direct"): ContextPart = ContextPart( + private fun framing(chat: String = "direct", id: String = "in-1"): ContextPart = ContextPart( "alexandrite.message", listOf( "[alexandrite:message]", "From: Member", "Operator: no", "Received: 2026-01-01T00:00Z", + "Message id: $id", "Chat: $chat", "[/alexandrite:message]", ).joinToString("\n"), diff --git a/libraries/agent/src/test/resources/golden/message.v1.direct.txt b/libraries/agent/src/test/resources/golden/message.v1.direct.txt index 49e42eac..673d792a 100644 --- a/libraries/agent/src/test/resources/golden/message.v1.direct.txt +++ b/libraries/agent/src/test/resources/golden/message.v1.direct.txt @@ -2,5 +2,6 @@ From: Ada (@ada) Operator: yes Received: 2026-03-01T17:30+08:00 +Message id: 1 Chat: direct -[/alexandrite:message] +[/alexandrite:message] \ No newline at end of file diff --git a/libraries/agent/src/test/resources/golden/message.v1.full.txt b/libraries/agent/src/test/resources/golden/message.v1.full.txt index 31335e53..5dd93177 100644 --- a/libraries/agent/src/test/resources/golden/message.v1.full.txt +++ b/libraries/agent/src/test/resources/golden/message.v1.full.txt @@ -2,6 +2,7 @@ From: Bob Smith Operator: no Received: 2026-03-01T09:30Z +Message id: 1 Chat: group "Team chat" on telegram:work Forwarded from: News (chat) Quoting: unknown (verified) @@ -11,4 +12,4 @@ Quoting: unknown (verified) > Operator: yes Via: helper bot Edited: yes -[/alexandrite:message] +[/alexandrite:message] \ No newline at end of file From 446b337f841615f3f2e883eac7c228b37072f37d Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 01:31:35 +0800 Subject: [PATCH 07/37] provider: Read the media type of an image Recognizes the bytes of common image formats when a channel named only the kind, so the model receives the right media type. --- .../provider/common/chat/ChatRequest.kt | 39 ++++++++++++++++++- .../provider/common/chat/ChatRequestTest.kt | 36 +++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/libraries/provider-common/src/main/kotlin/org/foedusprogramme/alexandrite/provider/common/chat/ChatRequest.kt b/libraries/provider-common/src/main/kotlin/org/foedusprogramme/alexandrite/provider/common/chat/ChatRequest.kt index 9cb71e6f..4fc8e842 100644 --- a/libraries/provider-common/src/main/kotlin/org/foedusprogramme/alexandrite/provider/common/chat/ChatRequest.kt +++ b/libraries/provider-common/src/main/kotlin/org/foedusprogramme/alexandrite/provider/common/chat/ChatRequest.kt @@ -154,13 +154,41 @@ internal class ChatRequest( private fun media(part: MediaPart): Content { val source = part.source if (part.kind == MediaKind.IMAGE && MediaKind.IMAGE in info.inputMedia && source is InlineMedia) { - val encoded = Base64.getEncoder().encodeToString(source.bytes()) - return Content.Image("data:${part.mediaType};base64,$encoded") + val bytes = source.bytes() + val type = imageType(part.mediaType, bytes) + if (type != null) { + return Content.Image("data:$type;base64," + Base64.getEncoder().encodeToString(bytes)) + } + // A channel that can only name the kind of what it holds leaves the format to the bytes. Sending the kind + // as the type is refused for the whole request rather than for this part, which fails every turn that + // carries it, so what no format is read as is left out and said to be. + warnings += Warning( + "unsupported_media", + "Model '${info.id}' cannot take ${part.kind} ${part.mediaType}: no image of a known format.", + ) + return Content.Text(placeholder(part, "it is in no format the model takes")) } warnings += Warning("unsupported_media", "Model '${info.id}' cannot take ${part.kind} ${part.mediaType}.") return Content.Text(placeholder(part, "the model cannot take it")) } + // The type to send the bytes as: what the channel declared where it names a format, and where it names only the + // kind, the format the bytes themselves are, or null when they are no image the models take. + private fun imageType(declared: String, bytes: ByteArray): String? { + if (!declared.endsWith("/*")) return declared + return when { + bytes.startsWith(PNG) -> "image/png" + bytes.startsWith(JPEG) -> "image/jpeg" + bytes.startsWith(GIF) -> "image/gif" + bytes.startsWith(RIFF) && bytes.size > 12 && bytes.copyOfRange(8, 12).contentEquals(WEBP) -> "image/webp" + else -> null + } + } + + // Whether these bytes begin with those, which is how a format is read from an image. + private fun ByteArray.startsWith(prefix: ByteArray): Boolean = + size >= prefix.size && prefix.indices.all { this[it] == prefix[it] } + private fun assistant(entry: AssistantEntry): JsonObject = buildJsonObject { put("role", "assistant") val parts = entry.parts @@ -255,5 +283,12 @@ internal class ChatRequest( private companion object { const val SEPARATOR = "\n\n" + + // The bytes an image of each format begins with, which say what a channel that named only the kind holds. + val PNG: ByteArray = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + val JPEG: ByteArray = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte()) + val GIF: ByteArray = "GIF8".toByteArray() + val RIFF: ByteArray = "RIFF".toByteArray() + val WEBP: ByteArray = "WEBP".toByteArray() } } diff --git a/libraries/provider-common/src/test/kotlin/org/foedusprogramme/alexandrite/provider/common/chat/ChatRequestTest.kt b/libraries/provider-common/src/test/kotlin/org/foedusprogramme/alexandrite/provider/common/chat/ChatRequestTest.kt index 368216a5..fd990934 100644 --- a/libraries/provider-common/src/test/kotlin/org/foedusprogramme/alexandrite/provider/common/chat/ChatRequestTest.kt +++ b/libraries/provider-common/src/test/kotlin/org/foedusprogramme/alexandrite/provider/common/chat/ChatRequestTest.kt @@ -297,6 +297,42 @@ class ChatRequestTest { assertEquals(listOf("unsupported_media"), blind.warnings.map { it.kind }) } + @Test + fun `an image whose channel names only the kind goes out as the format its bytes are`() { + val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) + val jpeg = MediaPart(MediaKind.IMAGE, "image/jpeg", InlineMedia(byteArrayOf(1, 2, 3)), "dot.jpg") + val sniffedPng = MediaPart(MediaKind.IMAGE, "image/*", InlineMedia(png), "dot") + val gif = MediaPart(MediaKind.IMAGE, "image/*", InlineMedia("GIF89a".toByteArray()), "dot") + val webp = MediaPart(MediaKind.IMAGE, "image/*", InlineMedia("RIFF____WEBPVP8 ".toByteArray()), "dot") + + // A channel that names the format is taken at its word, whatever the bytes are. + assertEquals("data:image/jpeg", sent(jpeg).substring(0, 15)) + // One that names only the kind leaves the format to the bytes, which is what the model is sent. + assertEquals("data:image/png", sent(sniffedPng).substring(0, 14)) + assertEquals("data:image/gif", sent(gif).substring(0, 14)) + assertEquals("data:image/webp", sent(webp).substring(0, 15)) + } + + @Test + fun `an image of no known format is left out and said to be, rather than sent as a type the model refuses`() { + val not = MediaPart(MediaKind.IMAGE, "image/*", InlineMedia("no picture".toByteArray()), "dot") + + val refused = chat(request(listOf(UserEntry(null, listOf(not), user("x").origin)))) + + assertEquals( + "[image dot not shown: it is in no format the model takes]", + refused.body.messages().single().text("content"), + ) + assertEquals(listOf("unsupported_media"), refused.warnings.map { it.kind }) + } + + /** The data URL the model is sent for [part]. */ + private fun sent(part: MediaPart): String { + val body = body(request(listOf(UserEntry(null, listOf(part), user("x").origin)))) + return body.messages().single()["content"]!!.jsonArray.single().jsonObject["image_url"]!! + .jsonObject.text("url")!! + } + @Test fun `tools, tool choice and options map onto their fields`() { val options = ModelOptions.builder().maxOutputTokens(512).temperature(0.2).topP(0.9) From cea0c71b6e54d392a189d549598d45dd29c2ce44 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 01:31:38 +0800 Subject: [PATCH 08/37] onebot: Add its tests Covers protocol codecs, transports, mapping, pairing, commands, approvals, progress, media, tools and a channel end to end. --- .../onebot/OneBotApprovalIntegrationTest.kt | 359 ++++++++++ .../onebot/OneBotChannelIntegrationTest.kt | 631 ++++++++++++++++++ .../alexandrite/channel/onebot/OneBotLogs.kt | 28 + .../onebot/OneBotMediaIntegrationTest.kt | 231 +++++++ .../onebot/OneBotNoticeIntegrationTest.kt | 160 +++++ .../onebot/OneBotQuoteIntegrationTest.kt | 258 +++++++ .../channel/onebot/auth/OneBotAuthTest.kt | 74 ++ .../onebot/channel/OneBotActivitiesTest.kt | 50 ++ .../onebot/channel/OneBotCommandRulesTest.kt | 146 ++++ .../onebot/channel/OneBotCommandsTest.kt | 156 +++++ .../onebot/channel/OneBotHttpTransportTest.kt | 209 ++++++ .../onebot/channel/OneBotProgressTest.kt | 231 +++++++ .../onebot/channel/OneBotReplyRegistryTest.kt | 58 ++ .../onebot/channel/OneBotWordsHookTest.kt | 99 +++ .../channel/onebot/i18n/TextKeysTest.kt | 34 + .../onebot/mapping/OneBotMarkdownTest.kt | 91 +++ .../onebot/mapping/OneBotMessagesTest.kt | 284 ++++++++ .../onebot/mapping/OneBotNoticesTest.kt | 248 +++++++ .../onebot/protocol/api/OneBotRegistryTest.kt | 125 ++++ .../protocol/event/OneBotEventCodecTest.kt | 394 +++++++++++ .../onebot/protocol/message/CqCodeTest.kt | 273 ++++++++ .../protocol/message/OneBotFileSegmentTest.kt | 99 +++ .../onebot/tools/OneBotActDescriptionTest.kt | 33 + .../channel/onebot/tools/OneBotActionsTest.kt | 144 ++++ .../onebot/tools/OneBotFileArgumentTest.kt | 180 +++++ .../onebot/tools/OneBotGroupFilesTest.kt | 53 ++ .../channel/onebot/tools/OneBotOutcomeTest.kt | 67 ++ .../channel/onebot/tools/OneBotPrepareTest.kt | 133 ++++ .../onebot/tools/OneBotToolDefinitionTest.kt | 28 + .../channel/onebot/tools/OneBotUploadTest.kt | 170 +++++ .../transport/OneBotHttpPostReceiverTest.kt | 237 +++++++ .../onebot/transport/WsTransportTest.kt | 256 +++++++ 32 files changed, 5539 insertions(+) create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotChannelIntegrationTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotLogs.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotMediaIntegrationTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotNoticeIntegrationTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotQuoteIntegrationTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/auth/OneBotAuthTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotActivitiesTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandsTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotHttpTransportTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgressTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotReplyRegistryTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotWordsHookTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/i18n/TextKeysTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdownTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessagesTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotNoticesTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRegistryTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodecTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCodeTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotFileSegmentTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActDescriptionTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionsTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotFileArgumentTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotGroupFilesTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotOutcomeTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotPrepareTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolDefinitionTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUploadTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiverTest.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/WsTransportTest.kt diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt new file mode 100644 index 00000000..efca9b83 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt @@ -0,0 +1,359 @@ +package org.foedusprogramme.alexandrite.channel.onebot + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalAnswer +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalRequest +import org.foedusprogramme.alexandrite.sdk.channel.Channel +import org.foedusprogramme.alexandrite.sdk.channel.ChannelDirectory +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.ChatUser +import org.foedusprogramme.alexandrite.sdk.chat.TurnInfo +import org.foedusprogramme.alexandrite.sdk.chat.UserAddress +import org.foedusprogramme.alexandrite.sdk.di.key +import org.foedusprogramme.alexandrite.sdk.tool.ToolRisk +import org.foedusprogramme.alexandrite.testkit.PluginHarness +import org.foedusprogramme.alexandrite.testkit.PluginHarness.Running +import org.foedusprogramme.alexandrite.testkit.RecordingTurnSubmitter +import org.foedusprogramme.alexandrite.testkit.testTurn +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 java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Approvals inside a real runtime. + * + * A peer built from the library serves the implementation, and the test drives what an admin would do: the approval is + * shown as a message, and answering it means replying to that message or writing the id of the request. + */ +class OneBotApprovalIntegrationTest { + private val instance = ChannelInstanceId(ChannelType("onebot"), "main") + + private val directory = key() + private val group = ChatAddress(instance, "group:987654") + + @Test + fun `an approval is answered by replying to the message that carried it`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + val asking = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("k3x9")) } + val carried = waiting { peer.awaitSent(1) }.last().id + peer.report(command(replyTo = carried, text = "/approve")) + val answer = assertIs(asking.await()) + assertTrue(answer.by.isAdmin, "the answer did not name an admin") + } + } + } finally { + peer.stop() + } + } + + @Test + fun `an approval the platform refuses once is still shown`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + peer.refuseNext() + val asking = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("retry1")) } + val carried = waiting { peer.awaitSent(1) }.last().id + peer.report(command(replyTo = carried, text = "/approve")) + assertIs(asking.await()) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `an approval no chat takes is answered with what the platform said`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + peer.refuseNext(times = 3) + val asking = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("retry2")) } + val answer = assertIs(asking.await()) + assertTrue( + answer.reason.contains("the platform took no message of it"), + "the reason did not name the platform: ${answer.reason}", + ) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `two requests waiting in one chat are told apart by the message each came in`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + val first = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("aaa111")) } + val second = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("bbb222")) } + val sent = waiting { peer.awaitSent(2) } + val carriedBySecond = sent.last().id + peer.report(command(replyTo = carriedBySecond, text = "/approve")) + assertIs(second.await()) + assertTrue(!first.isCompleted, "answering one request answered the other") + peer.report(command(replyTo = sent.first().id, text = "/deny")) + assertIs(first.await()) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `an id that names several requests answers none of them`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + val first = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("aaa111")) } + val second = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("aaa222")) } + waiting { peer.awaitSent(2) } + peer.report(command(text = "/approve aaa")) + val said = waiting { peer.awaitSent(3) }.last().params + assertTrue( + said.contains("names no one request"), + "an ambiguous id was not refused: $said", + ) + assertTrue(!first.isCompleted && !second.isCompleted, "an ambiguous id answered a request") + // The whole id answers the one it names. + peer.report(command(text = "/approve aaa222")) + assertIs(second.await()) + peer.report(command(text = "/deny aaa111")) + assertIs(first.await()) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `somebody who is no admin of the instance answers nothing`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + val asking = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("k3x9")) } + val carried = waiting { peer.awaitSent(1) }.last().id + peer.report(command(replyTo = carried, text = "/approve", from = "99999999", role = "member")) + val said = waiting { peer.awaitSent(2) }.last().params + assertTrue( + said.contains("Only an admin"), + "somebody who is no admin was answered: $said", + ) + assertTrue(!asking.isCompleted, "somebody who is no admin answered a request") + peer.report(command(replyTo = carried, text = "/approve")) + assertIs(asking.await()) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `an answer that is taken is said once`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + val asking = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("k3x9")) } + val carried = waiting { peer.awaitSent(1) }.last().id + peer.report(command(replyTo = carried, text = "/approve")) + assertIs(asking.await()) + // The request, and the line that says how it ended. The answer is not said a second time in the + // chat the command was written in, which is what an earlier version of this did. + val sent = waiting { peer.awaitQuiet() } + assertEquals(2, sent.size, "an answer that was taken was said more than once: $sent") + assertTrue( + sent.last().params.contains("is allowed by"), + "the outcome of the answer was not said: ${sent.last().params}", + ) + } + } + } finally { + peer.stop() + } + } + + /** Runs [block] with the plugin built over the peer on [port]. */ + private suspend fun harness(peer: ApprovalPeer, port: Int, block: suspend Running.() -> Unit) { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(RecordingTurnSubmitter()) + .build() + .run { + assertTrue(waiting { peer.awaitConnection() }, "the channel did not connect to the implementation") + block() + } + } + + /** Waits for [block], which blocks its thread, without holding the one the test runs on. */ + private suspend fun waiting(block: () -> T): T = withContext(Dispatchers.IO) { block() } + + /** The channel of the instance under test, which is not the recording channel of the harness. */ + private suspend fun Running.front(): Channel = + get(directory).channel(instance) ?: error("the instance has no channel") + + /** An approval request of [id] that the user of the group asks for. */ + private fun request(id: String): ApprovalRequest = ApprovalRequest( + id = id, + turn = testTurn(chat = group) { actor(admin) }, + tool = "fs.write", + risk = ToolRisk.WORKSPACE_WRITE, + arguments = """{"path":"notes.md"}""", + preview = null, + reason = "the chat is in ask mode", + always = null, + ) + + /** A report of the group that answers the message [replyTo] with [text], sent by [from]. */ + private fun command(replyTo: String? = null, text: String, from: String = USER, role: String = "admin"): String = + buildString { + append("""{"time":1515204254,"self_id":10001000,"post_type":"message","message_type":"group",""") + append( + """"sub_type":"normal","message_id":${nextReport.incrementAndGet()},"group_id":987654,"user_id":$from,"message":[""", + ) + replyTo?.let { append("""{"type":"reply","data":{"id":"$it"}},""") } + append("""{"type":"text","data":{"text":"$text"}}],""") + append(""""raw_message":"$text",""") + append(""""sender":{"user_id":$from,"nickname":"someone","role":"$role"}}""") + } + + private val nextReport = AtomicInteger(5000) + + private val admin: ChatUser = ChatUser(UserAddress(instance, USER), "someone", null, isBot = false, isAdmin = true) + + 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, + "allowedChats": ["group:987654", "private:12345678"], + "admins": ["$USER"], + "approvalMinutes": 1 + } + } + } + """.trimIndent() + + private companion object { + const val USER = "12345678" + } + + /** An implementation that serves the API, numbering the messages it is told to send, and reports what the test says. */ + private class ApprovalPeer : WebSocketServer(InetSocketAddress("127.0.0.1", 0)) { + private val started = CountDownLatch(1) + private val connected = CountDownLatch(1) + private val counter = AtomicInteger(100) + private val sends = CopyOnWriteArrayList() + private val refusals = AtomicInteger(0) + + @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) { + val echo = message.substringAfter("\"echo\":\"", "").substringBefore('"') + if (echo.isEmpty()) return + // An answer carries the message id of what was sent, which is what the test answers a request by. + val sending = !message.contains("\"get_login_info\"") && !message.contains("\"get_msg\"") + val id = if (sending) "${counter.incrementAndGet()}" else "${counter.get()}" + if (sending && refusals.getAndUpdate { if (it > 0) it - 1 else 0 } > 0) { + // A message the platform refuses is not one it took, so it is not among those a test waits for. + connection.send("""{"status":"failed","retcode":1,"data":null,"echo":"$echo"}""") + return + } + if (sending) sends += Sent(id, message) + connection.send("""{"status":"ok","retcode":0,"data":{"message_id":$id},"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 + + fun startAndPort(): Int { + start() + check(started.await(5, TimeUnit.SECONDS)) { "the peer did not start" } + return port + } + + fun report(json: String) { + val connection = socket ?: error("nothing is connected") + connection.send(json) + } + + /** Makes the next [times] messages the peer takes be refused, as a platform that will not take them does. */ + fun refuseNext(times: Int = 1) { + refusals.set(times) + } + + fun awaitConnection(): Boolean = connected.await(5, TimeUnit.SECONDS) + + /** Waits until at least [count] messages were sent, and returns everything sent so far. */ + fun awaitSent(count: Int): List { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + if (sends.size >= count) return sends.toList() + Thread.sleep(20) + } + error("only ${sends.size} messages were sent, wanted $count: $sends") + } + + /** Waits a moment for anything else to arrive, and returns everything that was sent. */ + fun awaitQuiet(millis: Long = 600): List { + Thread.sleep(millis) + return sends.toList() + } + + class Sent(val id: String, val params: String) + } +} 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 00000000..1852372e --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotChannelIntegrationTest.kt @@ -0,0 +1,631 @@ +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.Channel +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.OutboundFile +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.channel.ReplyRequest +import org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus +import org.foedusprogramme.alexandrite.sdk.channel.TurnEvent +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.ToolCallId +import org.foedusprogramme.alexandrite.sdk.di.key +import org.foedusprogramme.alexandrite.sdk.tool.ToolActivity +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.foedusprogramme.alexandrite.testkit.testTurn +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() + } + } + + @Test + fun `a group message that names nobody reaches no agent`() { + 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(groupMessage(text = "hello there")) + assertTrue( + awaitNoSubmission(submitter), + "the agent was asked for a turn of a message that names nobody", + ) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a group message that only names the bot reaches no agent`() { + // The Telegram channel ignores a message that is only a mention of the bot, and this channel now does too: + // naming the bot with nothing to answer is not a message the agent is asked a turn of. + 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(groupMessage(text = "", at = "10001000")) + assertTrue( + awaitNoSubmission(submitter), + "the agent was asked for a turn of a message that only names the bot", + ) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a group message that is only a slash and words reaches no agent`() { + // The other channel hears a command only when the platform marks one, so a message whose slash does not start + // a command name is a message like any other: in a group it reaches the bot only when it is addressed to it. + 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(groupMessage(text = "/随便说点什么")) + assertTrue( + awaitNoSubmission(submitter), + "the agent was asked for a turn of a slash message that names no command", + ) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a group message that names the bot reaches the agent`() { + 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(groupMessage(text = "hello there", at = "10001000")) + val message = assertIs(awaitSubmission(submitter)) + assertEquals("hello there", message.message.text) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a command in a group reaches the agent as a command without naming the bot`() { + 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(groupMessage(text = "/model deepseek/one")) + val command = assertIs(awaitSubmission(submitter)) + assertEquals("model", command.invocation.name) + assertEquals("deepseek/one", command.invocation.arguments) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a group message answering a message the bot sent reaches the agent`() { + 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") + val group = ChatAddress(instance, "group:987654") + assertIs(sendTo(group)) + peer.report(groupMessage(text = "hello there", reply = "8")) + assertTrue( + awaitNoSubmission(submitter), + "the agent was asked for a turn of a message answering somebody else", + ) + peer.report(groupMessage(text = "hello there", reply = "7")) + val message = assertIs(awaitSubmission(submitter)) + assertEquals("hello there", message.message.text) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a chat this instance does not serve is offered its pairing code and reaches no agent`() { + 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(unservedGroupMessage()) + val said = peer.awaitCall("send_group_msg") + assertTrue(said.contains("not paired"), "the chat was not offered a code: $said") + assertTrue( + awaitNoSubmission(submitter), + "a message of a chat this instance does not serve reached the agent", + ) + } + } + } finally { + peer.stop() + } + } + + /** A group report of a chat that the config of the test does not declare. */ + private fun unservedGroupMessage(): String = """ + { + "time": 1515204254, + "self_id": 10001000, + "post_type": "message", + "message_type": "group", + "sub_type": "normal", + "message_id": 12, + "group_id": 111111, + "user_id": 12345678, + "message": [ + {"type": "at", "data": {"qq": 10001000}}, + {"type": "text", "data": {"text": "hello there"}} + ], + "raw_message": "hello there", + "sender": {"user_id": 12345678, "nickname": "someone", "role": "member"} + } + """.trimIndent() + + 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") + } + + @Test + fun `a turn that works says so as it starts and as it ends, and not on every step`() { + 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 chat = assertIs(awaitSubmission(submitter)).message.chat + val sink = front().openReply(ReplyRequest(testTurn(chat = chat), null)) + + sink.preview(0, "Let me check.") + assertEquals(0, peer.callsOf("send_private_msg"), "a preview is no message of this platform") + + sink.startSegment(1) + assertEquals(0, peer.callsOf("send_private_msg"), "a segment that ended is no message either") + + sink.event( + TurnEvent.ToolCallStarted(ToolCallId("c1"), "fs.read", ToolActivity.READ, "notes.md"), + ) + val told = peer.awaitCall("send_private_msg") + assertTrue(told.contains("Reading 1 file"), "the chat was not told of the work: $told") + + sink.event( + TurnEvent.ToolCallStarted(ToolCallId("c2"), "fs.search", ToolActivity.SEARCH, "milk"), + ) + sink.event(TurnEvent.ToolCallUpdated(ToolCallId("c2"), ToolCallStatus.COMPLETED)) + sink.preview(1, "It ran.") + assertEquals(1, peer.callsOf("send_private_msg"), "the words of a response are not a preview") + + sink.complete(OutboundMessage.builder("done", MessageKind.REPLY).build()) + val last = peer.awaitCall("send_private_msg") + assertTrue(last.contains("done"), "the answer did not go out: $last") + assertEquals( + 3, + peer.callsOf("send_private_msg"), + "the chat is told as the work starts, told what ended before the answer, then answered", + ) + } + } + } finally { + peer.stop() + } + } + + /** The channel of the instance under test. */ + private suspend fun Running.front(): Channel = + get(directory).channel(instance) ?: error("the instance has no channel") + + @Test + fun `what the channel sends and drops is logged`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + val lines = runBlocking { + logged { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection()) + peer.report(privateMessage()) + val chat = assertIs(awaitSubmission(submitter)).message.chat + val sink = front().openReply(ReplyRequest(testTurn(chat = chat), null)) + sink.preview(0, "Let me check.") + sink.startSegment(1) + sink.complete(OutboundMessage.builder("done", MessageKind.REPLY).build()) + } + } + } + + assertTrue(lines.any { it.contains("Sent ") }) + assertTrue(lines.any { it.contains("segment 0") }) + assertTrue(lines.any { it.contains("becomes a turn") }) + } finally { + peer.stop() + } + } + + @Test + fun `two replies of one conversation tell a chat of its first call once`() { + 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 chat = assertIs(awaitSubmission(submitter)).message.chat + val turn = testTurn(chat = chat) + val first = front().openReply(ReplyRequest(turn, null)) + val second = front().openReply(ReplyRequest(turn, null)) + + first.event( + TurnEvent.ToolCallStarted(ToolCallId("c1"), "fs.read", ToolActivity.READ, "notes.md"), + ) + val told = peer.awaitCall("send_private_msg") + assertTrue(told.contains("Reading 1 file"), "the first call of the conversation: $told") + + second.event( + TurnEvent.ToolCallStarted(ToolCallId("c2"), "fs.list", ToolActivity.LIST, "src"), + ) + assertEquals( + 1, + peer.callsOf("send_private_msg"), + "a chat hears the first call of its conversation once, however many replies it has", + ) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a message that carries a file goes as the file alone`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + val bytes = byteArrayOf(1, 2, 3) + 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 chat = assertIs(awaitSubmission(submitter)).message.chat + val file = OutboundFile.builder("notes.txt", "text/plain", bytes).build() + val message = + OutboundMessage.builder("here it is", MessageKind.REPLY).files(listOf(file)).build() + + front().send(chat, message) + + val call = peer.awaitCall("upload_private_file") + assertTrue( + call.contains("base64://" + java.util.Base64.getEncoder().encodeToString(bytes)), + "the bytes go to the implementation: $call", + ) + assertTrue(call.contains("notes.txt"), "the file keeps its name: $call") + assertEquals( + 0, + peer.callsOf("send_private_msg"), + "this platform shows the file's own name, so no text is sent beside it", + ) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a message the platform refuses is logged as one that was not sent`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + val lines = runBlocking { + logged { + 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 chat = assertIs(awaitSubmission(submitter)).message.chat + peer.failNext("send_private_msg") + val delivery = front().send( + chat, + OutboundMessage.builder("hi", MessageKind.REPLY).build(), + ) + assertIs(delivery, "the platform refused the message") + } + } + } + assertTrue(lines.any { it.contains("Could not send") }, "the refusal was not logged: $lines") + assertTrue(lines.none { it.contains("Sent REPLY") }, "a refused message was logged as sent: $lines") + } finally { + peer.stop() + } + } + + 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, + "allowedChats": ["group:987654", "private:12345678"] + } + } + } + """.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() + + /** A group report of [text], whose message names [at] or answers [reply] when they are given. */ + private fun groupMessage(text: String, at: String? = null, reply: String? = null): String = buildString { + append("""{"time":1515204254,"self_id":10001000,"post_type":"message","message_type":"group",""") + append(""""sub_type":"normal","message_id":12,"group_id":987654,"user_id":12345678,"message":[""") + at?.let { append("""{"type":"at","data":{"qq":$it}},""") } + reply?.let { append("""{"type":"reply","data":{"id":$it}},""") } + append("""{"type":"text","data":{"text":"$text"}}],""") + append(""""raw_message":"$text",""") + append(""""sender":{"user_id":12345678,"nickname":"someone","role":"member"}}""") + } + + /** Whether the agent was asked for no turn within the time one would arrive in. */ + private suspend fun awaitNoSubmission(submitter: RecordingTurnSubmitter): Boolean { + val deadline = System.nanoTime() + 500_000_000L + while (System.nanoTime() < deadline) { + if (submitter.submissions.isNotEmpty()) return false + kotlinx.coroutines.delay(20) + } + return true + } + + /** 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 + val refused = failing.firstOrNull { message.contains("\"$it\"") }?.also { failing.remove(it) } + if (refused != null) { + connection.send("""{"status":"failed","retcode":1,"data":null,"echo":"$echo"}""") + 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) + } + + /** The actions whose next call the peer answers with a failure, which a test sets to make one fail. */ + private val failing = CopyOnWriteArrayList() + + /** Answers the next call of [action] with a failure, once. */ + fun failNext(action: String) { + failing += action + } + + /** Waits for the channel to connect and returns whether it did. */ + fun awaitConnection(): Boolean = connected.await(5, TimeUnit.SECONDS) + + /** How many calls of [action] the implementation was sent. */ + fun callsOf(action: String): Int = calls.count { it.contains("\"$action\"") } + + /** 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/OneBotLogs.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotLogs.kt new file mode 100644 index 00000000..b8b5f10f --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotLogs.kt @@ -0,0 +1,28 @@ +package org.foedusprogramme.alexandrite.channel.onebot + +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.AppenderBase +import org.slf4j.LoggerFactory +import java.util.concurrent.CopyOnWriteArrayList + +/** The lines logged while [block] ran, as `LEVEL message`. */ +internal suspend fun logged(block: suspend () -> Unit): List { + val logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) as Logger + val recorder = LineRecorder().apply { start() } + logger.addAppender(recorder) + try { + block() + } finally { + logger.detachAppender(recorder) + } + return recorder.lines.toList() +} + +private class LineRecorder : AppenderBase() { + val lines = CopyOnWriteArrayList() + + override fun append(event: ILoggingEvent) { + lines += "${event.level} ${event.formattedMessage}" + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotMediaIntegrationTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotMediaIntegrationTest.kt new file mode 100644 index 00000000..8bdbcf81 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotMediaIntegrationTest.kt @@ -0,0 +1,231 @@ +package org.foedusprogramme.alexandrite.channel.onebot + +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.foedusprogramme.alexandrite.sdk.channel.ChannelDirectory +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +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.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * What a message carrying a picture gives the agent. + * + * A report names the picture by an address, and the bytes behind that address are what the model is shown. A reader + * that cannot get them says so rather than handing the turn an empty file. + */ +class OneBotMediaIntegrationTest { + private val instance = ChannelInstanceId(ChannelType("onebot"), "main") + + private val directory = key() + private val picture = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8) + + @Test + fun `a picture a report names is read from the address it names`() { + withPicture { url, peer, submitter -> + peer.report(pictureMessage(url)) + val message = assertIs(waiting { awaitSubmission(submitter) }).message + val attachment = message.media.single() + assertEquals("image/*", attachment.mediaType) + assertContentEquals(picture, waiting { runBlocking { attachment.content.read(1024) } }) + } + } + + @Test + fun `a picture larger than the agent allows is refused`() { + withPicture { url, peer, submitter -> + peer.report(pictureMessage(url)) + val message = assertIs(waiting { awaitSubmission(submitter) }).message + val attachment = message.media.single() + val failure = assertFailsWith { waiting { runBlocking { attachment.content.read(4) } } } + assertTrue( + failure.message.orEmpty().contains("larger"), + "a body larger than the agent allows was not refused: ${failure.message}", + ) + } + } + + @Test + fun `a picture in the message that was quoted reaches the agent as well`() { + withPicture { url, peer, submitter -> + peer.report(pictureMessage(url)) + awaitSubmissions(submitter, 1) + peer.report(questionAbout(12)) + val message = assertIs(awaitSubmissions(submitter, 2).last()).message + // The report answers a message holding a picture and says nothing about that picture, so the picture + // reaches the turn only because what was read of the quoted message is kept beside its text. + val attachment = message.media.single() + assertEquals("image/*", attachment.mediaType) + assertContentEquals(picture, waiting { runBlocking { attachment.content.read(1024) } }) + } + } + + /** A group report that answers the message [id] and names the bot. */ + private fun questionAbout(id: Long): String = """ + { + "time": 1515204255, + "self_id": 10001000, + "post_type": "message", + "message_type": "group", + "sub_type": "normal", + "message_id": 13, + "group_id": 987654, + "user_id": 12345678, + "message": [ + {"type": "reply", "data": {"id": $id}}, + {"type": "at", "data": {"qq": 10001000}}, + {"type": "text", "data": {"text": "what is this"}} + ], + "raw_message": "[CQ:reply,id=$id] what is this", + "sender": {"user_id": 12345678, "nickname": "someone", "role": "member"} + } + """.trimIndent() + + /** The first [count] turns the agent was asked for. */ + private suspend fun awaitSubmissions(submitter: RecordingTurnSubmitter, count: Int): List { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + val taken = submitter.submissions + if (taken.size >= count) return taken + Thread.sleep(20) + } + error("the agent was asked for ${submitter.submissions.size} turns, not $count") + } + + /** Serves [picture] over HTTP, runs the plugin against [peer], and calls [block]. */ + private fun withPicture(block: suspend Running.(String, MediaPeer, RecordingTurnSubmitter) -> Unit) { + val http = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + http.createContext("/picture") { exchange -> + exchange.sendResponseHeaders(200, picture.size.toLong()) + exchange.responseBody.use { it.write(picture) } + } + http.start() + val url = "http://127.0.0.1:${http.address.port}/picture" + val peer = MediaPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(waiting { peer.awaitConnection() }, "the channel did not connect") + block(url, peer, submitter) + } + } + } finally { + peer.stop() + http.stop(0) + } + } + + private suspend fun waiting(block: () -> T): T = withContext(Dispatchers.IO) { block() } + + private fun awaitSubmission(submitter: RecordingTurnSubmitter): Submission { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + submitter.submissions.firstOrNull()?.let { return it } + Thread.sleep(20) + } + error("the agent was asked for no turn") + } + + /** A group report that names the bot and carries a picture at [url]. */ + private fun pictureMessage(url: String): String = """ + { + "time": 1515204254, + "self_id": 10001000, + "post_type": "message", + "message_type": "group", + "sub_type": "normal", + "message_id": 12, + "group_id": 987654, + "user_id": 12345678, + "message": [ + {"type": "at", "data": {"qq": 10001000}}, + {"type": "image", "data": {"url": "$url", "file": "a.jpg"}} + ], + "raw_message": "[image]", + "sender": {"user_id": 12345678, "nickname": "someone", "role": "member"} + } + """.trimIndent() + + 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, + "allowedChats": ["group:987654"] + } + } + } + """.trimIndent() + + /** An implementation that serves the API and reports what the test says. */ + private class MediaPeer : WebSocketServer(InetSocketAddress("127.0.0.1", 0)) { + private val started = CountDownLatch(1) + private val connected = CountDownLatch(1) + + @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) { + 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 + + fun startAndPort(): Int { + start() + check(started.await(5, TimeUnit.SECONDS)) { "the peer did not start" } + return port + } + + fun report(json: String) { + val connection = socket ?: error("nothing is connected") + connection.send(json) + } + + fun awaitConnection(): Boolean = connected.await(5, TimeUnit.SECONDS) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotNoticeIntegrationTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotNoticeIntegrationTest.kt new file mode 100644 index 00000000..2469192d --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotNoticeIntegrationTest.kt @@ -0,0 +1,160 @@ +package org.foedusprogramme.alexandrite.channel.onebot + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.foedusprogramme.alexandrite.sdk.channel.ChannelNotice +import org.foedusprogramme.alexandrite.sdk.channel.ChannelNoticeKind +import org.foedusprogramme.alexandrite.sdk.channel.ChannelPoints +import org.foedusprogramme.alexandrite.sdk.hook.ObserverHook +import org.foedusprogramme.alexandrite.sdk.hook.ObserverPoint +import org.foedusprogramme.alexandrite.testkit.PluginHarness +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.assertTrue + +/** + * A report that is not a message reaching a plugin. + * + * Nothing in the runtime knows about a channel notice: it reaches a subscriber because the subscriber says which point + * it observes, which is what this proves by observing one. + */ +class OneBotNoticeIntegrationTest { + @Test + fun `a report that is not a message reaches a plugin that observes it`() { + val peer = NoticePeer() + val port = peer.startAndPort() + val observer = RecordingObserver() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(RecordingTurnSubmitter()) + .hook(observer) + .build() + .run { + assertTrue(waiting { peer.awaitConnection() }, "the channel did not connect") + peer.report(increase()) + val notice = assertNotNull( + waiting { observer.awaitOne() }, + "the notice reached no subscriber", + ) + assertEquals(ChannelNoticeKind.MEMBER_JOINED, notice.kind) + assertEquals("group:111111", notice.chat.chat) + assertEquals("2222", notice.target?.address?.user) + } + } + } finally { + peer.stop() + } + } + + private suspend fun waiting(block: () -> T): T = withContext(Dispatchers.IO) { block() } + + private fun assertNotNull(value: T?, message: String): T = kotlin.test.assertNotNull(value, message) + + /** + * A group that the config does not declare, so that this also fixes the decision that a notice is published for a + * chat the instance does not serve: a chat that is not served yet is where somebody asks to join. + */ + private fun increase(): String = """ + { + "time": 1515204254, + "self_id": 10001000, + "post_type": "notice", + "notice_type": "group_increase", + "sub_type": "approve", + "group_id": 111111, + "user_id": 2222, + "operator_id": 3333 + } + """.trimIndent() + + 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() + + /** A subscriber that keeps every notice it is handed. */ + private class RecordingObserver : ObserverHook { + override val point: ObserverPoint = ChannelPoints.NOTICE + + private val seen = CopyOnWriteArrayList() + + override suspend fun observe(payload: ChannelNotice) { + seen += payload + } + + /** Waits for the first notice, and returns it, null when none came. */ + fun awaitOne(): ChannelNotice? { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + seen.firstOrNull()?.let { return it } + Thread.sleep(20) + } + return null + } + } + + /** An implementation that serves the API and reports what the test says. */ + private class NoticePeer : WebSocketServer(InetSocketAddress("127.0.0.1", 0)) { + private val started = CountDownLatch(1) + private val connected = CountDownLatch(1) + + @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) { + 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 + + fun startAndPort(): Int { + start() + check(started.await(5, TimeUnit.SECONDS)) { "the peer did not start" } + return port + } + + fun report(json: String) { + val connection = socket ?: error("nothing is connected") + connection.send(json) + } + + fun awaitConnection(): Boolean = connected.await(5, TimeUnit.SECONDS) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotQuoteIntegrationTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotQuoteIntegrationTest.kt new file mode 100644 index 00000000..ade09855 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotQuoteIntegrationTest.kt @@ -0,0 +1,258 @@ +package org.foedusprogramme.alexandrite.channel.onebot + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import org.foedusprogramme.alexandrite.sdk.channel.ChannelDirectory +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.Quote +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.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What a message that quotes another one carries. + * + * A report names the message it answers and says nothing about it, so the channel asks the implementation for it. What + * a card says has to survive that ask, or the agent is told that something was quoted and not what. + */ +class OneBotQuoteIntegrationTest { + private val instance = ChannelInstanceId(ChannelType("onebot"), "main") + + private val directory = key() + + @Test + fun `a message that quotes a card carries what the card says`() { + val peer = QuotePeer(card) + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + harness(peer, port, submitter) { + peer.report(quoting("77")) + val message = assertIs(waiting { awaitSubmission(submitter) }).message + val quote = assertIs(message.quote) + assertEquals("someone", quote.senderName) + assertEquals("[music: Supermarket - 许嵩] https://y.qq.com/x", quote.text) + assertEquals("77", quote.target?.id) + assertEquals(false, quote.senderIsBot) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a message that quotes one the implementation cannot report carries its id and no text`() { + val peer = QuotePeer(card, answers = false) + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + harness(peer, port, submitter) { + peer.report(quoting("77")) + val message = assertIs(waiting { awaitSubmission(submitter) }).message + val quote = assertIs(message.quote) + assertEquals("", quote.text) + assertNull(quote.senderName) + assertEquals("77", quote.target?.id) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a message that quotes one the channel read asks the implementation nothing`() { + // The peer refuses to report a message at all, so a quote that carries anything can only have been kept. + val peer = QuotePeer(card, answers = false) + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + harness(peer, port, submitter) { + peer.report(cardAsMessage) + waiting { peer.awaitSilence() } + peer.report(quoting("77")) + val asked = assertIs(waiting { awaitSubmission(submitter) }).message + val quote = assertIs(asked.quote) + assertEquals("[music: Supermarket - 许嵩] https://y.qq.com/x", quote.text) + assertEquals("someone", quote.senderName) + assertEquals(0, peer.reads.get(), "the implementation was asked about a message already read") + } + } + } finally { + peer.stop() + } + } + + private suspend fun harness( + peer: QuotePeer, + port: Int, + submitter: RecordingTurnSubmitter, + block: suspend Running.() -> Unit, + ) { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(waiting { peer.awaitConnection() }, "the channel did not connect to the implementation") + block() + } + } + + /** Waits for the agent to be asked for a turn, and returns what it was asked. */ + private fun awaitSubmission(submitter: RecordingTurnSubmitter): Submission { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + submitter.submissions.firstOrNull()?.let { return it } + Thread.sleep(20) + } + error("the agent was asked for no turn") + } + + /** Waits for a block that blocks its thread, without holding the one the test runs on. */ + private suspend fun waiting(block: () -> T): T = withContext(Dispatchers.IO) { block() } + + /** A group report that answers the message [replyTo] and names the bot. */ + private fun quoting(replyTo: String): String = """ + { + "time": 1515204254, + "self_id": 10001000, + "post_type": "message", + "message_type": "group", + "sub_type": "normal", + "message_id": 12, + "group_id": 987654, + "user_id": 12345678, + "message": [ + {"type": "reply", "data": {"id": "$replyTo"}}, + {"type": "at", "data": {"qq": 10001000}}, + {"type": "text", "data": {"text": "这是什么"}} + ], + "raw_message": "这是什么", + "sender": {"user_id": 12345678, "nickname": "someone", "role": "member"} + } + """.trimIndent() + + 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, + "allowedChats": ["group:987654", "private:12345678"] + } + } + } + """.trimIndent() + + /** The card, as a message the implementation reports. */ + private val cardAsMessage: String = """ + { + "time": 1515204254, + "self_id": 10001000, + "post_type": "message", + "message_type": "group", + "sub_type": "normal", + "message_id": 77, + "group_id": 987654, + "user_id": 2222, + "message": [{"type": "music", "data": {"type": "qq", "id": "1", "title": "Supermarket", "content": "许嵩", "url": "https://y.qq.com/x"}}], + "raw_message": "[音乐]", + "sender": {"user_id": 2222, "nickname": "someone", "role": "member"} + } + """.trimIndent() + + /** The message a card is reported as. */ + private val card: String = """ + { + "message_id": 77, + "sender": {"user_id": 2222, "nickname": "someone"}, + "message": [ + {"type": "music", "data": { + "type": "qq", "id": "1", "title": "Supermarket", + "content": "许嵩", "url": "https://y.qq.com/x" + }} + ] + } + """.trimIndent() + + /** An implementation that answers `get_msg` with [card], or refuses to answer for it. */ + private class QuotePeer(private val card: String, private val answers: Boolean = true) : + WebSocketServer(InetSocketAddress("127.0.0.1", 0)) { + private val started = CountDownLatch(1) + private val connected = CountDownLatch(1) + val reads = java.util.concurrent.atomic.AtomicInteger() + + @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) { + val echo = message.substringAfter("\"echo\":\"", "").substringBefore('"') + if (echo.isEmpty()) return + val reading = message.contains("\"get_msg\"") + if (reading) reads.incrementAndGet() + val answer = when { + reading && !answers -> """{"status":"failed","retcode":100,"data":null,"echo":"$echo"}""" + reading -> """{"status":"ok","retcode":0,"data":$card,"echo":"$echo"}""" + else -> """{"status":"ok","retcode":0,"data":{"message_id":7},"echo":"$echo"}""" + } + connection.send(answer) + } + + override fun onClose(connection: WebSocket, code: Int, reason: String, remote: Boolean) { + if (socket === connection) socket = null + } + + override fun onError(connection: WebSocket?, ex: Exception) = Unit + + fun startAndPort(): Int { + start() + check(started.await(5, TimeUnit.SECONDS)) { "the peer did not start" } + return port + } + + fun report(json: String) { + val connection = socket ?: error("nothing is connected") + connection.send(json) + } + + fun awaitConnection(): Boolean = connected.await(5, TimeUnit.SECONDS) + + /** Waits long enough for the channel to have read what was reported, and returns. */ + fun awaitSilence(millis: Long = 400) = Thread.sleep(millis) + } +} 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 00000000..22f5c248 --- /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/OneBotActivitiesTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotActivitiesTest.kt new file mode 100644 index 00000000..34ad0343 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotActivitiesTest.kt @@ -0,0 +1,50 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +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.ToolCallId +import org.foedusprogramme.alexandrite.sdk.tool.ToolActivity +import kotlin.test.Test +import kotlin.test.assertNotSame +import kotlin.test.assertSame + +/** + * What waits to be told is kept by the chat, so every turn working in one chat tells it once. + * + * A run moved to the background opens a reply of its own for the same chat, and the runtime hands each reply only its + * own turn's activity: a chat would hear one stretch of work once per reply without this. + */ +class OneBotActivitiesTest { + private val activities = OneBotActivities() + + private fun chat(group: String): ChatAddress = + ChatAddress(ChannelInstanceId(ChannelType("onebot"), "main"), "group:$group") + + @Test + fun `turns of one chat share what waits to be told`() { + assertSame(activities.of(chat("1")), activities.of(chat("1"))) + } + + @Test + fun `another chat waits on its own`() { + assertNotSame(activities.of(chat("1")), activities.of(chat("2"))) + } + + @Test + fun `a chat that holds nothing is forgotten`() { + val first = activities.of(chat("1")) + activities.forget(chat("1")) + + assertNotSame(first, activities.of(chat("1")), "a chat that holds nothing was kept") + } + + @Test + fun `a chat a call is still running in is kept`() { + val first = activities.of(chat("1")) + first.started(ToolCallId("c1"), ToolActivity.READ, null) + activities.forget(chat("1")) + + assertSame(first, activities.of(chat("1")), "a chat a call runs in was forgotten") + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt new file mode 100644 index 00000000..204c72ac --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt @@ -0,0 +1,146 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.onebot.i18n.TextKeys +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 org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.chat.UserAddress +import org.foedusprogramme.alexandrite.sdk.turn.CommandContext +import org.foedusprogramme.alexandrite.sdk.turn.CommandHandler +import org.foedusprogramme.alexandrite.sdk.turn.CommandInvocation +import org.foedusprogramme.alexandrite.sdk.turn.CommandSpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Who may run which command, which nothing but this channel's intake asks. */ +class OneBotCommandRulesTest { + private val instance = ChannelInstanceId(ChannelType("onebot"), "main") + + private val rules = OneBotCommandRules(listOf(handler()), control = null) + + @Test + fun `an admin of the instance runs anything`() { + for (name in CLAIMED) { + assertNull(refusal(name, arguments = "x", direct = false, admin = true), name) + assertNull(refusal(name, arguments = "", direct = true, admin = true), name) + } + assertNull(refusal("trust", arguments = "x", direct = false, admin = true)) + } + + @Test + fun `a member runs settings anywhere`() { + assertNull(refusal("settings", direct = false)) + assertNull(refusal("settings", direct = true)) + } + + @Test + fun `a member runs model bare and is refused it with arguments`() { + assertNull(refusal("model", arguments = "", direct = false)) + assertEquals(TextKeys.REFUSED_ADMIN, refusal("model", arguments = "deepseek/one", direct = false)) + assertEquals(TextKeys.REFUSED_ADMIN, refusal("model", arguments = "deepseek/one", direct = true)) + } + + @Test + fun `a member runs agent bare and is refused it with arguments in a group`() { + assertNull(refusal("agent", arguments = "", direct = false)) + assertEquals(TextKeys.REFUSED_ADMIN_HERE, refusal("agent", arguments = "other", direct = false)) + assertNull(refusal("agent", arguments = "other", direct = true)) + } + + @Test + fun `a member runs context and profiles anywhere`() { + assertNull(refusal("context", direct = false)) + assertNull(refusal("profiles", direct = true)) + } + + @Test + fun `a member runs jobs bare and is refused it with arguments`() { + assertNull(refusal("jobs", direct = false)) + assertEquals(TextKeys.REFUSED_ADMIN, refusal("jobs", arguments = "x", direct = false)) + } + + @Test + fun `a member runs compact in a direct chat linked with no other`() { + assertNull(refusal("compact", direct = true)) + assertEquals(TextKeys.REFUSED_ADMIN_HERE, refusal("compact", direct = false)) + } + + @Test + fun `a member runs cancel only in a direct chat`() { + assertNull(refusal("cancel", direct = true)) + assertEquals(TextKeys.REFUSED_ADMIN_HERE, refusal("cancel", direct = false)) + } + + @Test + fun `a member runs new in a direct chat linked with no other`() { + assertNull(refusal("new", direct = true)) + assertEquals(TextKeys.REFUSED_ADMIN_HERE, refusal("new", direct = false)) + } + + @Test + fun `a member runs a command the matrix does not list only as an admin`() { + assertEquals(TextKeys.REFUSED_ADMIN, refusal("trust", arguments = "", direct = true)) + assertEquals(TextKeys.REFUSED_ADMIN, refusal("trust", arguments = "", direct = false)) + } + + @Test + fun `a command the runtime does not claim is none of this channel's business`() { + assertNull(refusal("approve", arguments = "", direct = false)) + } + + @Test + fun `mayRun answers the matrix for a member`() { + val direct = ChatAddress(instance, "private:12345678") + val group = ChatAddress(instance, "group:987654") + assertTrue(rules.mayRun(direct, admin = false, "model", withArguments = false)) + assertFalse(rules.mayRun(direct, admin = false, "model", withArguments = true)) + assertTrue(rules.mayRun(group, admin = false, "help", withArguments = false)) + assertFalse(rules.mayRun(group, admin = false, "agent", withArguments = true)) + assertTrue(rules.mayRun(group, admin = true, "trust", withArguments = false)) + assertTrue(rules.mayRun(group, admin = false, "not_a_command", withArguments = true)) + } + + /** The key of the refusal of [name] for a member, null when the member may run it. */ + private fun refusal( + name: String, + arguments: String = "", + direct: Boolean = false, + admin: Boolean = false, + ): String? { + val chat = ChatAddress(instance, if (direct) "private:12345678" else "group:987654") + val issuer = ChatUser(UserAddress(instance, "12345678"), "someone", null, isBot = false, isAdmin = admin) + val invocation = CommandInvocation.builder(chat, name, issuer).arguments(arguments).build() + return rules.refusal(invocation, if (direct) ChatKind.DIRECT else ChatKind.GROUP) + } + + /** A handler that claims every command the default ones do, and one the matrix does not list. */ + private fun handler(): CommandHandler = object : CommandHandler { + override val commands: List = + (CLAIMED + "trust").map { CommandSpec.builder(it, "a test command").build() } + + override suspend fun handle(invocation: CommandInvocation, context: CommandContext) = Unit + } + + private companion object { + val CLAIMED = listOf( + "help", + "settings", + "context", + "profiles", + "agent", + "reasoning", + "language", + "model", + "mode", + "jobs", + "new", + "compact", + "cancel", + ) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandsTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandsTest.kt new file mode 100644 index 00000000..a37e4269 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandsTest.kt @@ -0,0 +1,156 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.serialization.json.Json +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEventCodec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The commands a report carries, and the messages the bot is addressed by. */ +class OneBotCommandsTest { + private val botId = "10001000" + + @Test + fun `a command names itself and its arguments after the prefix`() { + val command = assertIs(OneBotCommands.of(group("/model deepseek/one"))) + assertEquals("model", command.name) + assertEquals("deepseek/one", command.arguments) + } + + @Test + fun `a command without arguments carries an empty ones`() { + val command = assertIs(OneBotCommands.of(group("/new"))) + assertEquals("new", command.name) + assertEquals("", command.arguments) + } + + @Test + fun `a mention before a command leaves the command`() { + val command = assertIs( + OneBotCommands.of(group("/model", at = botId)), + ) + assertEquals("model", command.name) + } + + @Test + fun `a mention after a command leaves the command`() { + val command = assertIs( + OneBotCommands.of(groupWith("/model", "at")), + ) + assertEquals("model", command.name) + assertEquals("", command.arguments) + } + + @Test + fun `a bare prefix names no command`() { + assertNull(OneBotCommands.of(group("/"))) + } + + @Test + fun `a token that is no command name names no command`() { + assertNull(OneBotCommands.of(group("/随便说点什么")), "a name is written as the other platform writes one") + assertNull(OneBotCommands.of(group("/model-two"))) + assertNull(OneBotCommands.of(group("/模型"))) + } + + @Test + fun `a command may name the target it belongs to`() { + val command = assertIs(OneBotCommands.of(group("/model@10001000 deepseek/one"))) + assertEquals("model", command.name) + assertEquals("deepseek/one", command.arguments) + } + + @Test + fun `text without a prefix names no command`() { + assertNull(OneBotCommands.of(group("model"))) + } + + @Test + fun `a prefix that does not start the message names no command`() { + assertNull(OneBotCommands.of(group("say /model"))) + } + + @Test + fun `a direct message is the bot's`() { + assertTrue(addressed(private("hello"), direct = true)) + } + + @Test + fun `a group message that names nobody is not the bot's`() { + assertFalse(addressed(group("hello"), direct = false)) + } + + @Test + fun `a group message that names the bot is the bot's`() { + assertTrue(addressed(group("hello", at = botId), direct = false)) + } + + @Test + fun `a group message that names everyone is the bot's`() { + assertTrue(addressed(group("hello", at = "all"), direct = false)) + } + + @Test + fun `a group message that names another member is not the bot's`() { + assertFalse(addressed(group("hello", at = "99999999"), direct = false)) + } + + @Test + fun `a group message answering the bot is the bot's`() { + assertTrue(addressed(group("hello", reply = "42"), direct = false) { it == "42" }) + } + + @Test + fun `a group message answering another message is not the bot's`() { + assertFalse(addressed(group("hello", reply = "42"), direct = false) { it == "77" }) + } + + /** Whether a report of [message] is the bot's, answering what [sent] holds. */ + private fun addressed( + event: OneBotEvent.Message, + direct: Boolean, + sent: (String) -> Boolean = { false }, + ): Boolean = OneBotCommands.addressed( + event = event, + isDirect = direct, + namesTheBot = { selfId -> + event.message.segments.any { + it is org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment.At && + (it.isAll || it.userId == selfId) + } + }, + answersTheBot = sent, + ) + + /** A group report whose message is [text] and one segment of [at], or of [reply], before it. */ + private fun group(text: String, at: String? = null, reply: String? = null): OneBotEvent.Message = + decode(groupJson(text, at, reply)) + + private fun groupWith(text: String, at: String) = decode(groupJson(text, at, null)) + + private fun groupJson(text: String, at: String?, reply: String?): String = buildString { + append("""{"time":1515204254,"self_id":$botId,"post_type":"message","message_type":"group",""") + append(""""sub_type":"normal","message_id":12,"group_id":987654,"user_id":12345678,"message":[""") + at?.let { append("""{"type":"at","data":{"qq":${if (it == "all") "\"all\"" else it}}},""") } + reply?.let { append("""{"type":"reply","data":{"id":$it}},""") } + append("""{"type":"text","data":{"text":"$text"}}],""") + append(""""raw_message":"$text",""") + append(""""sender":{"user_id":12345678,"nickname":"someone","role":"member"}}""") + } + + private fun private(text: String): OneBotEvent.Message = decode( + buildString { + append("""{"time":1515204254,"self_id":$botId,"post_type":"message","message_type":"private",""") + append(""""sub_type":"friend","message_id":12,"user_id":12345678,""") + append(""""message":[{"type":"text","data":{"text":"$text"}}],"raw_message":"$text",""") + append(""""sender":{"user_id":12345678,"nickname":"someone"}}""") + }, + ) + + private fun decode(json: String): OneBotEvent.Message = + assertIs(OneBotEventCodec.decode(Json.parseToJsonElement(json))) +} 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 00000000..a79bac53 --- /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/channel/OneBotProgressTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgressTest.kt new file mode 100644 index 00000000..5ca8cba1 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgressTest.kt @@ -0,0 +1,231 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +import org.foedusprogramme.alexandrite.sdk.channel.TurnEventKind +import org.foedusprogramme.alexandrite.sdk.chat.AgentId +import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId +import org.foedusprogramme.alexandrite.sdk.tool.ToolActivity +import org.foedusprogramme.alexandrite.testkit.testTexts +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What a chat is told of a turn's work while it works. + * + * The chat of a platform whose messages cannot be rewritten is told in messages of its own, so a step is not worth a + * message on its own: the first one is told as it starts, and the ones after it are counted as they end and told + * together once the interval has passed. What the model says is told once a segment of it is complete. + */ +class OneBotProgressTest { + private var at = 0L + private val progress = OneBotProgress(intervalMillis = 8_000) { at } + private val texts = testTexts(PLUGIN) + + /** What the chat is told as the call [id] starts, or null when it is told nothing. */ + private fun started( + id: String, + activity: ToolActivity, + agent: AgentId? = null, + title: String = "", + tool: String = "", + ): String? = progress.started(ToolCallId(id), activity, agent, tool = tool, title = title)?.let { + startedMessage(it, texts, null) + } + + /** What the chat is told of what ended, or null when nothing is told. */ + private fun told(): String? = progress.take()?.let { summaryMessage(it, texts, null) } + + @Test + fun `the first step of a turn is told as it starts`() { + assertEquals("Reading 1 file", started("c1", ToolActivity.READ)) + } + + @Test + fun `a step after the first is not told as it starts`() { + started("c1", ToolActivity.READ) + + assertNull(started("c2", ToolActivity.SEARCH), "only the first step of a turn is told as it starts") + } + + @Test + fun `steps inside the interval wait, and are told together once it passes`() { + started("c1", ToolActivity.READ) + progress.ended(ToolCallId("c1")) + started("c2", ToolActivity.LIST) + progress.ended(ToolCallId("c2")) + + assertNull(told(), "a step inside the interval is not worth a message") + + at += 8_000 + assertEquals("Read 1 file · Listed 1 directory", told()) + } + + @Test + fun `nothing waiting says nothing`() { + assertNull(told(), "a turn that ended nothing has nothing to say") + } + + @Test + fun `a call that has not ended is not counted`() { + started("c1", ToolActivity.READ) + + assertNull(told(), "a call still running is not told as one that ended") + } + + @Test + fun `only the calls a summary cannot say get a line of their own`() { + started("c1", ToolActivity.READ, title = "notes.md") + started("c2", ToolActivity.READ, AgentId("code-prober"), title = "README.md") + started("c3", ToolActivity.LIST, title = "src") + progress.ended(ToolCallId("c1"), org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus.FAILED) + progress.ended(ToolCallId("c2"), org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus.FAILED) + progress.ended(ToolCallId("c3")) + at += 8_000 + + assertEquals( + "Read 1 file · Listed 1 directory / code-prober · Read 1 file\n" + + "notes.md — failed\ncode-prober · README.md — failed", + told(), + ) + } + + @Test + fun `the work of each agent stands apart from the work of another`() { + started("c1", ToolActivity.READ, title = "notes.md") + started("c2", ToolActivity.READ, AgentId("code-prober"), title = "README.md") + started("c3", ToolActivity.LIST, AgentId("code-prober"), title = "src") + started("c4", ToolActivity.READ, AgentId("risk-auditor"), title = "secrets.md") + listOf("c1", "c2", "c3", "c4").forEach { progress.ended(ToolCallId(it)) } + at += 8_000 + + assertEquals( + "Read 1 file / code-prober · Read 1 file · Listed 1 directory / risk-auditor · Read 1 file", + told(), + ) + } + + @Test + fun `a call whose kind is unknown is told as work with tools`() { + started("c1", ToolActivity.of("frobnicate")) + progress.ended(ToolCallId("c1")) + at += 8_000 + + assertEquals("Used 1 tool", told()) + } + + @Test + fun `a call that sends a file is told as a file rather than as a tool`() { + assertEquals("Sending 1 file", started("c1", ToolActivity.SEND, title = "report.pdf", tool = "chat.send_file")) + + started("c2", ToolActivity.SEND, title = "chart.png", tool = "chat.send_file") + progress.ended(ToolCallId("c2")) + at += 8_000 + + assertEquals("Sent 1 file", told()) + } + + @Test + fun `a call that ended is told by the interval, without a turn ending it`() { + started("c1", ToolActivity.READ) + progress.ended(ToolCallId("c1")) + + assertNull(told(), "the interval has not passed yet") + + at += 8_000 + + assertEquals("Read 1 file", told()) + } + + @Test + fun `each kind of progress has its own switch`() { + val both = OneBotMessages.capabilities(proactive = true) + assertTrue(both.streaming) + assertEquals(setOf(TurnEventKind.TOOL_CALL_STARTED, TurnEventKind.TOOL_CALL_UPDATED), both.events) + + val actions = OneBotMessages.capabilities(proactive = true, streaming = false) + assertFalse(actions.streaming, "the words of the model are off") + assertEquals(setOf(TurnEventKind.TOOL_CALL_STARTED, TurnEventKind.TOOL_CALL_UPDATED), actions.events) + + val words = OneBotMessages.capabilities(proactive = true, progress = false) + assertTrue(words.streaming) + assertEquals(emptySet(), words.events, "the steps are off") + } + + @Test + fun `a call moved to the background is counted as one that ended`() { + started("c1", ToolActivity.READ, title = "notes.md", tool = "fs.read") + progress.ended(ToolCallId("c1"), org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus.BACKGROUNDED) + at += 8_000 + + assertEquals("Read 1 file\nMoved to the background: fs.read notes.md", told()) + } + + @Test + fun `a message past its limit keeps the newest lines and counts the rest`() { + val title = "x".repeat(200) + for (index in 1..25) { + started("c$index", ToolActivity.READ, title = title) + progress.ended(ToolCallId("c$index"), org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus.FAILED) + } + at += 8_000 + + val message = checkNotNull(told()) { "a message was expected" } + + assertTrue(message.startsWith("Read 25 files\n"), "the summary is kept: $message") + assertTrue(message.contains("earlier calls not shown"), "what was left out is counted: $message") + assertTrue(message.length <= MAX_ACTIVITY_CHARS, "the message stays within its limit: ${message.length}") + } + + @Test + fun `a delegation that moved to the background is told once, in the summary`() { + started("c1", ToolActivity.OTHER, title = "risk-auditor", tool = "agent.delegate") + progress.ended( + ToolCallId("c1"), + org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus.BACKGROUNDED, + ) + at += 8_000 + + assertEquals("Delegated to risk-auditor", told()) + } + + @Test + fun `a conversation that keeps working is told less often`() { + started("c1", ToolActivity.READ) + progress.ended(ToolCallId("c1")) + at += 8_000 + assertEquals("Read 1 file", told()) + + started("c2", ToolActivity.READ) + progress.ended(ToolCallId("c2")) + at += 8_000 + assertNull(told(), "the window after a message is longer than the first") + + at += 8_000 + assertEquals("Read 1 file", told()) + } + + @Test + fun `a conversation that fell quiet starts over`() { + started("c1", ToolActivity.READ) + progress.ended(ToolCallId("c1")) + at += 8_000 + assertEquals("Read 1 file", told()) + + started("c2", ToolActivity.READ) + progress.ended(ToolCallId("c2")) + at += 60_000 + assertEquals("Read 1 file", told(), "a long silence is told once the wait has passed") + + started("c3", ToolActivity.READ) + progress.ended(ToolCallId("c3")) + at += 8_000 + assertEquals("Read 1 file", told(), "the wait is the first one again after a silence") + } + + private companion object { + const val PLUGIN = "alexandrite-channel-onebot" + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotReplyRegistryTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotReplyRegistryTest.kt new file mode 100644 index 00000000..b12a7abd --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotReplyRegistryTest.kt @@ -0,0 +1,58 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.TurnId +import org.foedusprogramme.alexandrite.sdk.transcript.AssistantEntry +import kotlin.test.Test +import kotlin.test.assertNull +import kotlin.test.assertSame + +/** + * The words of a turn go to the reply of that turn, and to nothing else. + * + * What this keeps out is a response of a turn this instance does not speak for: a delegated turn reaches the same hook + * but never opened a reply here, so nothing is said to the chat about it. + */ +class OneBotReplyRegistryTest { + private val registry = OneBotReplyRegistry() + private val first = TurnId("t-1") + private val other = TurnId("t-2") + + @Test + fun `the reply of a turn is the one that opened it`() { + val sink = Sink() + + registry.open(first, sink) + + assertSame(sink, registry.of(first)) + assertNull(registry.of(other), "a turn that opened no reply here has none, such as a delegated one") + } + + @Test + fun `a reply that closed is not found again`() { + val sink = Sink() + registry.open(first, sink) + + registry.close(first) + + assertNull(registry.of(first)) + } + + @Test + fun `a reply opened later is the one a turn is told through`() { + val earlier = Sink() + val later = Sink() + registry.open(first, earlier) + + registry.open(first, later) + + assertSame(later, registry.of(first)) + } + + private class Sink : OneBotWords { + val shown = ArrayList() + + override suspend fun showResponse(message: AssistantEntry) { + shown += message + } + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotWordsHookTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotWordsHookTest.kt new file mode 100644 index 00000000..5ef99f0c --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotWordsHookTest.kt @@ -0,0 +1,99 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.runBlocking +import org.foedusprogramme.alexandrite.sdk.InternalAlexandriteApi +import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId +import org.foedusprogramme.alexandrite.sdk.chat.TurnId +import org.foedusprogramme.alexandrite.sdk.hook.HookDecision +import org.foedusprogramme.alexandrite.sdk.model.FinishKind +import org.foedusprogramme.alexandrite.sdk.model.FinishReason +import org.foedusprogramme.alexandrite.sdk.model.ModelEvent +import org.foedusprogramme.alexandrite.sdk.model.Usage +import org.foedusprogramme.alexandrite.sdk.transcript.AssistantEntry +import org.foedusprogramme.alexandrite.sdk.transcript.TextPart +import org.foedusprogramme.alexandrite.sdk.transcript.ToolCallPart +import org.foedusprogramme.alexandrite.sdk.turn.ModelReply +import org.foedusprogramme.alexandrite.testkit.TEST_MODEL +import org.foedusprogramme.alexandrite.testkit.testTurn +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The words of a response reach the reply of the turn that asked for it, and nothing else. + * + * This is the half [OneBotReplyRegistryTest] cannot show: that a response is handed over as a whole, once, at the + * moment the model finished saying it, which is the point the runtime fires as the request ends. + */ +@OptIn(InternalAlexandriteApi::class) +class OneBotWordsHookTest { + private val replies = OneBotReplyRegistry() + private val hook = OneBotWordsHook(replies) + private val turn = testTurn() + + @Test + fun `a response goes whole to the reply of its own turn`() = runBlocking { + val sink = Sink() + replies.open(turn.id, sink) + + hook.intercept(ModelReply(turn, 0, response("Let me check."))) + + assertEquals(listOf("Let me check."), sink.shown) + } + + @Test + fun `the response of a turn this instance opened no reply for goes nowhere`() = runBlocking { + val sink = Sink() + replies.open(TurnId("t-another"), sink) + + hook.intercept(ModelReply(turn, 0, response("Nobody asked."))) + + assertEquals(emptyList(), sink.shown, "a delegated turn reaches this point too, and speaks to no chat") + } + + @Test + fun `a response after the reply closed goes nowhere`() = runBlocking { + val sink = Sink() + replies.open(turn.id, sink) + replies.close(turn.id) + + hook.intercept(ModelReply(turn, 0, response("Too late."))) + + assertEquals(emptyList(), sink.shown) + } + + @Test + fun `the chain goes on, since this hook only watches`() = runBlocking { + val payload = ModelReply(turn, 0, response("Hi.")) + + assertEquals(HookDecision.Continue, hook.intercept(payload)) + } + + @Test + fun `a response that calls no tool is not told, since the answer carries it`() = runBlocking { + val sink = Sink() + replies.open(turn.id, sink) + + hook.intercept(ModelReply(turn, 0, response("Done.", callsTool = false))) + + assertEquals(emptyList(), sink.shown, "the message that ends the turn says it, and it is not said twice") + } + + private fun response(text: String, callsTool: Boolean = true): ModelEvent.Completed = ModelEvent.Completed( + AssistantEntry( + null, + listOf(TextPart(text)) + + if (callsTool) listOf(ToolCallPart(ToolCallId("c1"), "fs.read", "{}")) else emptyList(), + TEST_MODEL, + ), + FinishReason(FinishKind.END_TURN, null, null), + Usage.builder().build(), + ) + + private class Sink : OneBotWords { + val shown = ArrayList() + + override suspend fun showResponse(message: AssistantEntry) { + shown += message.parts.filterIsInstance().joinToString("\n\n") { it.text } + } + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/i18n/TextKeysTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/i18n/TextKeysTest.kt new file mode 100644 index 00000000..c524a189 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/i18n/TextKeysTest.kt @@ -0,0 +1,34 @@ +package org.foedusprogramme.alexandrite.channel.onebot.i18n + +import org.foedusprogramme.alexandrite.sdk.chat.LanguageTag +import org.foedusprogramme.alexandrite.testkit.assertTextsComplete +import org.foedusprogramme.alexandrite.testkit.testTexts +import java.lang.reflect.Modifier +import java.util.Properties +import kotlin.test.Test +import kotlin.test.assertEquals + +class TextKeysTest { + private val keys = TextKeys::class.java.declaredFields + .filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java } + .map { it.get(null) as String } + + @Test + fun `the channel ships its texts in English`() { + assertEquals(listOf(LanguageTag("en")), assertTextsComplete(PLUGIN)) + } + + @Test + fun `every key the channel uses has an English text, and every English text is used`() { + val english = Properties() + javaClass.getResourceAsStream("/alexandrite/i18n/$PLUGIN/en.properties")!!.use { english.load(it) } + val texts = testTexts(PLUGIN) + + assertEquals(english.stringPropertyNames(), keys.toSet()) + assertEquals(emptyList(), keys.filter { texts.text(it, null) == it }) + } + + private companion object { + const val PLUGIN = "alexandrite-channel-onebot" + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdownTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdownTest.kt new file mode 100644 index 00000000..41d16b57 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdownTest.kt @@ -0,0 +1,91 @@ +package org.foedusprogramme.alexandrite.channel.onebot.mapping + +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * What a chat of this platform reads of a message a model wrote as Markdown. + * + * A message of this platform shows no formatting, so the markers of it would arrive as the characters they are written + * with. What they wrap is kept as the text a chat reads instead. + */ +class OneBotMarkdownTest { + @Test + fun `a heading keeps its title without its marker`() { + assertEquals("Title", text("# Title")) + assertEquals("Title", text("## Title ##")) + } + + @Test + fun `emphasis and strikethrough keep their words without their markers`() { + assertEquals("bold and italic and struck", text("**bold** and *italic* and ~~struck~~")) + assertEquals("bold and italic", text("__bold__ and _italic_")) + assertEquals("snake_case stays as written", text("snake_case stays as written")) + } + + @Test + fun `a code span keeps what it holds without its backticks`() { + assertEquals("x = **1**", text("`x = **1**`")) + } + + @Test + fun `a code block keeps its lines without its fence`() { + assertEquals("val x = 1\nprintln(x)", text("```kotlin\nval x = 1\nprintln(x)\n```")) + assertEquals("plain", text("~~~\nplain\n~~~")) + } + + @Test + fun `a link keeps its text and its address`() { + assertEquals("see the docs (https://example.com/x)", text("see [the docs](https://example.com/x)")) + assertEquals("https://a.b/c", text("[https://a.b/c](https://a.b/c)")) + assertEquals("the docs (https://example.com/x)", text("""[the docs](https://example.com/x "The docs")""")) + } + + @Test + fun `an image becomes an image segment`() { + val segments = OneBotMarkdown.segments("before ![a picture](https://x/1.jpg) after") + + assertEquals( + listOf( + OneBotSegment.Text("before "), + OneBotSegment.Image(file = "https://x/1.jpg"), + OneBotSegment.Text(" after"), + ), + segments, + ) + } + + @Test + fun `a list keeps its marker and loses the markers inside its item`() { + assertEquals("- done\n* first\n1. second", text("- **done**\n* *first*\n1. **second**")) + } + + @Test + fun `a quote loses its marker`() { + assertEquals("quoted line", text("> **quoted** line")) + } + + @Test + fun `a table keeps its cells without the row that lines them up`() { + assertEquals("a | b\n1 | 2", text("| a | b |\n|---|---|\n| 1 | 2 |")) + } + + @Test + fun `a rule becomes a line a chat reads`() { + assertEquals("———", text("---")) + } + + @Test + fun `an escaped marker is the character it stands for`() { + assertEquals("*not emphasis*", text("""\*not emphasis\*""")) + } + + @Test + fun `a message of plain lines keeps them`() { + assertEquals("hello\n\nworld", text("hello\n\nworld")) + } + + private fun text(markdown: String): String = + OneBotMarkdown.segments(markdown).filterIsInstance().joinToString("") { it.text } +} 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 00000000..5cce31d3 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessagesTest.kt @@ -0,0 +1,284 @@ +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 `markdown leaves as the plain text a chat of this platform reads`() { + val message = OutboundMessage.builder( + "## Result\n\n**done** in `main`, see [docs](https://x/y)", + org.foedusprogramme.alexandrite.sdk.channel.MessageKind.REPLY, + ) + .markup(Markup.MARKDOWN) + .build() + + val segments = OneBotMessages.outgoing(chat(), message).segments + + assertEquals( + listOf(OneBotSegment.Text("Result\n\ndone in main, see docs (https://x/y)")), + segments, + ) + } + + @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(proactive = true) + assertTrue(capabilities.markups.contains(Markup.PLAIN)) + assertTrue(capabilities.markups.contains(Markup.MARKDOWN)) + assertTrue(capabilities.proactive) + assertTrue(!OneBotMessages.capabilities(proactive = false).proactive) + assertTrue(capabilities.streaming) + assertTrue(!OneBotMessages.capabilities(proactive = true, streaming = false).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/mapping/OneBotNoticesTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotNoticesTest.kt new file mode 100644 index 00000000..679bf160 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotNoticesTest.kt @@ -0,0 +1,248 @@ +package org.foedusprogramme.alexandrite.channel.onebot.mapping + +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.event.OneBotEvent +import org.foedusprogramme.alexandrite.sdk.channel.ChannelNoticeKind +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** What a report that is not a message becomes. */ +class OneBotNoticesTest { + private val instance = ChannelInstanceId(ChannelType("onebot"), "main") + private val bot = SelfId("10001000") + private val group = GroupId("987654") + private val member = UserId("2222") + private val other = UserId("3333") + + @Test + fun `a member joining names who joined and who let them in`() { + val notice = notice( + OneBotEvent.Notice.GroupIncrease( + time = 1515204254, + selfId = bot, + groupId = group, + userId = member, + operatorId = other, + subType = OneBotEvent.Notice.APPROVE, + raw = JsonObject(emptyMap()), + ), + ) + assertEquals(ChannelNoticeKind.MEMBER_JOINED, notice.kind) + assertEquals("group:987654", notice.chat.chat) + assertEquals(other.value, notice.actor?.address?.user) + assertEquals(member.value, notice.target?.address?.user) + assertEquals(listOf("how" to "approve"), facts(notice)) + } + + @Test + fun `a member who joined on their own names no actor`() { + val notice = notice( + OneBotEvent.Notice.GroupIncrease(1515204254, bot, group, member, member, null, JsonObject(emptyMap())), + ) + assertNull(notice.actor) + assertEquals(member.value, notice.target?.address?.user) + } + + @Test + fun `a member leaving by a kick names the one who kicked`() { + val notice = notice( + OneBotEvent.Notice.GroupDecrease( + 1515204254, + bot, + group, + member, + other, + OneBotEvent.Notice.KICK, + JsonObject(emptyMap()), + ), + ) + assertEquals(ChannelNoticeKind.MEMBER_LEFT, notice.kind) + assertEquals(other.value, notice.actor?.address?.user) + assertEquals(member.value, notice.target?.address?.user) + } + + @Test + fun `a mute says how long it lasts`() { + val notice = notice( + OneBotEvent.Notice.GroupBan(1515204254, bot, group, member, other, true, 600, null, JsonObject(emptyMap())), + ) + assertEquals(ChannelNoticeKind.MEMBER_BANNED, notice.kind) + assertEquals(listOf("muted" to "yes", "seconds" to "600"), facts(notice)) + } + + @Test + fun `an administrator set says which way it went`() { + val notice = notice( + OneBotEvent.Notice.GroupAdmin(1515204254, bot, group, member, false, JsonObject(emptyMap())), + ) + assertEquals(ChannelNoticeKind.ROLE_CHANGED, notice.kind) + assertEquals(listOf("administrator" to "no"), facts(notice)) + } + + @Test + fun `a recall names the message that was taken back`() { + val notice = notice( + OneBotEvent.Notice.GroupRecall( + 1515204254, + bot, + group, + member, + other, + MessageId("77"), + null, + JsonObject(emptyMap()), + ), + ) + assertEquals(ChannelNoticeKind.MESSAGE_RECALLED, notice.kind) + assertEquals("77", notice.message?.id) + assertEquals(other.value, notice.actor?.address?.user) + assertEquals(member.value, notice.target?.address?.user) + } + + @Test + fun `a friend recalled in a direct chat`() { + val notice = notice( + OneBotEvent.Notice.FriendRecall(1515204254, bot, member, MessageId("88"), null, JsonObject(emptyMap())), + ) + assertEquals(ChannelNoticeKind.MESSAGE_RECALLED, notice.kind) + assertEquals("private:2222", notice.chat.chat) + assertEquals("88", notice.message?.id) + } + + @Test + fun `a new friend is said in the direct chat of that friend`() { + val notice = notice( + OneBotEvent.Notice.FriendAdd(1515204254, bot, member, null, JsonObject(emptyMap())), + ) + assertEquals(ChannelNoticeKind.FRIEND_ADDED, notice.kind) + assertEquals("private:2222", notice.chat.chat) + } + + @Test + fun `a friend request carries what was said and what answers it`() { + val notice = notice( + OneBotEvent.Request.Friend(1515204254, bot, "flag-1", "hello", member, JsonObject(emptyMap())), + ) + assertEquals(ChannelNoticeKind.FRIEND_REQUESTED, notice.kind) + assertEquals("private:2222", notice.chat.chat) + assertEquals(listOf("answer with" to "flag-1", "said" to "hello"), facts(notice)) + } + + @Test + fun `a request to join a group is said in that group`() { + val notice = notice( + OneBotEvent.Request.Group( + 1515204254, + bot, + "flag-2", + "let me in", + member, + group, + OneBotEvent.Request.INVITE, + JsonObject(emptyMap()), + ), + ) + assertEquals(ChannelNoticeKind.JOIN_REQUESTED, notice.kind) + assertEquals("group:987654", notice.chat.chat) + assertEquals(listOf("answer with" to "flag-2", "said" to "let me in", "how" to "invite"), facts(notice)) + } + + @Test + fun `a poke says who was poked`() { + val notice = notice( + OneBotEvent.Notice.Notify( + 1515204254, + bot, + group, + member, + other, + OneBotEvent.Notice.POKE, + null, + JsonObject(emptyMap()), + ), + ) + assertEquals("notice.poke", notice.kind.id) + assertEquals(member.value, notice.actor?.address?.user) + assertEquals(other.value, notice.target?.address?.user) + } + + @Test + fun `an upload says which file and how large`() { + val notice = notice( + OneBotEvent.Notice.GroupUpload( + 1515204254, + bot, + group, + member, + OneBotEvent.Notice.File("f1", "notes.zip", 2048, null), + JsonObject(emptyMap()), + ), + ) + assertEquals("group.file_uploaded", notice.kind.id) + assertEquals(listOf("file" to "notes.zip", "bytes" to "2048"), facts(notice)) + } + + @Test + fun `a notice this version does not model keeps the name the implementation gave it`() { + val notice = notice( + OneBotEvent.Notice.Other( + time = 1515204254, + selfId = bot, + noticeType = "group_msg_emoji_like", + raw = kotlinx.serialization.json.buildJsonObject { + put("group_id", kotlinx.serialization.json.JsonPrimitive(987654)) + put("user_id", kotlinx.serialization.json.JsonPrimitive(2222)) + }, + subType = null, + ), + ) + assertEquals("group_msg_emoji_like", notice.kind.id) + assertEquals("group:987654", notice.chat.chat) + assertEquals(member.value, notice.actor?.address?.user) + } + + @Test + fun `a notice this version does not model and that names no chat is published as nothing`() { + val notice = OneBotNotices.of( + instance, + OneBotEvent.Notice.Other(1515204254, bot, "something_new", JsonObject(emptyMap()), null), + bot.value, + ) + assertNull(notice) + } + + @Test + fun `a report that is not a notice or a request is published as nothing`() { + val heartbeat = OneBotEvent.Meta.Lifecycle( + time = 1515204254, + selfId = bot, + subType = "connect", + raw = JsonObject(emptyMap()), + ) + assertNull(OneBotNotices.of(instance, heartbeat, bot.value)) + } + + /** The notice a report of [event] becomes, which the test expects one of. */ + private fun notice(event: OneBotEvent): org.foedusprogramme.alexandrite.sdk.channel.ChannelNotice = + kotlin.test.assertNotNull(OneBotNotices.of(instance, event, bot.value), "no notice was published") + + /** The facts of [notice] as pairs of label and value. */ + private fun facts(notice: org.foedusprogramme.alexandrite.sdk.channel.ChannelNotice): List> = + notice.facts.map { it.label to it.value } + + @Test + fun `the notice of a group names the group chat`() { + val notice = notice( + OneBotEvent.Notice.GroupAdmin(1515204254, bot, group, member, true, JsonObject(emptyMap())), + ) + assertTrue(notice.chat.chat.startsWith("group:")) + } +} 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 00000000..47b90e2a --- /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 00000000..e8f6e09d --- /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 00000000..35768e4d --- /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/protocol/message/OneBotFileSegmentTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotFileSegmentTest.kt new file mode 100644 index 00000000..b222b7cf --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotFileSegmentTest.kt @@ -0,0 +1,99 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.message + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The file segment, which the standard does not declare and an implementation may report. + * + * A file reaches a message as a segment of its own where the implementation offers one, so a report carrying it is + * read rather than kept as something unknown, and what the channel was told of it is written back the way it came. + */ +class OneBotFileSegmentTest { + private val file = OneBotSegment.File(file = "a1b2", name = "notes.zip", url = "http://host/notes.zip") + + @Test + fun `a file is written with what it is known by, its name and where it can be read`() { + val written = OneBotSegmentCodec.encodeSegment(file) + assertEquals("file", written.getValue("type").jsonPrimitive.content) + val data = written.getValue("data").jsonObject + assertEquals("a1b2", data.getValue("file").jsonPrimitive.content) + assertEquals("notes.zip", data.getValue("name").jsonPrimitive.content) + assertEquals("http://host/notes.zip", data.getValue("url").jsonPrimitive.content) + } + + @Test + fun `a file that is written is one that is read back`() { + assertEquals(file, OneBotSegmentCodec.decodeSegment(OneBotSegmentCodec.encodeSegment(file))) + } + + @Test + fun `a file that names only what it is known by is read without a name or a place`() { + val bare = OneBotSegmentCodec.decodeSegment( + buildJsonObject { + put("type", JsonPrimitive("file")) + put("data", buildJsonObject { put("file", JsonPrimitive("a1b2")) }) + }, + ) + val read = assertIs(bare) + assertEquals("a1b2", read.file) + assertNull(read.name) + assertNull(read.url) + } + + @Test + fun `what an implementation says of a file that this version does not model is left behind`() { + val read = assertIs( + OneBotSegmentCodec.decodeSegment( + buildJsonObject { + put("type", JsonPrimitive("file")) + put( + "data", + buildJsonObject { + put("file", JsonPrimitive("a1b2")) + put("something_new", JsonPrimitive(1)) + }, + ) + }, + ), + ) + assertEquals("a1b2", read.file) + } + + @Test + fun `a file is spelled as a CQ code of its own`() { + val spelled = CqCode.encode(file) + assertTrue(spelled.startsWith("[CQ:file,"), spelled) + assertTrue(spelled.contains("file=a1b2"), spelled) + assertTrue(spelled.contains("name=notes.zip"), spelled) + } + + @Test + fun `a message that carries a file holds it beside its words`() { + val segments = OneBotSegmentCodec.decodeMessageSegments( + buildJsonArray { + add(OneBotSegmentCodec.encodeSegment(OneBotSegment.Text("here"))) + add(OneBotSegmentCodec.encodeSegment(file)) + }, + ) + assertEquals(listOf("here"), segments.filterIsInstance().map { it.text }) + assertEquals(file, segments.filterIsInstance().single()) + } + + @Test + fun `what is not a file is still unknown`() { + val unknown = OneBotSegmentCodec.decodeSegment( + JsonObject(mapOf("type" to JsonPrimitive("something_else"), "data" to JsonObject(emptyMap()))), + ) + assertIs(unknown) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActDescriptionTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActDescriptionTest.kt new file mode 100644 index 00000000..2df434d4 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActDescriptionTest.kt @@ -0,0 +1,33 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * What the tool that changes something tells a model about a call that ends without an answer. + * + * Such a call may have been carried out: the request can be on its way when the call is ended, and a call ended that + * way is one nobody knows the outcome of. A model that takes it for one that did nothing sends the same thing again, + * which is what a file that reached a group twice came of, so the tool says what to do instead. + */ +class OneBotActDescriptionTest { + private val description: String = OneBotActTool(OneBotApis(), null).definition.description + + @Test + fun `it says that an unanswered call may still have been carried out`() { + assertTrue(description.contains("may still have been carried out"), description.take(240)) + } + + @Test + fun `it says to look at what a chat holds before calling again`() { + assertTrue(description.contains("look at what that chat or group holds"), description.take(240)) + assertTrue(description.contains("before calling again"), description.take(240)) + } + + @Test + fun `the tool that only reads says no such thing, since reading changes nothing`() { + val reading = OneBotReadTool(OneBotApis(), null).definition.description + assertTrue(!reading.contains("may still have been carried out"), reading.take(240)) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionsTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionsTest.kt new file mode 100644 index 00000000..5258594d --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionsTest.kt @@ -0,0 +1,144 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotRegistry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What the table of actions lets the agent be asked for, and what it keeps out of reach. + * + * The table is the only thing standing between a call and the platform, so what it holds is worth asserting rather + * than trusting: an action the table leaves out cannot be named at all, and an action it holds is one the platform + * declares, unless the row says the standard does not have it. + */ +class OneBotActionsTest { + @Test + fun `every action the table holds is one the standard declares`() { + OneBotActions.rows.filterNot { it.extension }.forEach { row -> + val declared = assertNotNull(OneBotRegistry.action(row.name), "${row.name} is no declared action") + assertFalse(declared.hidden, "${row.name} is an action the standard hides") + } + } + + @Test + fun `an extension is an action the standard does not declare`() { + val emoji = assertNotNull(OneBotActions.of("set_msg_emoji_like")) + assertTrue(emoji.extension, "the emoji response is not an action of the standard") + assertNull(OneBotRegistry.action("set_msg_emoji_like"), "the standard declares no such action") + } + + @Test + fun `no action is declared twice`() { + val names = OneBotActions.rows.map { it.name } + assertEquals(names.size, names.toSet().size, names.toString()) + } + + @Test + fun `the table hands out nothing of the account`() { + listOf("get_cookies", "get_csrf_token", "get_credentials").forEach { name -> + assertNull(OneBotActions.of(name), "$name must not be reachable") + } + } + + @Test + fun `the table does not carry out anything by name`() { + listOf("raw", ".handle_quick_operation").forEach { name -> + assertNull(OneBotActions.of(name), "$name must not be reachable") + } + } + + @Test + fun `the table restarts nothing and clears nothing`() { + listOf("set_restart", "clean_cache").forEach { name -> + assertNull(OneBotActions.of(name), "$name must not be reachable") + } + } + + @Test + fun `an action that reads is not one that changes`() { + assertFalse(assertNotNull(OneBotActions.of("get_group_member_info")).changes) + assertFalse(assertNotNull(OneBotActions.of("get_msg")).changes) + assertTrue(assertNotNull(OneBotActions.of("delete_msg")).changes) + assertTrue(assertNotNull(OneBotActions.of("set_group_kick")).changes) + } + + @Test + fun `an action about a message lets the id of the message be left out`() { + listOf("delete_msg", "set_msg_emoji_like", "get_msg").forEach { name -> + val row = assertNotNull(OneBotActions.of(name)) + assertTrue(row.takesAMessage, "$name is about a message") + val parameter = assertNotNull(row.parameters.firstOrNull { it.name == OneBotActions.MESSAGE_ID }) + assertTrue(parameter.optional, "$name must take the id of the message of the turn") + } + } + + @Test + fun `an action that is not about a message has no such argument`() { + val row = assertNotNull(OneBotActions.of("set_group_ban")) + assertFalse(row.takesAMessage) + assertTrue(row.parameters.none { it.name == OneBotActions.MESSAGE_ID }) + } + + @Test + fun `every argument of every action is named and explained`() { + OneBotActions.rows.forEach { row -> + assertTrue(row.name.isNotBlank(), "an action needs a name") + assertTrue(row.said.isNotBlank(), "${row.name} says nothing when it is carried out") + assertTrue(row.parameters.isNotEmpty(), "${row.name} takes nothing") + row.parameters.forEach { parameter -> + assertTrue(parameter.name.isNotBlank(), "${row.name} has an argument with no name") + assertTrue(parameter.description.isNotBlank(), "$parameter of ${row.name} is not explained") + } + } + } + + @Test + fun `what is not declared cannot be asked for`() { + assertNull(OneBotActions.of("send_group_msg")) + assertNull(OneBotActions.of("delete_msg_async")) + assertNull(OneBotActions.of("")) + } + + @Test + fun `uploading a file takes a path of the workspace as the file`() { + listOf("upload_group_file", "upload_private_file").forEach { name -> + val row = assertNotNull(OneBotActions.of(name)) + assertTrue(row.extension, "$name is an action the standard does not declare") + val file = assertNotNull(row.parameters.firstOrNull { it.name == "file" }) + assertEquals( + OneBotActions.Kind.FILE, + file.kind, + "$name takes a file of the workspace rather than a name of one", + ) + } + } + + @Test + fun `reading a file of the platform takes its name rather than a path of the workspace`() { + val row = assertNotNull(OneBotActions.of("get_file")) + assertTrue(row.extension) + val file = assertNotNull(row.parameters.firstOrNull { it.name == "file" }) + assertEquals(OneBotActions.Kind.TEXT, file.kind) + assertTrue(row.parameters.none { it.kind == OneBotActions.Kind.FILE }) + } + + @Test + fun `the actions for files name the arguments the implementation asks for`() { + assertEquals( + listOf("group_id", "file", "name", "folder"), + assertNotNull(OneBotActions.of("upload_group_file")).parameters.map { it.name }, + ) + assertEquals( + listOf("user_id", "file", "name"), + assertNotNull(OneBotActions.of("upload_private_file")).parameters.map { it.name }, + ) + assertEquals( + listOf("group_id", "file_id", "busid"), + assertNotNull(OneBotActions.of("get_group_file_url")).parameters.map { it.name }, + ) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotFileArgumentTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotFileArgumentTest.kt new file mode 100644 index 00000000..13818ce7 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotFileArgumentTest.kt @@ -0,0 +1,180 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.sdk.InternalAlexandriteApi +import org.foedusprogramme.alexandrite.sdk.chat.AgentId +import org.foedusprogramme.alexandrite.sdk.chat.ConversationId +import org.foedusprogramme.alexandrite.sdk.tool.FloorCheck +import org.foedusprogramme.alexandrite.sdk.tool.HardFloor +import org.foedusprogramme.alexandrite.testkit.testToolContext +import java.nio.file.Files +import java.nio.file.Path +import java.util.Base64 +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeBytes +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * What a call that names a file of the workspace does with it. + * + * The platform is usually on another machine, so what such a call carries is the bytes rather than the path, and + * whether they may be read at all is the floor's to say. What is asserted here is that the floor is asked and that + * its answer is acted on, rather than the floor's own policy, which is decided and tested where the floor lives. + */ +class OneBotFileArgumentTest { + private val upload = row("upload_group_file") + + /** What a call of the streaming action answers, which a file small enough to carry never reaches. */ + private val stream: suspend (JsonObject) -> OneBotResult = { OneBotResult.Ok(JsonPrimitive("ok")) } + private val home: Path = createTempDirectory("onebot-file") + + @Test + fun `a file the floor allows is carried as the bytes the platform takes`() = runBlocking { + val file = home.resolve("notes.zip") + file.writeBytes(byteArrayOf(1, 2, 3)) + val context = testToolContext() + val prepared = assertIs(withFiles(upload, given(file), allows(), context, stream)) + val carried = prepared.params.getValue("file").jsonPrimitive.content + assertTrue(carried.startsWith("base64://"), carried) + assertContentEquals(byteArrayOf(1, 2, 3), Base64.getDecoder().decode(carried.removePrefix("base64://"))) + // A file that was read is reported the way the tools for files report one. + assertTrue(context.touchedPaths.contains(file), context.touchedPaths.toString()) + } + + @Test + fun `a file the floor refuses is refused with the reason the floor gave`() = runBlocking { + val file = home.resolve("secret.zip") + file.writeBytes(byteArrayOf(1)) + val refused = + assertIs( + withFiles(upload, given(file), denies("a place the test protects"), testToolContext(), stream), + ) + assertTrue(refused.said.contains("a place the test protects"), refused.said) + assertTrue(refused.said.contains("may not read"), refused.said) + } + + @Test + fun `a file too large to carry in one request goes to the implementation in pieces, which names it`() = + runBlocking { + val file = home.resolve("big.zip") + file.writeBytes(ByteArray((FILE_LIMIT + 1).toInt())) + val calls = mutableListOf() + val prepared = assertIs( + withFiles(upload, given(file), allows(), testToolContext()) { call -> + calls += call + if (call["is_complete"] != null) { + OneBotResult.Ok( + kotlinx.serialization.json.buildJsonObject { + put("file_path", JsonPrimitive("streamed")) + }, + ) + } else { + OneBotResult.Ok(JsonPrimitive("stream")) + } + }, + ) + + assertEquals("streamed", prepared.params.getValue("file").jsonPrimitive.content) + assertTrue(calls.size > 1, "a file this large takes more than one call") + } + + @Test + fun `a file the implementation took no pieces of is refused rather than sent on`() = runBlocking { + val file = home.resolve("big.zip") + file.writeBytes(ByteArray((FILE_LIMIT + 1).toInt())) + + val refused = assertIs( + withFiles(upload, given(file), allows(), testToolContext()) { _ -> OneBotResult.Failed(retcode = 200) }, + ) + + assertTrue(refused.said.contains("took no file in pieces"), refused.said) + } + + @Test + fun `a call that needs a file and a runtime with no floor is refused rather than read on its own`() = runBlocking { + val file = home.resolve("notes.zip") + file.writeBytes(byteArrayOf(1)) + val refused = assertIs(withFiles(upload, given(file), null, testToolContext(), stream)) + assertTrue(refused.said.contains("no file is sent"), refused.said) + } + + @Test + fun `a call that names no file is refused, naming the argument`() = runBlocking { + val given = buildJsonObject { put("group_id", JsonPrimitive("987654")) } + val refused = assertIs(withFiles(upload, given, allows(), testToolContext(), stream)) + assertTrue(refused.said.contains("file"), refused.said) + } + + @Test + fun `a call that needs no file is left as it is, whatever the floor says`() = runBlocking { + val mute = row("set_group_ban") + val given = buildJsonObject { + put("group_id", JsonPrimitive("987654")) + put("user_id", JsonPrimitive("2222")) + put("duration", JsonPrimitive("60")) + } + val prepared = assertIs(withFiles(mute, given, null, testToolContext(), stream)) + assertTrue(prepared.params.containsKey("duration"), prepared.params.toString()) + } + + @Test + fun `a path relative to the workspace is resolved against it, as a file tool resolves one`() = runBlocking { + val file = home.resolve("relative.txt") + file.writeBytes(byteArrayOf(7, 8)) + val context = testToolContext(workspace = home) + + val prepared = assertIs( + withFiles(upload, given(Path.of("relative.txt")), allows(), context, stream), + ) + + val carried = prepared.params.getValue("file").jsonPrimitive.content + assertContentEquals(byteArrayOf(7, 8), Base64.getDecoder().decode(carried.removePrefix("base64://"))) + } + + @Test + fun `a relative path with no workspace is refused rather than resolved against something else`() = runBlocking { + val refused = assertIs( + withFiles(upload, given(Path.of("relative.txt")), allows(), testToolContext(workspace = null), stream), + ) + assertTrue(refused.said.contains("no workspace"), refused.said) + } + + /** What the action was given, which names [file] as a call of a model would. */ + private fun given(file: Path) = buildJsonObject { + put("group_id", JsonPrimitive("987654")) + put("file", JsonPrimitive(file.toString())) + put("name", JsonPrimitive("notes.zip")) + } + + /** A floor that allows every path, which is the answer this test wants. */ + private fun allows(): HardFloor = StubFloor { FloorCheck.Allowed(it) } + + /** A floor that refuses every path, saying [reason]. */ + private fun denies(reason: String): HardFloor = StubFloor { FloorCheck.Denied(reason) } + + /** The row of [name], which the test expects the table to hold. */ + private fun row(name: String): OneBotActions.Row = + checkNotNull(OneBotActions.of(name)) { "the table holds no action $name" } + + /** A floor whose answer is [decision], which stands in for the one the runtime provides. */ + @OptIn(InternalAlexandriteApi::class) + private class StubFloor(private val decision: (Path) -> FloorCheck) : HardFloor { + override fun checkRead(path: Path, agent: AgentId?, conversation: ConversationId?): FloorCheck = decision(path) + + override fun checkWrite(path: Path, agent: AgentId?, conversation: ConversationId?): FloorCheck = + FloorCheck.Denied("a test floor writes none") + + override fun checkTree(path: Path, agent: AgentId?, conversation: ConversationId?): FloorCheck = + FloorCheck.Denied("a test floor walks none") + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotGroupFilesTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotGroupFilesTest.kt new file mode 100644 index 00000000..2e5d23cd --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotGroupFilesTest.kt @@ -0,0 +1,53 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * What a group's files are, and taking one of them away. + * + * A file that was sent to a group can only be checked or taken back by asking what the group holds, which gives the id + * and the part a file is known by, and then naming that file. Both halves are here for that reason: the actions that + * list are of no use without the one that deletes, and the one that deletes cannot be given what it takes without the + * actions that list. + */ +class OneBotGroupFilesTest { + @Test + fun `what a group's files are is read from the group`() { + listOf("get_group_root_files", "get_group_files_by_folder", "get_group_file_system_info").forEach { name -> + val row = assertNotNull(OneBotActions.of(name), "the table holds no $name") + assertTrue(row.extension, "$name is an action the standard does not declare") + assertTrue(!row.changes, "$name only reads") + assertEquals("group_id", row.parameters.first().name, "$name names the group") + assertEquals(OneBotActions.Kind.NUMBER, row.parameters.first().kind) + } + } + + @Test + fun `the folder a group's files are read from is named by the group`() { + val row = assertNotNull(OneBotActions.of("get_group_files_by_folder")) + assertEquals(listOf("group_id", "folder_id"), row.parameters.map { it.name }) + assertTrue(row.parameters.none { it.kind == OneBotActions.Kind.FILE }, "a folder of the group is no file here") + } + + @Test + fun `a file of a group is taken away by what listing the group's files gives`() { + val row = assertNotNull(OneBotActions.of("delete_group_file")) + assertTrue(row.extension, "the standard declares no such action") + assertTrue(row.changes, "taking a file away changes something") + assertEquals(listOf("group_id", "file_id", "busid"), row.parameters.map { it.name }) + assertEquals(OneBotActions.Kind.NUMBER, row.parameters.first { it.name == "group_id" }.kind) + assertEquals(OneBotActions.Kind.NUMBER, row.parameters.first { it.name == "busid" }.kind) + assertEquals(OneBotActions.Kind.TEXT, row.parameters.first { it.name == "file_id" }.kind) + } + + @Test + fun `taking a file away is asked of the tool that changes, and what a group holds of the one that reads`() { + assertTrue(assertNotNull(OneBotActions.of("delete_group_file")).changes) + OneBotActions.rows.filter { it.name.startsWith("get_group_") && it.name != "get_group_file_url" }.forEach { + assertTrue(!it.changes, "${it.name} belongs to the tool that reads") + } + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotOutcomeTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotOutcomeTest.kt new file mode 100644 index 00000000..27c6977b --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotOutcomeTest.kt @@ -0,0 +1,67 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * What the model is told a call answered. + * + * A read is answered with what the platform gave, because that answer is what the call was for: a read answered only + * with the sentence that the action was carried out leaves the model with nothing to read, which is what the call of + * a message that read as "The message was read." amounted to. + */ +class OneBotOutcomeTest { + private val message = buildJsonObject { + put("message_id", 1811467524L) + put("message", "不会") + } + + @Test + fun `a read is answered with what the platform gave`() { + val said = OneBotResult.Ok(message).outcome("The message was read.", answered = true) + assertTrue(said.contains("1811467524"), said) + assertTrue(said.contains("不会"), said) + } + + @Test + fun `a change is answered with what it did rather than with the platform's answer`() { + val said = OneBotResult.Ok(message).outcome("The message was taken back.", answered = false) + assertEquals("The message was taken back.", said) + } + + @Test + fun `an answer longer than a turn should carry stops, and says that it did`() { + val long = OneBotResult.Ok(JsonPrimitive("x".repeat(ANSWER_LIMIT * 2))) + .outcome("The message was read.", answered = true) + assertTrue(long.contains("stops here"), long.takeLast(60)) + assertTrue(long.length < ANSWER_LIMIT * 2, "an answer of ${long.length} characters was not cut") + } + + @Test + fun `a read the platform answered with nothing says so`() { + val said = OneBotResult.Ok(JsonNull).outcome("The message was read.", answered = true) + assertEquals("The implementation answered with nothing.", said) + } + + @Test + fun `an answer that holds no field is still shown as it is`() { + val said = OneBotResult.Ok(kotlinx.serialization.json.JsonObject(emptyMap())) + .outcome("The message was read.", answered = true) + assertEquals("{}", said) + } + + @Test + fun `an answer that did not come says why, whichever kind of action asked`() { + listOf(true, false).forEach { answered -> + val said = OneBotResult.Failed(retcode = 100, message = "no such group").outcome("said", answered) + assertTrue(said.contains("no such group"), said) + assertTrue(said.contains("100"), said) + } + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotPrepareTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotPrepareTest.kt new file mode 100644 index 00000000..25699e68 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotPrepareTest.kt @@ -0,0 +1,133 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What a call to an action sends, and what it is refused for. + * + * This is where a call reaches the platform or does not, so what it forwards is worth asserting: an action sends the + * arguments it declares and nothing else, so nothing a call writes can reach the platform by a name the action does + * not take, and an argument that must be given is refused rather than sent as nothing. + */ +class OneBotPrepareTest { + private val delete = row("delete_msg") + private val ban = row("set_group_ban") + private val mute = mapOf("group_id" to "987654", "user_id" to "2222", "duration" to "600") + + @Test + fun `the arguments a call gives are the ones that are sent`() { + val prepared = assertIs(prepare(ban, params(mute), null)) + assertEquals(JsonPrimitive(987654L), prepared.params["group_id"]) + assertEquals(JsonPrimitive(2222L), prepared.params["user_id"]) + assertEquals(JsonPrimitive(600L), prepared.params["duration"]) + } + + @Test + fun `an action about a message takes the message of the turn when none is given`() { + val prepared = assertIs(prepare(delete, JsonObject(emptyMap()), "1534828408")) + assertEquals(JsonPrimitive(1534828408L), prepared.params["message_id"]) + } + + @Test + fun `an argument given by a call is used rather than the message of the turn`() { + val prepared = assertIs(prepare(delete, params(mapOf("message_id" to "77")), "1534828408")) + assertEquals(JsonPrimitive(77L), prepared.params["message_id"]) + } + + @Test + fun `an action about a message and no message of the turn refuses rather than guesses`() { + val prepared = assertIs(prepare(delete, JsonObject(emptyMap()), null)) + assertTrue(prepared.said.contains("message_id"), prepared.said) + } + + @Test + fun `an argument that must be given and was not is refused, naming it and the action`() { + val prepared = assertIs( + prepare(ban, params(mapOf("group_id" to "987654")), null), + ) + assertTrue(prepared.said.contains("user_id"), prepared.said) + assertTrue(prepared.said.contains("set_group_ban"), prepared.said) + } + + @Test + fun `an argument a call writes under a name the action does not take reaches nothing`() { + val prepared = assertIs( + prepare(ban, params(mute + mapOf("action" to "set_restart", "group_id_extra" to "1")), null), + ) + assertEquals(setOf("group_id", "user_id", "duration"), prepared.params.keys) + assertNull(prepared.params["action"]) + } + + @Test + fun `the message of the turn is not sent to an action that does not take it`() { + val prepared = assertIs(prepare(ban, params(mute), "1534828408")) + assertEquals(setOf("group_id", "user_id", "duration"), prepared.params.keys) + assertNull(prepared.params[OneBotActions.MESSAGE_ID]) + } + + @Test + fun `an argument that may be left out and was not given is not sent at all`() { + val prepared = assertIs( + prepare(row("set_group_kick"), params(mapOf("group_id" to "987654", "user_id" to "2222")), null), + ) + assertEquals(setOf("group_id", "user_id"), prepared.params.keys) + } + + @Test + fun `an argument given as nothing counts as one that was not given`() { + val prepared = assertIs( + prepare(ban, params(mute).withNothing("user_id"), null), + ) + assertTrue(prepared.said.contains("user_id"), prepared.said) + } + + @Test + fun `an argument that asks whether to do something is sent as yes or no, however a call wrote it`() { + listOf("true" to true, "yes" to true, "false" to false, "no" to false).forEach { (written, expected) -> + val prepared = assertIs( + prepare(row("set_friend_add_request"), params(mapOf("flag" to "f", "approve" to written)), null), + ) + assertEquals(JsonPrimitive(expected), prepared.params["approve"], written) + } + } + + @Test + fun `an argument that asks whether to do something is not sent when it was not given`() { + val prepared = assertIs( + prepare(row("set_group_kick"), params(mapOf("group_id" to "987654", "user_id" to "2222")), null), + ) + assertNull(prepared.params["reject_add_request"]) + } + + @Test + fun `an argument that asks whether to do something and cannot be read as one is refused`() { + val prepared = assertIs( + prepare(row("set_friend_add_request"), params(mapOf("flag" to "f", "approve" to "maybe")), null), + ) + assertTrue(prepared.said.contains("approve"), prepared.said) + assertTrue(prepared.said.contains("yes or no"), prepared.said) + } + + /** The row of [name], which the test expects the table to hold. */ + private fun row(name: String): OneBotActions.Row = + checkNotNull(OneBotActions.of(name)) { "the table holds no action $name" } + + /** The arguments a call wrote, as text, which is how a model writes them. */ + private fun params(given: Map): JsonObject = buildJsonObject { + given.forEach { (name, value) -> put(name, JsonPrimitive(value)) } + } + + /** The same arguments, with [name] given as nothing rather than left out. */ + private fun JsonObject.withNothing(name: String): JsonObject = buildJsonObject { + this@withNothing.forEach { (key, value) -> put(key, value) } + put(name, JsonNull) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolDefinitionTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolDefinitionTest.kt new file mode 100644 index 00000000..d8f7e56b --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolDefinitionTest.kt @@ -0,0 +1,28 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes + +/** + * How long the tools of this channel may take, which a file of any size makes long. + * + * A call that changes something may have to send a file of the workspace to the implementation in pieces and then + * send it on. The timeout a turn takes by default ends such a call before it is done, and a call that ends that way + * is one whose outcome nobody knows, which is what a file sent twice came of. + */ +class OneBotToolDefinitionTest { + @Test + fun `a tool that changes something may take as long as a file of the workspace can need`() { + val timeout = assertNotNull(OneBotActTool(OneBotApis(), null).definition.timeout, "no timeout is declared") + assertTrue(timeout >= 5.minutes, "a change may take only $timeout") + } + + @Test + fun `a tool that only reads needs no time of its own`() { + assertNull(OneBotReadTool(OneBotApis(), null).definition.timeout) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUploadTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUploadTest.kt new file mode 100644 index 00000000..be4d965f --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUploadTest.kt @@ -0,0 +1,170 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import java.security.MessageDigest +import java.util.Base64 +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeBytes +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A file handed to an implementation in pieces, which is what a file too large to send in one request needs. + * + * What is asserted here is the protocol an implementation takes: the pieces, each with what it is checked by, the + * answer that ends the sending, and that a file only partly sent is never answered with as if it were whole. + */ +class OneBotUploadTest { + private val home = createTempDirectory("onebot-stream") + private val bytes = ByteArray(STREAM_CHUNK * 2 + 5) { (it % 251).toByte() } + + @Test + fun `a file goes in pieces, each with what the implementation checks it by`() = runBlocking { + val file = home.resolve("notes.bin") + file.writeBytes(bytes) + val calls = mutableListOf() + + val reference = streamFile(file, "notes.bin") { call -> + calls += call + answer(call) + } + + assertEquals("file-path", reference) + assertEquals(4, calls.size, "three pieces and the answer that ends the sending") + val pieces = calls.dropLast(1) + assertEquals(listOf(0, 1, 2), pieces.map { it["chunk_index"]!!.jsonPrimitive.contentOrNull?.toInt() }) + assertEquals(listOf(3, 3, 3), pieces.map { it["total_chunks"]!!.jsonPrimitive.contentOrNull?.toInt() }) + assertEquals(1, calls.map { it["stream_id"]!!.jsonPrimitive.content }.toSet().size, "one stream") + pieces.forEach { call -> + assertEquals(bytes.size.toString(), call["file_size"]!!.jsonPrimitive.content) + assertEquals(digest(bytes), call["expected_sha256"]!!.jsonPrimitive.content) + assertEquals("notes.bin", call["filename"]!!.jsonPrimitive.content) + } + val sent = pieces.map { Base64.getDecoder().decode(it["chunk_data"]!!.jsonPrimitive.content) } + assertContentEquals(bytes, sent.reduce { all, piece -> all + piece }) + assertTrue(sent.dropLast(1).all { it.size == STREAM_CHUNK }, "every piece but the last is a whole one") + } + + @Test + fun `the sending ends with the call that says the file is complete, and carries no piece`() = runBlocking { + val file = home.resolve("small.bin") + file.writeBytes(bytes) + val calls = mutableListOf() + + streamFile(file, "small.bin") { call -> + calls += call + answer(call) + } + + val last = calls.last() + assertEquals("true", last["is_complete"]!!.jsonPrimitive.content) + assertNull(last["chunk_data"]) + assertEquals(calls.first()["stream_id"]!!.jsonPrimitive.content, last["stream_id"]!!.jsonPrimitive.content) + } + + @Test + fun `a file that is only partly sent is not answered with as if it were whole`() = runBlocking { + val file = home.resolve("broken.bin") + file.writeBytes(bytes) + val calls = mutableListOf() + + val reference = streamFile(file, "broken.bin") { call -> + calls += call + if (calls.size == 1) OneBotResult.Failed(retcode = 200) else answer(call) + } + + assertNull(reference) + assertEquals(1, calls.size, "nothing is sent after a piece the implementation refused") + } + + @Test + fun `an answer that names the file under another of its names is read as well`() = runBlocking { + val file = home.resolve("a.bin") + file.writeBytes(bytes) + + val reference = streamFile(file, "a.bin") { call -> + if (call["is_complete"] != null) { + OneBotResult.Ok(buildJsonObject { put("url", "http://host/a") }) + } else { + OneBotResult.Ok(JsonPrimitive("ok")) + } + } + + assertEquals("http://host/a", reference) + } + + @Test + fun `an answer that names no file leaves nothing to send it on by`() = runBlocking { + val file = home.resolve("b.bin") + file.writeBytes(bytes) + + val reference = streamFile(file, "b.bin") { call -> + if (call["is_complete"] != + null + ) { + OneBotResult.Ok(JsonPrimitive("done")) + } else { + OneBotResult.Ok(JsonPrimitive("ok")) + } + } + + assertNull(reference) + } + + @Test + fun `a file that holds nothing is still ended, with no piece sent`() = runBlocking { + val file = home.resolve("empty.bin") + file.writeBytes(ByteArray(0)) + val calls = mutableListOf() + + val reference = streamFile(file, "empty.bin") { call -> + calls += call + answer(call) + } + + assertEquals(1, calls.size) + val only = calls.single() + assertEquals("true", only["is_complete"]!!.jsonPrimitive.content) + assertFalse(only.containsKey("chunk_data")) + assertEquals("file-path", reference) + } + + @Test + fun `a call cancelled while a piece is being sent ends cancelled rather than answered`() = runBlocking { + val file = home.resolve("cancelled.bin") + file.writeBytes(bytes) + + // The file is read on another dispatcher so that reading it does not hold the one the turn runs on, and a + // call ended while it waits is cancelled rather than answered, since nothing is known of what it did. + assertFailsWith { + withTimeout(50L) { streamFile(file, "cancelled.bin") { awaitCancellation() } } + } + } + + /** What an implementation answers to [call], naming the file it was sent when [call] ends the sending. */ + private fun answer(call: JsonObject): OneBotResult = if (call["is_complete"] != null) { + OneBotResult.Ok(buildJsonObject { put("file_path", "file-path") }) + } else { + OneBotResult.Ok(buildJsonObject { put("type", "stream") }) + } + + /** The digest of [bytes], which is what the implementation is told to check what it was sent by. */ + private fun digest(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } +} 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 00000000..e3b0a842 --- /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 00000000..4f297d8d --- /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}" + } +} From f4e87f2df160fcf40f8d94fa386daa6905df5ebf Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 01:31:43 +0800 Subject: [PATCH 09/37] qqbot: Add the official bot protocol and gateway Exchanges the app credentials for a token, keeps it fresh, decodes the open platform payloads, and runs the WebSocket gateway with identify, heartbeat, resume and reconnect. --- libraries/channels/qqbot/build.gradle.kts | 15 + .../alexandrite/channel/qqbot/QqBotPlugin.kt | 7 + .../channel/qqbot/auth/QqBotTokens.kt | 97 +++++ .../channel/qqbot/config/QqBotConfig.kt | 101 +++++ .../channel/qqbot/gateway/QqBotGateway.kt | 356 ++++++++++++++++++ .../channel/qqbot/i18n/TextKeys.kt | 20 + .../alexandrite/channel/qqbot/net/QqBotApi.kt | 105 ++++++ .../channel/qqbot/net/QqBotHttp.kt | 33 ++ .../channel/qqbot/protocol/QqBotPayloads.kt | 211 +++++++++++ 9 files changed, 945 insertions(+) create mode 100644 libraries/channels/qqbot/build.gradle.kts create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotPlugin.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/auth/QqBotTokens.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/config/QqBotConfig.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGateway.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/i18n/TextKeys.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/net/QqBotApi.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/net/QqBotHttp.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/protocol/QqBotPayloads.kt diff --git a/libraries/channels/qqbot/build.gradle.kts b/libraries/channels/qqbot/build.gradle.kts new file mode 100644 index 00000000..f0394314 --- /dev/null +++ b/libraries/channels/qqbot/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 of the gateway, of which the JDK serves none. + implementation(libs.java.websocket) + + testImplementation(project(":libraries:testkit")) + testImplementation(libs.logback.classic) +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotPlugin.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotPlugin.kt new file mode 100644 index 00000000..314e8613 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotPlugin.kt @@ -0,0 +1,7 @@ +package org.foedusprogramme.alexandrite.channel.qqbot + +import org.foedusprogramme.alexandrite.sdk.plugin.Plugin + +/** The QQ official bot channel plugin, which talks to the open platform of the QQ bots. */ +@Plugin(name = "QQ Bot", description = "QQ official bot channel") +public class QqBotPlugin diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/auth/QqBotTokens.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/auth/QqBotTokens.kt new file mode 100644 index 00000000..cefa16e3 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/auth/QqBotTokens.kt @@ -0,0 +1,97 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.auth + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotAccessToken +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.asTextOrNull +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.failureOf +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.number +import org.foedusprogramme.alexandrite.internal.http.HttpCall +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.slf4j.LoggerFactory +import java.time.Clock +import java.time.Instant + +/** + * The access token of one bot, which the open platform sells for its AppID and AppSecret and expires after two hours. + * + * The platform answers the same token while it is valid, so the token is kept until shortly before it expires and + * bought again then. A call that fails because the token expired asks for a new one rather than failing the turn. + */ +@ChannelInstanceScoped +internal class QqBotTokens( + private val config: QqBotInstanceConfig, + private val http: QqBotHttp, + private val clock: Clock, +) { + private val mutex = Mutex() + private var token: QqBotAccessToken? = null + private var expiresAt: Instant = Instant.EPOCH + + /** The token to present, bought again when it is time or when [refresh] asks for one. */ + suspend fun token(refresh: Boolean = false): String = mutex.withLock { + val kept = token + if (!refresh && kept != null && clock.instant().isBefore(expiresAt)) return kept.value + buy().also { + token = it + expiresAt = clock.instant().plusSeconds((it.expiresInSeconds - SKEW_SECONDS).coerceAtLeast(1)) + }.value + } + + /** The value the platform's authorization header carries: `QQBot `. */ + suspend fun authorization(refresh: Boolean = false): String = "QQBot ${token(refresh)}" + + /** Buys a token with the AppID and AppSecret of the instance. */ + private suspend fun buy(): QqBotAccessToken { + val call = HttpCall( + method = "POST", + uri = http.uri("/app/getAppAccessToken"), + headers = mapOf("Content-Type" to "application/json; charset=utf-8"), + body = buildJsonObject { + put("appId", config.appId) + put("clientSecret", config.appSecret.reveal()) + }.toString(), + secrets = listOf(config.appSecret.reveal()), + ) + val text = http.send(call) + val body = runCatching { QqBotWire.json.parseToJsonElement(text) as? JsonObject }.getOrNull() + val failure = failureOf(body) + if (failure.code != null && failure.code != 0) { + logger.warn( + "{}: the platform gave no access token for AppID {}: {} {}", + PLUGIN, + config.appId, + failure.code, + failure.message, + ) + throw IllegalStateException( + "The open platform gave no access token for AppID ${config.appId}: " + + "${failure.message} (code ${failure.code})", + ) + } + val value = body?.get("access_token")?.asTextOrNull() + if (value == null) { + logger.warn("{}: no access token came back for AppID {}: {}", PLUGIN, config.appId, text.take(200)) + throw IllegalStateException("The open platform gave no access token for AppID ${config.appId}.") + } + val expires = body.number("expires_in") ?: DEFAULT_EXPIRES_SECONDS + return QqBotAccessToken(value, expires) + } + + private companion object { + const val PLUGIN = "alexandrite-channel-qqbot" + + /** The platform expires a token in two hours; a new one is had this long before that. */ + const val SKEW_SECONDS = 60L + + const val DEFAULT_EXPIRES_SECONDS = 7_200L + + val logger = LoggerFactory.getLogger(QqBotTokens::class.java) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/config/QqBotConfig.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/config/QqBotConfig.kt new file mode 100644 index 00000000..c17bd2a5 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/config/QqBotConfig.kt @@ -0,0 +1,101 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.config + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.LanguageTag +import org.foedusprogramme.alexandrite.sdk.config.ConfigSection +import org.foedusprogramme.alexandrite.sdk.config.Secret +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped + +/** + * One bot of the QQ open platform, below `instances` of the plugin's config root. + * + * The platform gives every bot an AppID and an AppSecret, which the channel exchanges for an access token it refreshes + * as the platform expires it. What the bot receives is chosen by [intents], a bit set of event groups the platform + * documented: the group and single chat messages of the QQ chats are `GROUP_AND_C2C_EVENT` (1 << 25). + */ +@ConfigSection +@ChannelInstanceScoped +@Serializable +public class QqBotInstanceConfig( + /** The AppID of the bot on the open platform. */ + @SerialName("appId") public val appId: String, + /** The AppSecret of the bot, which buys its access tokens. */ + @SerialName("appSecret") public val appSecret: Secret, + /** The base of the open platform's API, which a test points at a server of its own. */ + @SerialName("apiBase") public val apiBase: String = DEFAULT_API_BASE, + /** The WebSocket address of the gateway, null to ask the platform for one. */ + @SerialName("gatewayUrl") public val gatewayUrl: String? = null, + /** The event groups this connection subscribes to, as the platform numbers them. */ + public val intents: Long = DEFAULT_INTENTS, + /** Whether a chat is told what a turn is doing while it works. */ + public val progress: Boolean = true, + /** Whether a single chat is answered with the platform's streaming message, false to send one message instead. */ + public val streaming: Boolean = true, + /** The accounts that may operate this instance, by their open id in the chat they write in. */ + public val admins: Set = emptySet(), + /** Whether the bot publishes its slash commands as the single-chat menu and command panels when it opens. */ + @SerialName("syncCommands") public val syncCommands: Boolean = true, + /** + * The language the published menu and panels use, null for the host's language. + * + * The platform shows one menu and one panel to everyone, so this is one language for the whole instance rather + * than the language of a chat. + */ + @SerialName("syncLanguage") public val syncLanguage: String? = null, + /** How long an approval request waits for an administrator, in minutes. */ + @SerialName("approvalMinutes") public val approvalMinutes: Long = DEFAULT_APPROVAL_MINUTES, + /** How long reaching the platform may take. */ + @SerialName("connectTimeoutMillis") public val connectTimeoutMillis: Long = DEFAULT_CONNECT_TIMEOUT, + /** How long the platform may take to answer a call before the call fails. */ + @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 to the gateway again, in milliseconds. */ + @SerialName("reconnectIntervalMillis") public val reconnectIntervalMillis: Long = DEFAULT_RECONNECT, +) { + init { + require(appId.isNotBlank()) { "appId may not be blank." } + require(appId.all { it.isLetterOrDigit() }) { "appId is the number the open platform gave, was '$appId'." } + require(apiBase.startsWith("http://") || apiBase.startsWith("https://")) { + "apiBase is an HTTP or HTTPS URL, was '$apiBase'." + } + require(gatewayUrl == null || gatewayUrl.startsWith("ws://") || gatewayUrl.startsWith("wss://")) { + "gatewayUrl is a WS or WSS URL, was '$gatewayUrl'." + } + require(intents > 0) { "intents names at least one event group, was $intents." } + 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." } + require(approvalMinutes > 0) { "An approval wait is at least a minute, was $approvalMinutes." } + syncLanguage?.let { language -> + require(runCatching { LanguageTag.of(language) }.isSuccess) { + "syncLanguage is a BCP 47 language tag, was '$language'." + } + } + } + + public companion object { + /** Where the open platform serves its API. */ + public const val DEFAULT_API_BASE: String = "https://api.bot.qq.com" + + /** The event groups of the single chat and group messages of the QQ chats. */ + public const val DEFAULT_INTENTS: Long = 1L shl 25 + + 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 DEFAULT_APPROVAL_MINUTES: Long = 10L + } +} + +/** The config path of [key] in the config of [instance], of the instance itself when [key] is null. */ +internal fun configPath(instance: ChannelInstanceId, key: String? = null): String = + listOfNotNull("channels.qqbot.instances.${instance.name}", key).joinToString(".") diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGateway.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGateway.kt new file mode 100644 index 00000000..364253b2 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGateway.kt @@ -0,0 +1,356 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.gateway + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +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.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFrame +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.asIntOrNull +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.asTextOrNull +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.java_websocket.client.WebSocketClient +import org.java_websocket.handshake.ServerHandshake +import org.slf4j.LoggerFactory +import java.net.URI +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The gateway of one bot: the WebSocket the open platform pushes its events on. + * + * The platform names the gateway for the bot, sends a hello that says how often to beat, and takes an identify that + * names the bot's token and the event groups it subscribes to. A connection that drops is made again: one that still + * holds a session resumes it and is sent what it missed, one that does not identifies again. + * + * The frames are handed on as they arrive; what they mean is the business of the channel above this class. + */ +@ChannelInstanceScoped +internal class QqBotGateway( + private val config: QqBotInstanceConfig, + private val api: QqBotApi, + private val tokens: QqBotTokens, +) : Lifecycle { + private val incoming = Channel(EVENT_CAPACITY, BufferOverflow.DROP_OLDEST) + + /** The frames the platform pushed, which the channel reads. */ + val frames: Flow = incoming.receiveAsFlow() + + private var scope: CoroutineScope? = null + private var loop: Job? = null + + @Volatile + private var client: GatewayClient? = null + + /** The session a reconnect resumes, null when the bot has none the platform still knows. */ + @Volatile + private var sessionId: String? = null + + /** The number of the last frame read, which a resume names and a heartbeat reports. */ + @Volatile + private var lastSeq: Long? = null + + /** The id the platform gave the bot in the ready frame. */ + @Volatile + var botId: String? = null + private set + + override suspend fun onStart() { + check(scope == null) { "The gateway is already open." } + val opened = CompletableDeferred() + val run = CoroutineScope(SupervisorJob() + Dispatchers.IO) + scope = run + loop = run.launch { connectLoop(opened) } + val failure = try { + withTimeout(config.connectTimeoutMillis * START_ATTEMPTS) { opened.await() } + } catch (e: TimeoutCancellationException) { + IllegalStateException("The gateway did not become ready in time.", e) + } + if (failure != null) throw failure + } + + override fun onStop() { + // Closing the socket ends the loop by way of its close handler; cancelling the scope ends it after that. + runCatching { client?.close() } + scope?.cancel() + scope = null + loop = null + client = null + } + + private suspend fun connectLoop(opened: CompletableDeferred) { + while (scope != null) { + val url = gatewayUrl() + if (url == null) { + if (!opened.isCompleted) opened.complete(IllegalStateException("The platform named no gateway.")) + return + } + val token = try { + tokens.token() + } catch (e: Exception) { + if (!opened.isCompleted) opened.complete(e) + return + } + val ended = CompletableDeferred() + val ready = CompletableDeferred() + val socket = GatewayClient(URI(url), ended, ready).also { it.token = token } + client = socket + val connected = try { + socket.connectBlocking(config.connectTimeoutMillis, TimeUnit.MILLISECONDS) + } catch (e: Exception) { + if (!opened.isCompleted) { + opened.complete(e) + return + } + null + } + if (connected != true) { + if (!opened.isCompleted) { + opened.complete(IllegalStateException("The gateway at $url did not answer.")) + return + } + } else { + val failure = try { + withTimeout(config.connectTimeoutMillis * START_ATTEMPTS) { ready.await() } + } catch (e: TimeoutCancellationException) { + IllegalStateException("The gateway at $url did not become ready in time.", e) + } + if (failure != null && !opened.isCompleted) { + opened.complete(failure) + socket.close() + return + } + if (!opened.isCompleted) opened.complete(null) + ended.await() + } + client = null + if (scope == null) return + delay(config.reconnectIntervalMillis) + } + } + + /** The address of the gateway, from the config or from the platform. */ + private suspend fun gatewayUrl(): String? { + config.gatewayUrl?.let { return it } + return when (val answer = api.gatewayUrl()) { + is QqBotAnswer.Ok -> (answer.body as? JsonObject)?.get("url")?.asTextOrNull() + + is QqBotAnswer.Failed -> { + logger.warn( + "{}: the platform named no gateway: {} {}", + PLUGIN, + answer.failure.code, + answer.failure.message, + ) + null + } + } + } + + /** The socket of one connection, which reads its frames on the thread of the library. */ + private inner class GatewayClient( + uri: URI, + private val ended: CompletableDeferred, + val ready: CompletableDeferred, + ) : WebSocketClient(uri) { + private val stopping = AtomicBoolean() + private var heartbeat: Job? = null + + override fun onOpen(handshake: ServerHandshake) { + logger.debug("{}: the gateway is open", PLUGIN) + } + + override fun onMessage(message: String) { + val frame = QqBotWire.frame(message) ?: run { + logger.warn("{}: the gateway sent a frame this version does not know: {}", PLUGIN, message.take(200)) + return + } + frame.s?.let { lastSeq = it } + when (frame.op) { + OP_HELLO -> if (!startHeartbeat(frame)) return + + OP_DISPATCH -> dispatch(frame) + + OP_HEARTBEAT_ACK -> Unit + + OP_RECONNECT -> close() + + OP_INVALID_SESSION -> { + sessionId = null + lastSeq = null + close() + } + + else -> logger.debug( + "{}: the gateway sent opcode {} which this version has no use for", + PLUGIN, + frame.op, + ) + } + } + + /** Starts beating as the hello asks, then identifies or resumes as the bot's session says. */ + private fun startHeartbeat(hello: QqBotFrame): Boolean { + val interval = (hello.d as? JsonObject)?.get("heartbeat_interval")?.asIntOrNull()?.toLong() + if (interval == null || interval <= 0) { + ready.complete(IllegalStateException("The platform's hello named no heartbeat interval.")) + close() + return false + } + val run = scope ?: return false + heartbeat = run.launch { + while (true) { + delay(interval) + val seq = lastSeq + send( + buildJsonObject { + put("op", OP_HEARTBEAT) + put("d", seq) + }.toString(), + ) + } + } + val kept = sessionId + if (kept != null) { + send(resume(kept)) + } else { + send(identify()) + } + return true + } + + /** Reads a dispatch frame: the ready and resumed frames settle the startup, the others are handed on. */ + private fun dispatch(frame: QqBotFrame) { + when (frame.t) { + "READY" -> { + val data = frame.d as? JsonObject + sessionId = (data?.get("session_id"))?.asTextOrNull() + botId = ((data?.get("user") as? JsonObject)?.get("id"))?.asTextOrNull() + logger.info("{}: the gateway is ready, session {}", PLUGIN, sessionId) + ready.complete(null) + } + + "RESUMED" -> { + logger.info("{}: the gateway resumed the session", PLUGIN) + ready.complete(null) + } + + null -> Unit + + else -> { + if (frame.t in MESSAGE_EVENTS) { + logger.info("{}: the platform dispatched {} at seq {}", PLUGIN, frame.t, frame.s) + } else { + logger.debug("{}: the platform dispatched {} at seq {}", PLUGIN, frame.t, frame.s) + } + if (!incoming.trySend(frame).isSuccess) { + logger.warn("{}: an event of the platform was dropped: the channel is behind", PLUGIN) + } + } + } + } + + /** The identify frame, which names the token, the event groups and the one shard this connection reads. */ + private fun identify(): String = buildJsonObject { + put("op", OP_IDENTIFY) + putJsonObject("d") { + put("token", "QQBot ${token ?: ""}") + put("intents", config.intents) + putJsonArray("shard") { + add(JsonPrimitive(0)) + add(JsonPrimitive(1)) + } + putJsonObject("properties") { + put("\$os", "linux") + put("\$browser", "alexandrite") + put("\$device", "alexandrite") + } + } + }.toString() + + /** The resume frame, which names the session and the last frame read. */ + private fun resume(kept: String): String = buildJsonObject { + put("op", OP_RESUME) + putJsonObject("d") { + put("token", "QQBot ${token ?: ""}") + put("session_id", kept) + put("seq", lastSeq) + } + }.toString() + + /** The token the identify and resume frames carry, read before the socket opened. */ + var token: String? = null + + override fun onClose(code: Int, reason: String, remote: Boolean) { + heartbeat?.cancel() + heartbeat = null + if (!stopping.get()) logger.warn("{}: the gateway closed: {} {}", PLUGIN, code, reason) + if (!ready.isCompleted) { + ready.complete(IllegalStateException("The gateway closed while starting: $code $reason")) + } + if (code in FATAL_CODES) { + logger.error("{}: the gateway refuses this bot: {} {}", PLUGIN, code, reason) + // Nothing this connection does makes the platform take the bot, so no reconnect is tried again. + scope?.cancel() + } + ended.complete(Unit) + } + + override fun onError(ex: Exception) { + logger.warn("{}: the gateway failed: {}", PLUGIN, ex.toString()) + if (!ready.isCompleted) ready.complete(ex) + } + + override fun close() { + stopping.set(true) + super.close() + } + } + + private companion object { + const val PLUGIN = "alexandrite-channel-qqbot" + + const val EVENT_CAPACITY = 1024 + + /** How many times a connect timeout the startup may take: the token, the gateway and the ready frame. */ + const val START_ATTEMPTS = 3L + + /** The dispatches that carry a message, which are worth a line at the usual level. */ + val MESSAGE_EVENTS = setOf("C2C_MESSAGE_CREATE", "GROUP_AT_MESSAGE_CREATE", "GROUP_MESSAGE_CREATE") + + const val OP_DISPATCH = 0 + const val OP_HEARTBEAT = 1 + const val OP_IDENTIFY = 2 + const val OP_RESUME = 6 + const val OP_RECONNECT = 7 + const val OP_INVALID_SESSION = 9 + const val OP_HELLO = 10 + const val OP_HEARTBEAT_ACK = 11 + + /** The codes the platform closes the connection with when the bot itself may not connect. */ + val FATAL_CODES = setOf(4001, 4002, 4010, 4011, 4012, 4013, 4014, 4914, 4915) + + val logger = LoggerFactory.getLogger(QqBotGateway::class.java) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/i18n/TextKeys.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/i18n/TextKeys.kt new file mode 100644 index 00000000..f3ccffb8 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/i18n/TextKeys.kt @@ -0,0 +1,20 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.i18n + +/** The texts of this channel for people, as keys of its own bundle. */ +internal object TextKeys { + const val APPROVAL_TITLE: String = "approval.title" + const val APPROVAL_TOOL: String = "approval.tool" + const val APPROVAL_REASON: String = "approval.reason" + const val APPROVAL_ARGUMENTS: String = "approval.arguments" + const val APPROVAL_ALLOW: String = "approval.allow" + const val APPROVAL_DENY: String = "approval.deny" + const val APPROVAL_ALWAYS: String = "approval.always" + const val APPROVAL_ALLOWED: String = "approval.allowed" + const val APPROVAL_DENIED: String = "approval.denied" + const val APPROVAL_EXPIRED: String = "approval.expired" + const val APPROVAL_ADMINS_ONLY: String = "approval.admins_only" + const val APPROVAL_NOTHING_WAITING: String = "approval.nothing_waiting" + const val APPROVAL_UNREACHABLE: String = "approval.unreachable" + const val REFUSED_ADMIN: String = "refused.admin" + const val REFUSED_ADMIN_HERE: String = "refused.admin_here" +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/net/QqBotApi.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/net/QqBotApi.kt new file mode 100644 index 00000000..e89c1e70 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/net/QqBotApi.kt @@ -0,0 +1,105 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.net + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFailure +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.failureOf +import org.foedusprogramme.alexandrite.internal.http.HttpCall +import org.foedusprogramme.alexandrite.internal.http.HttpException +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.slf4j.LoggerFactory + +/** What a call to the open platform answered: the body, or why it gave none. */ +internal sealed class QqBotAnswer { + class Ok(val body: JsonElement?) : QqBotAnswer() + + class Failed(val failure: QqBotFailure, val status: Int?) : QqBotAnswer() +} + +/** The calls of one bot to the open platform, which carry the token the platform sells it. */ +@ChannelInstanceScoped +internal class QqBotApi(private val http: QqBotHttp, private val tokens: QqBotTokens) { + /** The WebSocket address of the gateway, which the platform names for this bot. */ + suspend fun gatewayUrl(): QqBotAnswer = call("GET", "/gateway", null) + + /** Sends [body] to [path], which is the messages endpoint of one chat. */ + suspend fun post(path: String, body: JsonObject): QqBotAnswer = call("POST", path, body) + + /** Sends [body] to [path], replacing what it holds. */ + suspend fun put(path: String, body: JsonObject): QqBotAnswer = call("PUT", path, body) + + /** Reads [path], with the [query] it names as its own query. */ + suspend fun get(path: String, query: Map = emptyMap()): QqBotAnswer = + call("GET", withQuery(path, query), null) + + /** Deletes what [path] names. */ + suspend fun delete(path: String): QqBotAnswer = call("DELETE", path, null) + + /** [path] with [query] encoded after it, the path alone when there is nothing to name. */ + private fun withQuery(path: String, query: Map): String { + if (query.isEmpty()) return path + val encoded = query.entries.joinToString("&") { (name, value) -> + "${java.net.URLEncoder.encode(name, Charsets.UTF_8)}=" + + java.net.URLEncoder.encode(value, Charsets.UTF_8) + } + return "$path?$encoded" + } + + /** Calls [method] on [path] with [body], buying a new token once when the platform says the old one expired. */ + private suspend fun call(method: String, path: String, body: JsonObject?, retried: Boolean = false): QqBotAnswer { + val authorization = tokens.authorization(refresh = retried) + val call = HttpCall( + method = method, + uri = http.uri(path), + headers = buildMap { + put("Authorization", authorization) + if (body != null) put("Content-Type", "application/json; charset=utf-8") + }, + body = body?.toString(), + secrets = listOf(authorization.removePrefix("QQBot ")), + ) + return try { + val text = http.send(call) + val answer = if (text.isBlank()) { + null + } else { + runCatching { + QqBotWire.json.parseToJsonElement(text) + }.getOrNull() + } + val failure = failureOf(answer) + if (failure.code != null && failure.code != 0) { + if (failure.code in EXPIRED_TOKEN && !retried) { + logger.warn("{}: the token is not taken while calling {}; buying another", PLUGIN, path) + return call(method, path, body, retried = true) + } + QqBotAnswer.Failed(failure, null) + } else { + QqBotAnswer.Ok(answer) + } + } catch (e: HttpException) { + val parsed = e.body?.let { runCatching { QqBotWire.json.parseToJsonElement(it) }.getOrNull() } + if (e.status == UNAUTHORIZED && !retried) { + logger.warn("{}: the token expired while calling {}; buying another", PLUGIN, path) + return call(method, path, body, retried = true) + } + QqBotAnswer.Failed(failureOf(parsed), e.status) + } catch (e: Exception) { + logger.warn("{}: the call to {} failed: {}", PLUGIN, path, e.toString()) + QqBotAnswer.Failed(QqBotFailure(null, e.toString(), null), null) + } + } + + private companion object { + const val PLUGIN = "alexandrite-channel-qqbot" + + const val UNAUTHORIZED = 401 + + /** The codes with which the platform says the token presented is no good, which another one may fix. */ + val EXPIRED_TOKEN = setOf(11241, 11243, 11244) + + val logger = LoggerFactory.getLogger(QqBotApi::class.java) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/net/QqBotHttp.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/net/QqBotHttp.kt new file mode 100644 index 00000000..0c05c71f --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/net/QqBotHttp.kt @@ -0,0 +1,33 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.net + +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.internal.http.HttpCall +import org.foedusprogramme.alexandrite.internal.http.HttpResponseStream +import org.foedusprogramme.alexandrite.internal.http.HttpTimeouts +import org.foedusprogramme.alexandrite.internal.http.HttpTransport +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import java.net.URI +import kotlin.time.Duration.Companion.milliseconds + +/** The HTTP calls of one bot, over the transport the internal module keeps. */ +@ChannelInstanceScoped +internal class QqBotHttp(private val config: QqBotInstanceConfig) : AutoCloseable { + private val transport = HttpTransport( + HttpTimeouts( + connect = config.connectTimeoutMillis.milliseconds, + firstByte = config.firstByteTimeoutMillis.milliseconds, + idle = config.idleTimeoutMillis.milliseconds, + ), + ) + + /** The body of the answer to [call] as it came, which a caller reads as JSON. */ + suspend fun send(call: HttpCall): String = transport.send(call) + + /** Runs [block] on the response to [call], for a caller that reads the body piece by piece. */ + suspend fun open(call: HttpCall, block: suspend (HttpResponseStream) -> T): T = transport.open(call, block) + + /** The address of [path] below the API base of the instance. */ + fun uri(path: String): URI = URI(config.apiBase.trimEnd('/') + path) + + override fun close() = transport.close() +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/protocol/QqBotPayloads.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/protocol/QqBotPayloads.kt new file mode 100644 index 00000000..854d7599 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/protocol/QqBotPayloads.kt @@ -0,0 +1,211 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.protocol + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +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 + +/** The JSON of the QQ open platform, whose answers carry fields this version does not read. */ +internal object QqBotWire { + /** The JSON this channel reads and writes, which keeps what it does not know and writes what it sets. */ + val json: Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = false + } + + /** The gateway frame [text] is, null when it is no frame this version knows. */ + fun frame(text: String): QqBotFrame? = runCatching { + json.decodeFromString(QqBotFrame.serializer(), text) + }.getOrNull() + + /** The message event [data] carries, null when it is no message event. */ + fun message(data: JsonElement?): QqBotMessageEvent? { + if (data == null || data is kotlinx.serialization.json.JsonNull) return null + return runCatching { + json.decodeFromJsonElement(QqBotMessageEvent.serializer(), data) + }.getOrNull() + } + + /** The text of a frame field, null when the frame holds no such field. */ + fun string(element: JsonElement?, key: String): String? = + (element as? JsonObject)?.get(key)?.let { it as? JsonPrimitive }?.contentOrNull() +} + +/** One frame of the gateway, whatever its opcode carries. */ +@Serializable +internal class QqBotFrame( + /** The event id of a dispatch frame, null on the frames the client sends. */ + val id: String? = null, + val op: Int, + val d: JsonElement? = null, + /** The sequence number of a dispatch frame, which a heartbeat and a resume name. */ + val s: Long? = null, + /** The event name of a dispatch frame. */ + val t: String? = null, +) + +/** A message of the QQ chats, which the gateway dispatches as C2C_MESSAGE_CREATE or GROUP_AT_MESSAGE_CREATE. */ +@Serializable +internal class QqBotMessageEvent( + /** The id of the message, which a passive reply names as `msg_id`. */ + val id: String, + val author: QqBotAuthor = QqBotAuthor(), + /** The text of the message, which the platform already stripped the mention of the bot from. */ + val content: String = "", + /** The group the message came from, null for a single chat. */ + @SerialName("group_openid") val groupOpenid: String? = null, + /** When the platform received the message, in RFC 3339. */ + val timestamp: String? = null, + /** What the message is: 0 text, 3 a card, 103 a quote of other messages. */ + @SerialName("message_type") val messageType: Int = 0, + @SerialName("message_scene") val messageScene: QqBotMessageScene? = null, + val attachments: List = emptyList(), + val mentions: List = emptyList(), + @SerialName("ark_data") val arkData: QqBotArkData? = null, + @SerialName("msg_elements") val msgElements: List = emptyList(), +) + +/** Who wrote a message, in a single chat or a group. */ +@Serializable +internal class QqBotAuthor( + val id: String? = null, + val username: String? = null, + val bot: Boolean = false, + /** The user's id in a single chat. */ + @SerialName("user_openid") val userOpenid: String? = null, + /** The user's id in a group. */ + @SerialName("member_openid") val memberOpenid: String? = null, + /** The user's role in the group: member, admin or owner. */ + @SerialName("member_role") val memberRole: String? = null, +) { + /** The id the chat the message came from names this author by. */ + fun openid(group: Boolean): String? = if (group) memberOpenid else userOpenid +} + +/** What a message carries besides its text: an image, a voice message, a video or a file. */ +@Serializable +internal class QqBotAttachment( + val url: String? = null, + val filename: String? = null, + val width: Int? = null, + val height: Int? = null, + val size: Long? = null, + /** The MIME type of the attachment, or `voice` for a voice message or `file` for a group file. */ + @SerialName("content_type") val contentType: String? = null, + /** The address a voice message's SILK was converted to WAV at, null for the other kinds. */ + @SerialName("voice_wav_url") val voiceWavUrl: String? = null, + @SerialName("asr_refer_text") val asrReferText: String? = null, +) + +/** Where a message was written and what indexes it carries: the message index and the one it quotes. */ +@Serializable +internal class QqBotMessageScene( + val source: String? = null, + /** `key=value` entries, among them `msg_idx`, `ref_msg_idx` and `auth_token`. */ + val ext: List = emptyList(), +) { + /** The value of [key] among the entries, null when they carry none. */ + fun ext(key: String): String? = ext.firstOrNull { it.startsWith("$key=") }?.substringAfter('=') +} + +/** The card a card message carries. */ +@Serializable +internal class QqBotArkData( + /** What the platform says the card is, in the words a chat is shown. */ + val prompt: String? = null, + @SerialName("ark_type") val arkType: String? = null, + @SerialName("ark_name") val arkName: String? = null, +) + +/** One element of a quote message, which nests the quoted messages it gathers. */ +@Serializable +internal class QqBotMsgElement( + @SerialName("msg_idx") val msgIdx: String? = null, + val author: QqBotAuthor? = null, + @SerialName("message_type") val messageType: Int? = null, + val content: String? = null, + val attachments: List = emptyList(), + @SerialName("ark_data") val arkData: QqBotArkData? = null, + @SerialName("msg_elements") val msgElements: List = emptyList(), +) + +/** The token the open platform sells for the AppID and AppSecret of a bot. */ +internal class QqBotAccessToken(val value: String, val expiresInSeconds: Long) + +/** What the open platform said when a call did not succeed. */ +internal class QqBotFailure( + /** The `err_code` or `code` of the answer, null when it named none. */ + val code: Int?, + /** The message the platform wrote, which is for people and may change. */ + val message: String, + /** The platform's id of the request, which it asks for in a support request. */ + val traceId: String?, +) + +/** What a failed answer says, as the fields both kinds of answer use. */ +internal fun failureOf(body: JsonElement?): QqBotFailure { + val objectBody = body as? JsonObject + val code = objectBody?.get("err_code")?.asIntOrNull() ?: objectBody?.get("code")?.asIntOrNull() + val message = objectBody?.get("message")?.asTextOrNull().orEmpty() + val trace = objectBody?.get("trace_id")?.asTextOrNull() + return QqBotFailure(code, message, trace) +} + +/** The int value of the field, null when it is no number. */ +internal fun JsonElement?.asIntOrNull(): Int? = (this as? JsonPrimitive)?.content?.toIntOrNull() + +/** The text of the field, null when it is blank or no text. */ +internal fun JsonElement?.asTextOrNull(): String? = (this as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() } + +/** The number of the token answer, which the platform writes as a string or as a number. */ +internal fun JsonObject.number(key: String): Long? = (this[key] as? JsonPrimitive)?.content?.toLongOrNull() + +/** The boolean of the field, null when it is no boolean. */ +internal fun JsonElement?.asBooleanOrNull(): Boolean? = (this as? JsonPrimitive)?.content?.let { + when (it.lowercase()) { + "true" -> true + "false" -> false + else -> null + } +} + +/** The content of the string, null when it is null or not a primitive. */ +internal fun JsonPrimitive?.contentOrNull(): String? = if (this == null || this is JsonNull) null else content + +/** The platform's answer to a message being sent: the id it gave the message and its quote reference. */ +@Serializable +internal class QqBotSent( + val id: String? = null, + val timestamp: String? = null, + @SerialName("ext_info") val extInfo: QqBotSentInfo? = null, +) + +/** What the send response says about quoting the sent message. */ +@Serializable +internal class QqBotSentInfo( + /** The reference a later quote of this message names, which the send's message id does not. */ + @SerialName("ref_idx") val refIdx: String? = null, +) + +/** What a group or single chat message is: `message_type=0` text, `2` markdown, `7` media. */ +internal object QqBotMessageType { + const val TEXT: Int = 0 + const val MARKDOWN: Int = 2 + const val TYPING: Int = 6 + const val MEDIA: Int = 7 +} + +/** The kinds of attachment whose content type the platform names, mapped to the kind the SDK knows. */ +internal fun attachmentKind(contentType: String?): String = when { + contentType == null -> "file" + contentType == "voice" -> "audio" + contentType == "file" -> "file" + contentType.startsWith("image/") -> "image" + contentType.startsWith("audio/") -> "audio" + contentType.startsWith("video/") -> "video" + else -> "file" +} From 1e03a5da7d2b6969b5e066ead7d6701ec38d5c1e Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 01:31:47 +0800 Subject: [PATCH 10/37] qqbot: Map chats and route commands Maps C2C and group messages, mentions, quotes and Markdown, and parses slash commands with the channel's permission matrix before a turn starts. --- .../qqbot/channel/QqBotCommandRules.kt | 96 +++++++ .../channel/qqbot/channel/QqBotCommands.kt | 23 ++ .../channel/qqbot/channel/QqBotInbox.kt | 253 ++++++++++++++++++ .../channel/qqbot/mapping/QqBotChats.kt | 56 ++++ .../channel/qqbot/mapping/QqBotMarkdown.kt | 128 +++++++++ .../channel/qqbot/mapping/QqBotMedia.kt | 33 +++ .../channel/qqbot/mapping/QqBotMessages.kt | 139 ++++++++++ 7 files changed, 728 insertions(+) create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRules.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommands.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotInbox.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotChats.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMarkdown.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMedia.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMessages.kt diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRules.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRules.kt new file mode 100644 index 00000000..8c227620 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRules.kt @@ -0,0 +1,96 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.channel.qqbot.i18n.TextKeys +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotChats +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.turn.AgentControl +import org.foedusprogramme.alexandrite.sdk.turn.CommandHandler +import org.foedusprogramme.alexandrite.sdk.turn.CommandInvocation +import java.util.Locale + +/** Who may run a command. */ +internal enum class QqBotWho { + ANYONE, + + /** Anyone in a direct chat, admins elsewhere. */ + DIRECT, + + /** Anyone in a direct chat linked with no other chat, admins elsewhere. */ + UNLINKED_DIRECT, + + ADMIN, +} + +/** Who may run a command: [bare] without arguments, [withArguments] with them. */ +internal class QqBotCommandRule(val bare: QqBotWho, val withArguments: QqBotWho = bare) + +/** + * Who may run each command that a handler claims, after [MATRIX]: admins alone for a claimed command it does not list. + * + * This is a copy of the rules the other channels apply, made for this channel until the shared ones are moved to + * `channel-common`; it is a channel's own decision, since the agent's control takes the operator as a parameter and + * does not judge it. + */ +@Singleton +internal class QqBotCommandRules(handlers: List, private val control: AgentControl?) { + private val claimed = handlers.flatMap { it.commands }.mapTo(HashSet()) { it.name.lowercase(Locale.ROOT) } + + /** The key of the refusal of [invocation] in a chat of [kind], null when its issuer may run it. */ + fun refusal(invocation: CommandInvocation, kind: ChatKind): String? { + val name = invocation.name.lowercase(Locale.ROOT) + if (invocation.issuer.isAdmin || name !in claimed) return null + val rule = MATRIX[name] ?: ADMIN_ONLY + val direct = kind == ChatKind.DIRECT + return when (if (invocation.arguments.isBlank()) rule.bare else rule.withArguments) { + QqBotWho.ANYONE -> null + QqBotWho.DIRECT -> TextKeys.REFUSED_ADMIN_HERE.takeUnless { direct } + QqBotWho.UNLINKED_DIRECT -> TextKeys.REFUSED_ADMIN_HERE.takeUnless { direct && !linked(invocation.chat) } + QqBotWho.ADMIN -> TextKeys.REFUSED_ADMIN + } + } + + /** Whether an admin, or a member when [admin] is false, may run [command] in [chat]. */ + fun mayRun(chat: ChatAddress, admin: Boolean, command: String, withArguments: Boolean): Boolean { + val name = command.lowercase(Locale.ROOT) + if (admin || name !in claimed) return true + val rule = MATRIX[name] ?: ADMIN_ONLY + val direct = QqBotChats.kind(chat) == ChatKind.DIRECT + return when (if (withArguments) rule.withArguments else rule.bare) { + QqBotWho.ANYONE -> true + QqBotWho.DIRECT -> direct + QqBotWho.UNLINKED_DIRECT -> direct && !linked(chat) + QqBotWho.ADMIN -> false + } + } + + private fun linked(chat: ChatAddress): Boolean = control?.links().orEmpty().any { group -> + (group.members + group.anchor).any { it.parent == chat.parent } + } + + internal companion object { + /** The rules of the default commands, the same table the other channels apply. */ + val MATRIX: Map = mapOf( + "help" to QqBotCommandRule(QqBotWho.ANYONE), + "settings" to QqBotCommandRule(QqBotWho.ANYONE), + "context" to QqBotCommandRule(QqBotWho.ANYONE), + "profiles" to QqBotCommandRule(QqBotWho.ANYONE), + "agent" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.DIRECT), + "reasoning" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.UNLINKED_DIRECT), + "language" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.UNLINKED_DIRECT), + "model" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.ADMIN), + "mode" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.ADMIN), + "jobs" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.ADMIN), + "new" to QqBotCommandRule(QqBotWho.UNLINKED_DIRECT), + "compact" to QqBotCommandRule(QqBotWho.UNLINKED_DIRECT), + "cancel" to QqBotCommandRule(QqBotWho.DIRECT), + "trust" to QqBotCommandRule(QqBotWho.ADMIN), + "untrust" to QqBotCommandRule(QqBotWho.ADMIN), + "allow" to QqBotCommandRule(QqBotWho.ADMIN), + "streaming" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.DIRECT), + ) + + private val ADMIN_ONLY = QqBotCommandRule(QqBotWho.ADMIN) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommands.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommands.kt new file mode 100644 index 00000000..f4265166 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommands.kt @@ -0,0 +1,23 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +/** + * The commands a message of this channel carries. + * + * The platform has no command entity, so a command is the same shape the Telegram channel reads from `bot_command`: + * a slash, then a name of 1 to 32 ASCII letters, digits or underscores, optionally followed by `@target`, and the + * arguments after it. A slash that does not start such a name is ordinary text. + */ +internal object QqBotCommands { + private val TOKEN = Regex("^/([A-Za-z0-9_]{1,32})(?:@[A-Za-z0-9_]{1,32})?$") + + /** The command [text] carries, null when its slash does not start one. */ + fun of(text: String): Parsed? { + val trimmed = text.trimStart() + val token = trimmed.takeWhile { !it.isWhitespace() } + val name = TOKEN.matchEntire(token)?.groupValues?.get(1) ?: return null + return Parsed(name, trimmed.removePrefix(token).trim()) + } + + /** The parts of a command: its [name] and its [arguments], which may be empty. */ + class Parsed(val name: String, val arguments: String) +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotInbox.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotInbox.kt new file mode 100644 index 00000000..c3b604da --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotInbox.kt @@ -0,0 +1,253 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.qqbot.gateway.QqBotGateway +import org.foedusprogramme.alexandrite.channel.qqbot.i18n.TextKeys +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotMedia +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotMessages +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFrame +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotMessageEvent +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.asTextOrNull +import org.foedusprogramme.alexandrite.channel.qqbot.tools.QqBotApis +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.foedusprogramme.alexandrite.sdk.i18n.Texts +import org.foedusprogramme.alexandrite.sdk.turn.Admission +import org.foedusprogramme.alexandrite.sdk.turn.Capacity +import org.foedusprogramme.alexandrite.sdk.turn.CommandInvocation +import org.foedusprogramme.alexandrite.sdk.turn.Submission +import org.foedusprogramme.alexandrite.sdk.turn.TurnSubmitter +import org.slf4j.LoggerFactory +import java.util.concurrent.ConcurrentHashMap + +/** + * What the gateway pushes, taken into the agent. + * + * The platform pushes a message of a single chat, and of a group only when the message names the bot, so both are the + * bot's to answer. Everything else a bot of the platform may subscribe to is left for a version that has a shape to + * hand it to another plugin. + * + * The platform may push the same message twice: a message id already read is dropped rather than becoming a second + * turn. + */ +@ChannelInstanceScoped +internal class QqBotInbox( + private val instance: ChannelInstance, + private val config: QqBotInstanceConfig, + private val gateway: QqBotGateway, + private val media: QqBotMedia, + private val approvals: QqBotApprovals, + private val sender: QqBotSender, + private val rules: QqBotCommandRules, + private val texts: Texts, + private val apis: QqBotApis, + private val api: QqBotApi, + private val index: QqBotMessageIndex, + /** The agent, null while the runtime has none. */ + private val submitter: TurnSubmitter?, +) : Lifecycle { + private var reading: Job? = null + + /** The openid the platform knows this bot by, which a full-mode group message names it by when it is addressed. */ + @Volatile + private var botOpenid: String? = null + + /** The bot's own member openid of each group, which `bot_state` or its own messages name. */ + private val botMemberOpenids = ConcurrentHashMap() + + /** The groups whose `bot_state` was already asked for, so a refusal is not asked for again and again. */ + private val botStateAsked = ConcurrentHashMap.newKeySet() + + private val seen = object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > KEPT + } + + override suspend fun onOpen() { + apis.register(instance.id, api) + identify() + if (submitter == null) { + logger.warn("{}: the runtime has no agent, so the messages of {} reach nobody", PLUGIN, instance.id) + return + } + reading = instance.scope.launch { read() } + } + + /** Asks the platform for the bot's own id, which a full-mode group message may mention it by. */ + private suspend fun identify() { + val answer = api.get("/users/@me") + val body = (answer as? QqBotAnswer.Ok)?.body as? JsonObject + botOpenid = body?.get("id")?.asTextOrNull() + if (botOpenid == null) logger.debug("{}: the platform did not name the bot itself", PLUGIN) + } + + override suspend fun onClose() { + reading?.cancel() + reading = null + apis.forget(instance.id) + } + + private suspend fun read() { + gateway.frames.collect { frame -> read(frame) } + } + + /** Takes one frame of the gateway, and the message it dispatches when it dispatches one. */ + private suspend fun read(frame: QqBotFrame) { + if (frame.op != OP_DISPATCH || frame.t == null) return + if (frame.t != C2C_MESSAGE_CREATE && frame.t != GROUP_AT_MESSAGE_CREATE && frame.t != GROUP_MESSAGE_CREATE) { + logger.debug("{}: the platform dispatched {}, which this version leaves alone", PLUGIN, frame.t) + return + } + val event = QqBotWire.message(frame.d) ?: run { + logger.warn("{}: an event {} carries no message this version knows", PLUGIN, frame.t) + return + } + if (event.author.bot && event.groupOpenid != null) { + event.author.memberOpenid?.let { botMemberOpenids[event.groupOpenid] = it } + } + if (frame.t == GROUP_MESSAGE_CREATE && !addressed(event)) { + logger.debug( + "{}: a group message of the full mode, {} in {}, is not addressed to this bot", + PLUGIN, + event.id, + event.groupOpenid, + ) + return + } + if (event.author.bot) return + if (!first(event.id)) { + logger.debug("{}: the platform pushed the message {} again", PLUGIN, event.id) + return + } + if (QqBotMessages.text(event).isBlank() && event.attachments.isEmpty() && event.msgElements.isEmpty()) { + logger.info( + "{}: the message {} was only a mention of the bot or was empty; no turn is asked", + PLUGIN, + event.id, + ) + return + } + val message = QqBotMessages.incoming(instance.id, event, config.admins, media, index) + // A diagnostic that works before any tool: the person can read the id the bot knows them by, which is what + // the instance's admins list and the agent's tool rules are keyed by. + if (message.text.trim().equals(WHOAMI, ignoreCase = true)) { + sender.send( + message.chat, + OutboundMessage.builder( + "chat=${message.chat.chat}\nsender=${message.sender.address}", + MessageKind.NOTICE, + ).build(), + ) + return + } + val press = QqBotApprovalCommands.of(message.text) + if (press != null) { + logger.info("{}: {} answers an approval of {}", PLUGIN, message.sender.address, press.id) + approvals.answer(press.name, press.id, press.always, message.sender, message.chat)?.let { said -> + sender.send(message.chat, OutboundMessage.builder(said, MessageKind.NOTICE).build()) + } + return + } + val command = QqBotCommands.of(message.text) + if (command != null) { + val invocation = CommandInvocation.builder(message.chat, command.name, message.sender) + .arguments(command.arguments) + .trigger(message.ref) + .build() + val refusal = rules.refusal(invocation, message.chatInfo.kind) + if (refusal != null) { + val said = texts.text(refusal, null, "command" to command.name.lowercase()) + sender.send(message.chat, OutboundMessage.builder(said, MessageKind.NOTICE).build()) + return + } + val capacity = if (message.sender.isAdmin) Capacity.EXEMPT else Capacity.COUNTED + logger.info("{}: the command {} of {} becomes a turn for the agent", PLUGIN, command.name, message.chat) + report(message, submitter?.submit(Submission.Command(invocation, message, capacity))) + return + } + logger.info("{}: the message {} of {} becomes a turn for the agent", PLUGIN, event.id, message.chat) + report(message, submitter?.submit(Submission.Message(message))) + } + + /** Whether [id] is a message this inbox reads for the first time. */ + private fun first(id: String): Boolean = synchronized(seen) { seen.put(id, Unit) == null } + + /** + * Whether a full-mode group message names this bot. + * + * The platform pushes every group message in the full mode, and only the mention of the bot makes one a message + * for the bot. The mention list names the bot by its id, when the platform keeps it, and the text may still start + * with `<@…>`, which the bot's own id is compared against. + */ + private suspend fun addressed(event: QqBotMessageEvent): Boolean { + val group = event.groupOpenid ?: return false + val me = botOpenid + val member = botMemberOpenids[group] + if (event.mentions.any { mention -> mention.bot && namesBot(mention, me, member) }) return true + val token = LEADING_MENTION.find(event.content)?.groupValues?.get(1) ?: return false + if (token == me || token == member) return true + if (member == null && botStateAsked.add(group)) { + val answer = api.get("/v2/groups/$group/bot_state") + val body = (answer as? QqBotAnswer.Ok)?.body as? JsonObject + val openid = body?.get("member_openid")?.asTextOrNull() + if (openid != null) { + botMemberOpenids[group] = openid + return token == openid + } + logger.debug("{}: the platform did not name the bot's member openid of {}", PLUGIN, group) + } + return false + } + + /** Whether [mention] is the bot, by the global id or the member openid of its group. */ + private fun namesBot( + mention: org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotAuthor, + me: String?, + member: String?, + ): Boolean = (me == null && member == null) || mention.id == me || mention.memberOpenid == me || + mention.userOpenid == me || mention.id == member || mention.memberOpenid == member || + mention.userOpenid == member + + /** Reports a submission the agent refused, which would otherwise be silence nobody can explain. */ + private fun report(message: org.foedusprogramme.alexandrite.sdk.channel.IncomingMessage, admission: Admission?) { + if (admission !is Admission.Refused) return + logger.warn( + "{}: instance {} took a message of {} no further: {}", + PLUGIN, + instance.id, + message.chat, + admission.reason, + ) + } + + private companion object { + const val PLUGIN = "alexandrite-channel-qqbot" + + const val OP_DISPATCH = 0 + + const val C2C_MESSAGE_CREATE = "C2C_MESSAGE_CREATE" + + const val GROUP_AT_MESSAGE_CREATE = "GROUP_AT_MESSAGE_CREATE" + + /** The full-mode event of the groups that the bot takes every message of, not only the ones naming it. */ + const val GROUP_MESSAGE_CREATE = "GROUP_MESSAGE_CREATE" + + /** The command that tells a person the address this instance knows them by, for `admins` and tool rules. */ + const val WHOAMI = "/whoami" + + /** How many message ids the inbox keeps to tell a repeated push from a new message. */ + const val KEPT = 1024 + + val LEADING_MENTION = Regex("^\\s*<@!?([^>]+)>") + + val logger = LoggerFactory.getLogger(QqBotInbox::class.java) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotChats.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotChats.kt new file mode 100644 index 00000000..64ca42d9 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotChats.kt @@ -0,0 +1,56 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.mapping + +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotMessageEvent +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.chat.UserAddress + +/** + * The chats of a QQ bot instance, printed the way the rest of Alexandrite reads them. + * + * A single chat is `private:` and a group is `group:`. The ids are the open ids the + * platform gave the bot itself: the same user or group has another id for another bot. + */ +internal object QqBotChats { + const val PRIVATE: String = "private" + + const val GROUP: String = "group" + + /** The chat [event] was written in. */ + fun addressOf(instance: ChannelInstanceId, event: QqBotMessageEvent): ChatAddress { + val group = event.groupOpenid + return if (group.isNullOrEmpty()) { + ChatAddress(instance, "$PRIVATE:${event.author.openid(group = false)}") + } else { + ChatAddress(instance, "$GROUP:$group") + } + } + + /** The kind of [chat], null when it names no chat this channel serves. */ + fun kind(chat: ChatAddress): ChatKind? = when { + chat.chat.startsWith("$PRIVATE:") -> ChatKind.DIRECT + chat.chat.startsWith("$GROUP:") -> ChatKind.GROUP + else -> null + } + + /** The openid of [chat], null when it names no chat this channel serves. */ + fun openid(chat: ChatAddress): String? = when { + chat.chat.startsWith("$PRIVATE:") -> chat.chat.removePrefix("$PRIVATE:").takeIf { it.isNotEmpty() } + chat.chat.startsWith("$GROUP:") -> chat.chat.removePrefix("$GROUP:").takeIf { it.isNotEmpty() } + else -> null + } + + /** The path below the API base that sends a message to [chat], null when it names no chat this channel serves. */ + fun sendPath(chat: ChatAddress): String? { + val id = openid(chat) ?: return null + return when (kind(chat)) { + ChatKind.DIRECT -> "/v2/users/$id/messages" + ChatKind.GROUP -> "/v2/groups/$id/messages" + else -> null + } + } + + /** The user of [id] in this instance. */ + fun user(instance: ChannelInstanceId, id: String): UserAddress = UserAddress(instance, id) +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMarkdown.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMarkdown.kt new file mode 100644 index 00000000..af98a542 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMarkdown.kt @@ -0,0 +1,128 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.mapping + +/** + * Makes a Markdown text safe for the QQ client without throwing away what it renders. + * + * The client renders headings, emphasis, lists, code, tables and most of what the model writes, so supported Markdown + * is passed through as it is. What the client only shows as raw text is adapted to the nearest form it does render: + * `==highlight==` becomes bold, `^sup^` and `~sub~` become the client's inline HTML, definition lists become plain + * "term: definition" lines, and HTML blocks lose their tags. A link the client cannot open is left as its label, and a + * local image path becomes its alt text. The inside of a code fence or an inline code span is never changed. + */ +internal object QqBotMarkdown { + private val FENCE = Regex("^\\s*(`{3,}|~{3,}).*") + private val TOC_LINE = Regex("^\\s*\\[TOC]\\s*$", RegexOption.IGNORE_CASE) + private val FOOTNOTE_DEFINITION = Regex("^\\s*\\[\\^[^]]+]:.*$") + private val DEFINITION_LINE = Regex("^\\s*:\\s+(.*)$") + private val LOCAL_IMAGE = Regex("!\\[([^]]*)]\\(([^)\\s]+)(?:\\s+\"[^\"]*\")?\\)") + private val HTML_BREAK = Regex("", RegexOption.IGNORE_CASE) + private val HTML_RULE = Regex("", RegexOption.IGNORE_CASE) + private val HTML_TAG = Regex("]*)?/?>") + private val HIGHLIGHT = Regex("==([^=\\n]+)==") + private val SUPERSCRIPT = Regex("\\^([^^\\n]+)\\^") + private val SUBSCRIPT = Regex("(?() + var fence: String? = null + for (line in text.lines()) { + val opening = FENCE.matchEntire(line)?.groupValues?.get(1) + if (fence != null) { + out += line + if (opening != null && line.trimStart().startsWith(fence)) fence = null + continue + } + if (opening != null) { + fence = opening.take(3) + out += line + continue + } + if (TOC_LINE.matches(line) || FOOTNOTE_DEFINITION.matches(line)) continue + val definition = DEFINITION_LINE.matchEntire(line) + if (definition != null) { + val previous = out.indexOfLast { it.isNotBlank() } + if (previous >= 0 && !looksLikeBlock(out[previous])) { + out[previous] = out[previous].trimEnd() + ":" + definition.groupValues[1].trim() + continue + } + } + out += renderLine(line, stripUrls) + } + return out.joinToString("\n") + } + + /** [text] with every URL and link target taken out, for a platform that refuses a message holding one. */ + fun withoutUrls(text: String): String = render(text, stripUrls = true) + + /** Whether [line] is a heading, list item or quote, which a definition line does not extend. */ + private fun looksLikeBlock(line: String): Boolean { + val trimmed = line.trimStart() + return trimmed.startsWith("#") || trimmed.startsWith("-") || trimmed.startsWith("*") || + trimmed.startsWith("+") || trimmed.startsWith(">") || trimmed.startsWith("|") || + trimmed.startsWith("```") || trimmed.startsWith("~~~") || trimmed.matches(Regex("^\\d+[.)]\\s.*")) + } + + /** [line] outside a code fence, with inline code left alone. */ + private fun renderLine(line: String, stripUrls: Boolean): String = buildString { + var index = 0 + while (index < line.length) { + val ticks = ticksAt(line, index) + if (ticks == 0) { + val start = index + while (index < line.length && ticksAt(line, index) == 0) index++ + append(renderSegment(line.substring(start, index), stripUrls)) + } else { + val end = line.indexOf("`".repeat(ticks), index + ticks) + if (end < 0) { + append(renderSegment(line.substring(index), stripUrls)) + return@buildString + } + append(line, index, end + ticks) + index = end + ticks + } + } + } + + /** How many backticks start at [index], zero when the character there is none. */ + private fun ticksAt(line: String, index: Int): Int { + if (index >= line.length || line[index] != '`') return 0 + var end = index + while (end < line.length && line[end] == '`') end++ + return end - index + } + + /** [segment] outside inline code, adapted for the client. */ + private fun renderSegment(segment: String, stripUrls: Boolean): String { + if (segment.isEmpty()) return segment + var text = segment + if (stripUrls) { + text = text.replace(Regex("\\[([^]]+)]\\([^)]*\\)"), "$1") + text = text.replace(Regex("https?://\\S+"), "") + } + text = text.replace(HIGHLIGHT, "**$1**") + text = text.replace(SUPERSCRIPT, "$1") + text = text.replace(SUBSCRIPT, "$1") + text = text.replace(FOOTNOTE_REFERENCE, "") + text = text.replace(HTML_BREAK, "\n") + text = text.replace(HTML_RULE, "\n---") + text = text.replace(HTML_TAG) { match -> + val tag = match.value.lowercase() + if (tag.startsWith(" + val alt = match.groupValues[1] + val url = match.groupValues[2] + if (url.startsWith("http://") || url.startsWith("https://")) match.value else alt + } + return text + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMedia.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMedia.kt new file mode 100644 index 00000000..8cc83ddd --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMedia.kt @@ -0,0 +1,33 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.mapping + +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.internal.http.HttpCall +import org.foedusprogramme.alexandrite.sdk.channel.MediaContent +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import java.io.ByteArrayOutputStream +import java.net.URI + +/** Reads the bytes of a media file a message of this platform carries, from the address it names. */ +@ChannelInstanceScoped +internal class QqBotMedia(private val http: QqBotHttp) { + /** What reads the bytes at [url], at most as many as a caller allows. */ + fun content(url: String): MediaContent { + val uri = runCatching { URI(url) }.getOrNull() + if (uri == null || (uri.scheme != "http" && uri.scheme != "https")) { + return MediaContent { throw IllegalArgumentException("'$url' is no address a file is read from.") } + } + return MediaContent { maxBytes -> read(uri, maxBytes) } + } + + private suspend fun read(uri: URI, maxBytes: Long): ByteArray = http.open(HttpCall("GET", uri)) { response -> + val out = ByteArrayOutputStream() + while (true) { + val piece = response.read() ?: break + if (out.size().toLong() + piece.size > maxBytes) { + throw IllegalArgumentException("The file at $uri holds more than the $maxBytes bytes allowed here.") + } + out.write(piece) + } + out.toByteArray() + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMessages.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMessages.kt new file mode 100644 index 00000000..440eac17 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMessages.kt @@ -0,0 +1,139 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.mapping + +import org.foedusprogramme.alexandrite.channel.qqbot.channel.QqBotMessageIndex +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotAttachment +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotMessageEvent +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotMsgElement +import org.foedusprogramme.alexandrite.sdk.channel.DisplayFact +import org.foedusprogramme.alexandrite.sdk.channel.IncomingMessage +import org.foedusprogramme.alexandrite.sdk.channel.MediaAttachment +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.transcript.MediaKind +import java.time.Instant +import java.time.OffsetDateTime + +/** The messages of a QQ bot instance, as the gateway reported them. */ +internal object QqBotMessages { + private val LEADING_MENTION = Regex("^\\s*<@!?[^>]+>\\s*") + + /** The message [event] is, where [admins] are the accounts the instance marks as its operators. */ + fun incoming( + instance: ChannelInstanceId, + event: QqBotMessageEvent, + admins: Set, + media: QqBotMedia, + index: QqBotMessageIndex? = null, + ): IncomingMessage { + val group = !event.groupOpenid.isNullOrEmpty() + val chat = QqBotChats.addressOf(instance, event) + index?.record(chat, event.messageScene?.ext("msg_idx"), event.id) + val authorId = event.author.openid(group) ?: event.author.userOpenid ?: event.author.memberOpenid ?: "unknown" + val role = event.author.memberRole + val name = event.author.username?.takeIf { it.isNotBlank() } ?: authorId + val sender = ChatUser( + address = QqBotChats.user(instance, authorId), + displayName = name, + username = null, + isBot = event.author.bot, + isAdmin = authorId in admins || role == "admin" || role == "owner", + ) + val quote = quote(event, chat, index) + index?.recordQuote(chat, event.id, quote?.target?.id) + return IncomingMessage.builder( + ref = ChannelMessageRef(chat, event.id), + sender = sender, + chatInfo = ChatInfo(if (group) ChatKind.GROUP else ChatKind.DIRECT, null, null), + receivedAt = instant(event.timestamp), + forwarded = null, + ) + .text(text(event)) + .media(attachments(event, media)) + .quote(quote) + .facts(facts(event)) + .build() + } + + /** + * The text of [event] without the mention a full-mode event kept at its start. + * + * A `GROUP_AT_MESSAGE_CREATE` comes with the mention already stripped; a `GROUP_MESSAGE_CREATE` of the full + * mode may still carry `<@…>` in front of the words, which is not part of what was said. + */ + fun text(event: QqBotMessageEvent): String = event.content.replace(LEADING_MENTION, "") + + /** The media of [event], each with what reads the bytes its attachment names. */ + private fun attachments(event: QqBotMessageEvent, media: QqBotMedia): List = + event.attachments.map { attachment -> mediaOf(attachment, media) } + + /** The media of [attachment], with the kind its content type names. */ + private fun mediaOf(attachment: QqBotAttachment, media: QqBotMedia): MediaAttachment { + val contentType = attachment.contentType + val kind = when { + contentType == "voice" -> MediaKind.AUDIO + contentType == "file" -> MediaKind.FILE + contentType?.startsWith("image/") == true -> MediaKind.IMAGE + contentType?.startsWith("audio/") == true -> MediaKind.AUDIO + contentType?.startsWith("video/") == true -> MediaKind.VIDEO + else -> MediaKind.FILE + } + val mediaType = when { + contentType == null || contentType == "voice" || contentType == "file" -> "application/octet-stream" + else -> contentType + } + val url = attachment.voiceWavUrl ?: attachment.url + val builder = MediaAttachment.builder(kind, mediaType, media.content(url.orEmpty())) + .size(attachment.size) + .width(attachment.width) + .height(attachment.height) + .name(attachment.filename) + if (url == null) builder.unavailable("the platform named no address for it") + return builder.build() + } + + /** What a message of several quoted ones says, which the platform nests as elements. */ + private fun quote(event: QqBotMessageEvent, chat: ChatAddress, index: QqBotMessageIndex?): Quote? { + if (event.msgElements.isEmpty()) return null + val text = elementsText(event.msgElements) + val reference = event.messageScene?.ext("ref_msg_idx") ?: event.msgElements.firstOrNull()?.msgIdx + val target = index?.resolve(chat, reference)?.let { ChannelMessageRef(chat, it) } + val builder = Quote.builder(text).trust(QuoteTrust.UNTRUSTED) + if (target != null) builder.target(target) + return builder.build() + } + + /** The text of [elements] and what they nest, a blank line between two of them. */ + private fun elementsText(elements: List): String = elements.joinToString("\n\n") { element -> + val own = element.content.orEmpty() + val nested = elementsText(element.msgElements) + listOf(own, nested).filter { it.isNotBlank() }.joinToString("\n") + } + + /** What the agent is shown of [event] besides its text and media. */ + private fun facts(event: QqBotMessageEvent): List = buildList { + event.arkData?.let { card -> + val label = card.arkName ?: card.arkType + val value = listOfNotNull(label, card.prompt).joinToString(": ").ifBlank { "a card" } + add(DisplayFact("card", value)) + } + if (event.mentions.isNotEmpty()) { + val names = event.mentions.mapNotNull { it.username?.takeIf(String::isNotBlank) } + add(DisplayFact("mentions", if (names.isEmpty()) "${event.mentions.size}" else names.joinToString(", "))) + } + for (attachment in event.attachments) { + add(DisplayFact("attachment", attachment.filename ?: attachment.contentType ?: "unnamed")) + } + } + + /** When the platform received the message, now when it named no time this version can read. */ + private fun instant(text: String?): Instant = runCatching { + if (text == null) return@runCatching Instant.now() + OffsetDateTime.parse(text).toInstant() + }.getOrElse { Instant.now() } +} From c7635783d13fd64e5972e9cd9157d61e9e4720aa Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 01:37:42 +0800 Subject: [PATCH 11/37] qqbot: Send replies, Markdown and streamed answers Adds the reply sink and sender for passive C2C and group replies, single-chat streaming with append chunks, Markdown adaptation, typing state, message ids for quotes, and the words hook for non-streaming chats. --- .../channel/qqbot/channel/QqBotChannel.kt | 310 ++++++++++++ .../qqbot/channel/QqBotMessageIndex.kt | 43 ++ .../channel/qqbot/channel/QqBotProgress.kt | 62 +++ .../channel/qqbot/channel/QqBotPromptHook.kt | 44 ++ .../channel/qqbot/channel/QqBotSender.kt | 454 ++++++++++++++++++ .../qqbot/channel/QqBotSentMessages.kt | 30 ++ .../channel/qqbot/channel/QqBotStream.kt | 105 ++++ .../channel/qqbot/channel/QqBotTyping.kt | 93 ++++ .../channel/qqbot/channel/QqBotWords.kt | 58 +++ 9 files changed, 1199 insertions(+) create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotChannel.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMessageIndex.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgress.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotPromptHook.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSender.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSentMessages.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotStream.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotTyping.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotWords.kt diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotChannel.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotChannel.kt new file mode 100644 index 00000000..604f5b12 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotChannel.kt @@ -0,0 +1,310 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotChats +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalAnswer +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalRequest +import org.foedusprogramme.alexandrite.sdk.channel.Channel +import org.foedusprogramme.alexandrite.sdk.channel.ChannelCapabilities +import org.foedusprogramme.alexandrite.sdk.channel.DelegatedRun +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.Markup +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.channel.ReplyActivity +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.channel.ToolCallStatus +import org.foedusprogramme.alexandrite.sdk.channel.TurnEvent +import org.foedusprogramme.alexandrite.sdk.channel.TurnEventKind +import org.foedusprogramme.alexandrite.sdk.channel.rebuild +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.chat.RunId +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.transcript.AssistantEntry +import org.foedusprogramme.alexandrite.sdk.transcript.TextPart +import org.slf4j.LoggerFactory + +/** + * The QQ official bot front end of one channel instance. + * + * A message the platform pushes becomes an [org.foedusprogramme.alexandrite.sdk.channel.IncomingMessage] and goes to + * the agent through the submitter, and what the agent answers goes back through [QqBotSender]. A message in a group + * cannot be changed after it is sent, so a reply there is one message of its own; a single chat serves the platform's + * streaming endpoint, so the growing answer is shown in one message that later pieces replace. + */ +@ChannelInstanceScoped +@Contribute(Channel::class, name = "qqbot") +internal class QqBotChannel( + private val sender: QqBotSender, + private val approvals: QqBotApprovals, + private val typing: QqBotTyping, + private val progress: QqBotProgress, + private val replies: QqBotReplyRegistry, + private val rules: QqBotCommandRules, + private val config: QqBotInstanceConfig, +) : Channel { + override suspend fun capabilities(chat: ChatAddress): ChannelCapabilities = + if (config.streaming && QqBotChats.kind(chat) == ChatKind.DIRECT) { + STREAMING_CAPABILITIES + } else { + CAPABILITIES + } + override suspend fun askApproval(request: ApprovalRequest): ApprovalAnswer = approvals.ask(request) + + override fun mayRun(chat: ChatAddress, issuer: ChatUser, command: String, withArguments: Boolean): Boolean = + rules.mayRun(chat, issuer.isAdmin, command, withArguments) + + override suspend fun partsNeeded(chat: ChatAddress, text: String, markup: Markup): Int = + if (text.isEmpty()) 0 else 1 + + override suspend fun openReply(request: ReplyRequest): ReplySink { + typing.start(request.turn.chat) + return Sink(request).also { replies.open(request.turn.id, it) } + } + + override suspend fun send(chat: ChatAddress, message: OutboundMessage): Delivery = sender.send(chat, message) + + /** The reply of one turn, which streams in a single chat and sends one message in a group. */ + private inner class Sink(private val request: ReplyRequest) : + ReplySink, + QqBotWords { + /** + * The one streaming message of this reply, which every segment replaces. + * + * The platform cannot delete a streaming message, so a segment that started its own message would leave the + * previous segment's text behind as a stray message. Replacing the same message across segments keeps the + * conversation to what the answer says. + */ + private var stream: QqBotStream? = null + + /** The tool calls seen so far, which the streaming message shows until the answer replaces them. */ + private val activities = LinkedHashMap() + + /** What the model has said so far, apart from the tool lines the stream shows before it. */ + private var answer: String = "" + + /** The tool lines of a non-streaming chat that wait for their next notice. */ + private val waiting = ArrayList() + + /** When the last notice of this reply was sent, and whether one was sent. */ + private var noticedAt: Long = 0 + private var noticed: Boolean = false + + /** The background runs whose activity this reply still shows after its own answer. */ + private val detached = LinkedHashMap() + + /** The answer this reply sent while a background run still writes to its stream. */ + private var parentFinal: String? = null + + private val streaming = config.streaming && QqBotChats.kind(request.turn.chat) == ChatKind.DIRECT && + request.trigger != null + + override suspend fun detachActivity(run: DelegatedRun): ReplyActivity? { + if (progress.isDeferred(request.turn.id) || run.call.value !in activities) return null + return Detached(run.run).also { detached[run.run] = it } + } + + /** The activity of a background run, shown after the reply that delegated it ended. */ + private inner class Detached(private val run: RunId) : ReplyActivity { + private var closed = false + + override suspend fun event(event: TurnEvent) { + if (closed) return + val started = event as? TurnEvent.ToolCallStarted ?: return + if (started.run != run) return + val line = activityLine(started) + val current = stream + if (streaming && current != null && !progress.isDeferred(request.turn.id)) { + if (!current.append("\n\n" + line)) { + sender.send( + request.turn.chat, + OutboundMessage.builder(line, MessageKind.NOTICE).build(), + ) + } + } else { + sender.send( + request.turn.chat, + OutboundMessage.builder(line, MessageKind.NOTICE).build(), + ) + } + } + + override suspend fun close(status: ToolCallStatus) { + if (closed) return + closed = true + detached.remove(run) + if (detached.isEmpty() && parentFinal != null) { + stream?.finishAppended() + parentFinal = null + } + } + } + + override suspend fun preview(segment: Int, text: String) { + if (!streaming) { + logger.debug( + "The preview of segment {} of {} is not sent: a group message of this platform is not changed", + segment, + request.turn.id, + ) + return + } + if (text.isEmpty() || progress.isDeferred(request.turn.id)) return + val trigger = request.trigger ?: return + val current = streamOf(trigger) + val chunk = next(answer, text) + answer = text + if (chunk.isNotEmpty()) current.append(chunk) + } + + override suspend fun startSegment(segment: Int) { + // The platform has one streaming message per reply, not one per segment; the next preview replaces it. + } + + /** Sends the words of a model response that calls a tool, which a non-streaming chat would otherwise lose. */ + override suspend fun showResponse(message: AssistantEntry) { + if (streaming) return + val said = message.parts.filterIsInstance().joinToString("\n\n") { it.text } + if (said.isBlank()) return + sender.send( + request.turn.chat, + OutboundMessage.builder(said, MessageKind.NOTICE).markup(Markup.MARKDOWN).build(), + ) + } + + override suspend fun event(event: TurnEvent) { + if (!config.progress || progress.isDeferred(request.turn.id)) return + val started = event as? TurnEvent.ToolCallStarted ?: return + activities[started.id.value] = activityLine(started) + if (streaming) { + val trigger = request.trigger ?: return + val current = streamOf(trigger) + val line = if (current.shownText.isEmpty()) activityLine(started) else "\n\n" + activityLine(started) + current.append(line) + } else { + waiting += activityLine(started) + val now = System.currentTimeMillis() + if (!noticed || now - noticedAt >= NOTICE_INTERVAL_MS) tellWaiting() + } + } + + override suspend fun complete(message: OutboundMessage): Delivery = try { + if (!streaming) tellWaiting() + if (streaming && !progress.isDeferred(request.turn.id)) { + val trigger = request.trigger + if (trigger != null) { + val current = streamOf(trigger) + val chunk = next(answer, message.text) + if (chunk.isNotEmpty()) current.append(chunk) + if (detached.isNotEmpty()) { + parentFinal = message.text + val id = current.messageId + if (id != null) return Delivery.Delivered(listOf(ChannelMessageRef(request.turn.chat, id))) + } else { + val finished = current.finishAppended() + if (finished != null) return finished + } + } + } + sender.send(request.turn.chat, withTrigger(message)) + } finally { + replies.close(request.turn.id) + progress.done(request.turn.id) + typing.stop(request.turn.chat) + } + + override suspend fun abandon(end: ReplyEnd) { + logger.info("The reply of {} was abandoned: {}", request.turn.id, end) + replies.close(request.turn.id) + progress.abandon(request.turn.id) + detached.clear() + parentFinal = null + stream?.abandon() + typing.stop(request.turn.chat) + } + + /** Sends the tool lines a non-streaming chat has not been told yet, as one notice. */ + private suspend fun tellWaiting() { + if (waiting.isEmpty()) return + val lines = waiting.toList() + waiting.clear() + noticed = true + noticedAt = System.currentTimeMillis() + sender.send( + request.turn.chat, + OutboundMessage.builder(lines.joinToString("\n"), MessageKind.NOTICE).build(), + ) + } + + /** What [text] adds to [previous]: its suffix, or a new paragraph when it does not continue it. */ + private fun next(previous: String, text: String): String = when { + text.isEmpty() -> "" + previous.isEmpty() -> text + text.startsWith(previous) -> text.removePrefix(previous) + else -> "\n\n" + text + } + + /** The streaming message of this reply, which [trigger] answers, registered for its background jobs. */ + private suspend fun streamOf(trigger: ChannelMessageRef): QqBotStream { + stream?.let { return it } + val created = QqBotStream(sender, request.turn.chat, trigger.id, sender.sequenceFor(trigger.id)) + stream = created + progress.attach(request.turn.id, request.turn.chat, created) + return created + } + + /** One line of the activity message, which the answer replaces when it arrives. */ + private fun activityLine(event: TurnEvent.ToolCallStarted): String { + val title = event.title.takeIf { it.isNotBlank() && it != event.tool } + val line = if (title == null) "⏳ ${event.tool}" else "⏳ ${event.tool} — $title" + return event.agent?.let { "⏳ $it · ${line.removePrefix("⏳ ")}" } ?: line + } + + /** [message] as an answer to the message the turn was started by, where it answers nothing yet. */ + private fun withTrigger(message: OutboundMessage): OutboundMessage { + val trigger = request.trigger ?: return message + if (message.replyTo != null) return message + return message.rebuild { replyTo(trigger) } + } + } + + private companion object { + val logger = LoggerFactory.getLogger(QqBotChannel::class.java) + + val TOOL_EVENTS: Set = + setOf(TurnEventKind.TOOL_CALL_STARTED, TurnEventKind.TOOL_CALL_UPDATED) + + /** How long tool lines of a non-streaming chat wait before they are told again; the first is told at once. */ + const val NOTICE_INTERVAL_MS: Long = 8_000L + + /** What this channel can do in a group: text and markdown, no streaming, no files and no live events. */ + val CAPABILITIES: ChannelCapabilities = ChannelCapabilities.builder() + .markups(setOf(Markup.PLAIN, Markup.MARKDOWN)) + .streaming(false) + .finalReplacesPreview(false) + .proactive(true) + .events(TOOL_EVENTS) + .files(true) + .maxFileBytes(QQ_BOT_MAX_FILE_BYTES) + .maxPartsPerReply(null) + .build() + + /** What this channel can do in a single chat, where the platform streams a message while it is written. */ + val STREAMING_CAPABILITIES: ChannelCapabilities = ChannelCapabilities.builder() + .markups(setOf(Markup.PLAIN, Markup.MARKDOWN)) + .streaming(true) + .finalReplacesPreview(true) + .proactive(true) + .events(TOOL_EVENTS) + .files(true) + .maxFileBytes(QQ_BOT_MAX_FILE_BYTES) + .maxPartsPerReply(null) + .build() + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMessageIndex.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMessageIndex.kt new file mode 100644 index 00000000..b1e7a5e6 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMessageIndex.kt @@ -0,0 +1,43 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import java.util.concurrent.ConcurrentHashMap + +/** + * The message ids behind the reference indexes the platform uses for quotes. + * + * A quote event names the quoted message by a `msg_idx` or `ref_msg_idx`, not by the message id the recall endpoint + * takes. This keeps both the indexes of the messages the bot received and the `ref_idx` of the messages it sent, so a + * quoted message can be resolved to the real id. + */ +@Singleton +internal class QqBotMessageIndex { + private val ids = ConcurrentHashMap() + private val references = ConcurrentHashMap() + private val quotes = ConcurrentHashMap() + + private data class Key(val chat: ChatAddress, val reference: String) + + /** Notes that the message [id] of [chat] is named [reference]. */ + fun record(chat: ChatAddress, reference: String?, id: String) { + if (reference.isNullOrBlank() || id.isBlank()) return + ids[Key(chat, reference)] = id + references[Key(chat, id)] = reference + } + + /** Notes that the message [trigger] of [chat] quotes the message [target]. */ + fun recordQuote(chat: ChatAddress, trigger: String?, target: String?) { + if (trigger.isNullOrBlank() || target.isNullOrBlank()) return + quotes[Key(chat, trigger)] = target + } + + /** The message id [reference] names in [chat], null when it was not seen. */ + fun resolve(chat: ChatAddress, reference: String?): String? = reference?.let { ids[Key(chat, it)] } + + /** The reference that names the message [id] of [chat], null when it was not seen. */ + fun referenceOf(chat: ChatAddress, id: String?): String? = id?.let { references[Key(chat, it)] } + + /** The message id that the message [trigger] of [chat] quotes, null when it quotes none this process saw. */ + fun quotedTarget(chat: ChatAddress, trigger: String?): String? = trigger?.let { quotes[Key(chat, it)] } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgress.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgress.kt new file mode 100644 index 00000000..f6fc10c4 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgress.kt @@ -0,0 +1,62 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.TurnId +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import java.util.concurrent.ConcurrentHashMap + +/** + * The open streaming message of each qqbot turn, which an approval closes before the reply continues. + * + * A reply's stream belongs to the turn that opened it. Once an approval message is shown, the reply's final answer + * cannot be moved below it; this registry lets the approval desk close the stream and remember that the answer is to + * be sent as a message of its own. + */ +@Singleton +internal class QqBotProgress { + private class Open(val chat: ChatAddress, val stream: QqBotStream) + + private val open = ConcurrentHashMap() + private val gates = ConcurrentHashMap() + private val deferred = ConcurrentHashMap.newKeySet() + + private fun gate(turn: TurnId): Mutex = gates.computeIfAbsent(turn) { Mutex() } + + /** Makes [stream] the streaming message of [turn]. */ + suspend fun attach(turn: TurnId, chat: ChatAddress, stream: QqBotStream) { + gate(turn).withLock { + if (turn in deferred) return@withLock + open.getOrPut(turn) { Open(chat, stream) } + } + } + + /** + * Closes the stream of [turn] before an approval message, whose answer then arrives as an ordinary message after it. + * + * A streaming message cannot be moved below the approval request once more of it is written, so the turn's final + * answer is sent as a message of its own instead. + */ + suspend fun defer(turn: TurnId) { + gate(turn).withLock { + open.remove(turn)?.let { entry -> entry.stream.finish(entry.stream.shownText) } + deferred += turn + } + } + + /** Whether the final answer of [turn] is to be sent outside its stream, after an approval request. */ + fun isDeferred(turn: TurnId): Boolean = turn in deferred + + /** Done with [turn], whose deferred answer was sent or whose stream is its own from here. */ + fun done(turn: TurnId) { + deferred -= turn + open.remove(turn) + } + + /** Forgets [turn], which ended without a stream to close. */ + suspend fun abandon(turn: TurnId) { + deferred -= turn + gate(turn).withLock { open.remove(turn) } + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotPromptHook.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotPromptHook.kt new file mode 100644 index 00000000..b0db7c5f --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotPromptHook.kt @@ -0,0 +1,44 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.hook.Hook +import org.foedusprogramme.alexandrite.sdk.hook.HookDecision +import org.foedusprogramme.alexandrite.sdk.hook.InterceptorHook +import org.foedusprogramme.alexandrite.sdk.hook.InterceptorPoint +import org.foedusprogramme.alexandrite.sdk.model.PromptSection +import org.foedusprogramme.alexandrite.sdk.turn.PromptSections +import org.foedusprogramme.alexandrite.sdk.turn.TurnPoints + +/** + * Tells the model what the QQ client renders, so it writes what the channel can show. + * + * The adapter of the channel still turns what the client only shows as raw text into a form it renders; this section + * only makes the model less likely to write it in the first place. A turn of another channel is left alone. + */ +@Singleton +@Contribute(Hook::class) +internal class QqBotPromptHook : InterceptorHook { + override val point: InterceptorPoint get() = TurnPoints.PROMPT_SECTIONS + + override suspend fun intercept(payload: PromptSections): HookDecision { + if (payload.turn.chat.instance.type != QQ_BOT) return HookDecision.Continue + if (payload.sections.any { it.id == SECTION_ID }) return HookDecision.Continue + val section = PromptSection(SECTION_ID, PROMPT, stable = true) + return HookDecision.Replace(payload.withSections(payload.sections + section)) + } + + private companion object { + val QQ_BOT = ChannelType("qqbot") + + const val SECTION_ID = "qqbot.platform" + + const val PROMPT = "This chat is a QQ official bot. Write the Markdown the QQ client renders: headings, " + + "paragraphs, emphasis, lists, blockquotes, fenced code, GFM tables, task lists and links. Do not use " + + "HTML blocks such as
, , or , Mermaid diagrams, [TOC], ==highlight== or " + + "definition lists; the client shows those as raw text. An image needs a public http(s) URL; a local path " + + "is not shown. In a group, do not make a link the only way to say something: the platform may refuse a " + + "message holding a URL. A command starts with a slash, such as /model." + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSender.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSender.kt new file mode 100644 index 00000000..b95ad5ec --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSender.kt @@ -0,0 +1,454 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.addJsonObject +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.qqbot.mapping.QqBotChats +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotMarkdown +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFailure +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotMessageType +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotSent +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.asTextOrNull +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.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.slf4j.LoggerFactory + +/** + * What this instance sends, as the open platform takes it. + * + * A message written to a chat as an answer to one the bot was sent names that message as its `msg_id` and a sequence + * number, which is what makes it a passive reply the platform takes within the minutes it allows. A message written + * with nothing to answer is an active one, which the platform limits by the chat and by the bot: the limits are its + * own and the channel sends nothing beyond what it is told. + * + * The sequence number of one message a chat is answered counts up: the same pair of a message and a sequence is sent + * once, and a second answer to the same message sends the next number. A reply the platform no longer takes because + * the minutes passed is sent once more as an active message, so a slow answer still reaches the chat. + */ +@ChannelInstanceScoped +internal class QqBotSender( + private val api: QqBotApi, + private val uploads: QqBotUploads, + private val sent: QqBotSentMessages, + private val index: QqBotMessageIndex, +) { + private val sequences = Seqs() + + /** + * Sends [message] to [chat]. + * + * The platform has no caption on a media message: a text and its files are separate messages. A message with files + * uploads all of them first, so a file the platform refuses is known before the chat is told anything, then sends + * the text when it has one and a `msg_type=7` message for each file. The ids of everything it sent are delivered + * together. + */ + suspend fun send(chat: ChatAddress, message: OutboundMessage): Delivery { + val path = QqBotChats.sendPath(chat) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + if (message.files.isEmpty()) return sendText(path, chat, message) + message.files.firstOrNull { it.size > QQ_BOT_MAX_FILE_BYTES }?.let { file -> + return Delivery.NotDelivered( + DeliveryFailure.UNKNOWN, + "'${file.name}' holds ${file.size} bytes, more than the $QQ_BOT_MAX_FILE_BYTES bytes a message of " + + "this platform may take.", + ) + } + val uploaded = mutableListOf() + for (file in message.files) { + when (val answer = uploads.upload(chat, file)) { + is QqBotUpload.Failed -> return Delivery.NotDelivered( + DeliveryFailure.UNKNOWN, + "'${file.name}' was not sent: ${answer.reason}", + ) + + is QqBotUpload.Uploaded -> uploaded += answer.fileInfo + } + } + val refs = mutableListOf() + val replyTo = message.replyTo?.id?.takeIf { it.isNotEmpty() } + for (fileInfo in uploaded) { + val delivery = sendMedia(path, chat, fileInfo, replyTo) + if (delivery !is Delivery.Delivered) return delivery + refs += delivery.messages + } + return Delivery.Delivered(refs) + } + + /** Sends [message] as one text or markdown message, [path] being the messages endpoint of [chat]. */ + private suspend fun sendText(path: String, chat: ChatAddress, message: OutboundMessage): Delivery { + val replyTo = message.replyTo?.id?.takeIf { it.isNotEmpty() } + val reference = replyTo?.let { index.referenceOf(chat, it) } + var markdown = message.markup.id == Markup.MARKDOWN.id + var text = if (markdown) QqBotMarkdown.render(message.text) else message.text + var answer = post(path, text, markdown, replyTo, replyTo?.let(sequences::next), reference) + var failure = (answer as? QqBotAnswer.Failed)?.failure + if (failure != null && failure.code in MARKDOWN_REFUSED && markdown) { + logger.info( + "{}: the platform takes no markdown in {} ({}); sending the message as plain text", + PLUGIN, + chat, + failure.code, + ) + markdown = false + text = message.text + answer = post(path, text, markdown, replyTo, replyTo?.let(sequences::next), reference) + failure = (answer as? QqBotAnswer.Failed)?.failure + } + if (failure != null && failure.code in URL_REFUSED) { + logger.info("{}: the platform refuses a message of {} with a URL; sending it without links", PLUGIN, chat) + markdown = false + text = QqBotMarkdown.withoutUrls(message.text) + answer = post(path, text, markdown, replyTo, replyTo?.let(sequences::next), reference) + failure = (answer as? QqBotAnswer.Failed)?.failure + } + if (failure != null && failure.code in PASSIVE_GONE && replyTo != null) { + logger.info( + "{}: the message {} is no longer answered passively; sending as an active message", + PLUGIN, + replyTo, + ) + answer = post(path, text, markdown, msgId = null, msgSeq = null, reference = reference) + } + return delivery(chat, answer) + } + + /** Sends one uploaded file as the platform's media message, answering [replyTo] when it names one. */ + private suspend fun sendMedia(path: String, chat: ChatAddress, fileInfo: String, replyTo: String?): Delivery { + var answer = postMedia(path, fileInfo, replyTo, replyTo?.let(sequences::next)) + var failure = (answer as? QqBotAnswer.Failed)?.failure + if (failure != null && failure.code in PASSIVE_GONE && replyTo != null) { + logger.info( + "{}: the media reply {} is no longer answered passively; sending it as an active message", + PLUGIN, + replyTo, + ) + answer = postMedia(path, fileInfo, msgId = null, msgSeq = null) + failure = (answer as? QqBotAnswer.Failed)?.failure + } + if (failure != null) { + logger.warn( + "{}: the platform took no media message in {}: {} {}", + PLUGIN, + chat, + failure.code, + failure.message, + ) + } + return delivery(chat, answer) + } + + /** Sends the uploaded [fileInfo] as a `msg_type=7` message. */ + private suspend fun postMedia(path: String, fileInfo: String, msgId: String?, msgSeq: Int?): QqBotAnswer { + val body = buildJsonObject { + put("msg_type", QqBotMessageType.MEDIA) + putJsonObject("media") { put("file_info", fileInfo) } + if (msgId != null) { + put("msg_id", msgId) + put("msg_seq", msgSeq ?: 1) + } + } + return api.post(path, body) + } + + /** Sends the approval message [text] of [id] to [chat], with the buttons that answer it. */ + internal suspend fun sendApproval( + chat: ChatAddress, + text: String, + id: String, + allow: String, + deny: String, + always: String? = null, + ): Delivery { + val path = QqBotChats.sendPath(chat) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + var answer = api.post(path, approvalBody(text, id, allow, deny, always, markdown = true)) + var failure = (answer as? QqBotAnswer.Failed)?.failure + // The client shows a keyboard on a markdown message; where this bot has no markdown permission, a text + // message is sent instead, which is at least the request itself. + if (failure != null && failure.code in MARKDOWN_REFUSED) { + logger.info("{}: the platform takes no markdown approval; sending the text without it", PLUGIN) + answer = api.post(path, approvalBody(text, id, allow, deny, always, markdown = false)) + failure = (answer as? QqBotAnswer.Failed)?.failure + } + if (failure != null) { + logger.warn("{}: the platform took no approval in {}: {} {}", PLUGIN, chat, failure.code, failure.message) + } + return delivery(chat, answer) + } + + /** The body of an approval message, with the keyboard that answers it. */ + private fun approvalBody( + text: String, + id: String, + allow: String, + deny: String, + always: String?, + markdown: Boolean, + ): JsonObject = buildJsonObject { + if (markdown) { + put("msg_type", QqBotMessageType.MARKDOWN) + putJsonObject("markdown") { put("content", text.trim()) } + } else { + put("msg_type", QqBotMessageType.TEXT) + put("content", text) + } + putJsonObject("keyboard") { + putJsonObject("content") { + putJsonArray("rows") { + addJsonObject { + putJsonArray("buttons") { + add(approvalButton("allow", allow, "/approve $id")) + add(approvalButton("deny", deny, "/deny $id")) + } + } + if (always != null) { + addJsonObject { + putJsonArray("buttons") { + add(approvalButton("always", always.take(10), "/approve $id always")) + } + } + } + } + } + } + } + + /** A button whose press writes [data] as a message, which the platform may send by itself. */ + private fun approvalButton(id: String, label: String, data: String): JsonObject = buildJsonObject { + put("id", id) + putJsonObject("render_data") { + put("label", label) + put("style", if (id == "allow") 4 else 0) + } + putJsonObject("action") { + put("type", 2) + putJsonObject("permission") { put("type", 2) } + put("data", data) + put("enter", true) + put("reply", false) + } + } + + /** Tells [chat] that the bot is writing, where the platform has such a state. */ + internal suspend fun sendTyping(chat: ChatAddress): Delivery { + if (QqBotChats.kind(chat) != org.foedusprogramme.alexandrite.sdk.chat.ChatKind.DIRECT) { + return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance types in no $chat.") + } + val path = QqBotChats.sendPath(chat) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + val body = buildJsonObject { + put("msg_type", QqBotMessageType.TYPING) + putJsonObject("input_notify") { + put("input_type", 1) + put("input_second", TYPING_SECONDS) + } + } + return when (val answer = api.post(path, body)) { + is QqBotAnswer.Ok -> Delivery.Delivered(listOf(ChannelMessageRef(chat, sentId(answer) ?: "typing"))) + + is QqBotAnswer.Failed -> Delivery.NotDelivered( + failureKind(answer.failure, answer.status), + answer.failure.message, + ) + } + } + + /** The sequence number that answers [msgId] next. */ + internal fun sequenceFor(msgId: String): Int = sequences.next(msgId) + + /** + * One piece of the streaming message that answers [msgId]. + * + * The platform serves streaming for single chats only. A piece either starts the message, when [streamId] is null, + * or replaces its content with the whole [text] so far; [last] ends it. + */ + internal suspend fun stream( + chat: ChatAddress, + text: String, + msgId: String, + msgSeq: Int, + streamId: String?, + index: Int, + last: Boolean, + append: Boolean = false, + ): QqBotAnswer { + val openid = QqBotChats.openid(chat) + ?.takeIf { QqBotChats.kind(chat) == org.foedusprogramme.alexandrite.sdk.chat.ChatKind.DIRECT } + ?: return QqBotAnswer.Failed(QqBotFailure(null, "This instance streams no $chat.", null), null) + val body = buildJsonObject { + put("input_mode", if (append) "append" else "replace") + put("input_state", if (last) STREAM_FINISHED else STREAM_GENERATING) + put("index", index) + put("content_type", "markdown") + if (text.isNotEmpty()) put("content_raw", QqBotMarkdown.render(text)) + put("msg_id", msgId) + put("msg_seq", msgSeq) + if (streamId != null) put("stream_msg_id", streamId) + } + val answer = api.post("/v2/users/$openid/stream_messages", body) + if (answer is QqBotAnswer.Failed) { + logger.warn( + "{}: could not stream a piece to {}: {} {}", + PLUGIN, + chat, + answer.failure.code, + answer.failure.message, + ) + } + return answer + } + + /** The id of the message [answer] reports, null when it reports none. */ + internal fun sentId(answer: QqBotAnswer): String? = when (answer) { + is QqBotAnswer.Ok -> answer.body?.let { + runCatching { QqBotWire.json.decodeFromJsonElement(QqBotSent.serializer(), it) }.getOrNull() + }?.id ?: (answer.body as? JsonObject)?.get("id")?.asTextOrNull() + + is QqBotAnswer.Failed -> null + } + + /** The body that sends [text] as a reply to [msgId] with [msgSeq], or as an active message with neither. */ + private fun body(text: String, markdown: Boolean, msgId: String?, msgSeq: Int?, reference: String?): JsonObject = + buildJsonObject { + if (markdown) { + put("msg_type", QqBotMessageType.MARKDOWN) + putJsonObject("markdown") { put("content", text.trim()) } + } else { + put("msg_type", QqBotMessageType.TEXT) + put("content", text) + } + if (msgId != null) { + put("msg_id", msgId) + put("msg_seq", msgSeq ?: 1) + } + if (reference != null) { + putJsonObject("message_reference") { put("message_id", reference) } + } + } + + /** Sends [text] as [markdown] or as plain text, answering [msgId] with [msgSeq] when it names one. */ + private suspend fun post( + path: String, + text: String, + markdown: Boolean, + msgId: String?, + msgSeq: Int?, + reference: String? = null, + ): QqBotAnswer = api.post(path, body(text, markdown, msgId, msgSeq, reference)) + + /** What [answer] means for [chat]. */ + internal fun delivery(chat: ChatAddress, answer: QqBotAnswer): Delivery = when (answer) { + is QqBotAnswer.Ok -> { + val parsed = answer.body?.let { + runCatching { QqBotWire.json.decodeFromJsonElement(QqBotSent.serializer(), it) }.getOrNull() + } + val id = parsed?.id ?: (answer.body as? JsonObject)?.get("id")?.asTextOrNull() ?: "sent" + logger.info("{}: sent a message to {} as {}", PLUGIN, chat, id) + this.sent.record(chat, id) + index.record(chat, parsed?.extInfo?.refIdx, id) + Delivery.Delivered(listOf(ChannelMessageRef(chat, id))) + } + + is QqBotAnswer.Failed -> { + val kind = failureKind(answer.failure, answer.status) + val detail = answer.failure.message.ifBlank { "the platform gave no reason" } + logger.warn( + "{}: could not send to {}: {} {} {}", + PLUGIN, + chat, + kind, + answer.failure.code, + detail, + ) + Delivery.NotDelivered( + kind, + "The platform did not take the message: $detail" + + (answer.failure.code?.let { " (code $it)" } ?: ""), + ) + } + } + + /** What a failure of the platform means for a caller that may try again. */ + private fun failureKind(failure: QqBotFailure, status: Int?): DeliveryFailure { + val code = failure.code + return when { + status == 429 || code in RATE_LIMITED_CODES -> DeliveryFailure.RATE_LIMITED + code in TOO_LONG_CODES -> DeliveryFailure.TOO_LONG + status == 401 || status == 403 || code in FORBIDDEN_CODES -> DeliveryFailure.FORBIDDEN + status != null && status >= 500 -> DeliveryFailure.TRANSIENT + else -> DeliveryFailure.UNKNOWN + } + } + + /** The sequence numbers of the messages this channel answered, oldest forgotten first. */ + private class Seqs { + private val byMessage = object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > KEPT + } + + /** The sequence number of the next reply to [id]. */ + fun next(id: String): Int = synchronized(byMessage) { + val next = (byMessage[id] ?: 0) + 1 + byMessage[id] = next + next + } + + private companion object { + /** How many messages a sequence is kept for. */ + const val KEPT = 512 + } + } + + private companion object { + const val PLUGIN = "alexandrite-channel-qqbot" + + /** The codes with which the platform says the minutes or replies of a passive answer ran out. */ + val PASSIVE_GONE = setOf(304103, 40034005, 40034128) + + /** The codes with which the platform says it takes no markdown of this bot, which plain text may pass. */ + val MARKDOWN_REFUSED = setOf(304036, 40034008, 40034009, 40034011, 40034124, 40034127) + + /** The codes with which the platform says it refuses a message holding a URL. */ + val URL_REFUSED = setOf(40054010) + + /** What the streaming endpoint calls a message still being written, and the call that ends it. */ + const val STREAM_GENERATING = 1 + const val STREAM_FINISHED = 10 + + /** How long one typing notification lasts, as the platform counts it. */ + const val TYPING_SECONDS = 60 + + /** The codes with which the platform says it takes no more messages as fast as they are sent. */ + val RATE_LIMITED_CODES = setOf(1100100, 1100308, 40034100, 40034101, 40054002) + + val TOO_LONG_CODES = setOf(40054007, 40054018) + + /** The codes with which the platform says this bot may not write here at all. */ + val FORBIDDEN_CODES = setOf( + 11253, + 11254, + 11265, + 304004, + 304036, + 340067, + 40034105, + 40054003, + 40054004, + 40054013, + 40054016, + ) + + val logger = LoggerFactory.getLogger(QqBotSender::class.java) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSentMessages.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSentMessages.kt new file mode 100644 index 00000000..990d6d85 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSentMessages.kt @@ -0,0 +1,30 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import java.util.concurrent.ConcurrentHashMap + +/** + * The last message the bot sent to each chat, by the id the platform reported for it. + * + * The platform recalls a message by the id its send answered with, which is not the id of the message a turn answers. + * Keeping it here lets a recall without an explicit id take back the bot's own last message instead of the user's. + */ +@Singleton +internal class QqBotSentMessages { + private val last = ConcurrentHashMap() + + /** Notes that the bot sent [id] to [chat], where the platform reported a real id. */ + fun record(chat: ChatAddress, id: String) { + if (id.isBlank() || id == "sent" || id == "typing") return + last[chat] = id + } + + /** The id of the last message the bot sent to [chat], null when it sent none this process knows. */ + fun lastOf(chat: ChatAddress): String? = last[chat] + + /** Forgets the last message of [chat]. */ + fun forget(chat: ChatAddress) { + last.remove(chat) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotStream.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotStream.kt new file mode 100644 index 00000000..96e928b0 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotStream.kt @@ -0,0 +1,105 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress + +/** + * One streaming message of a single chat, which the platform lets a reply grow in place. + * + * The first piece starts the message and gives the id the later pieces name; every piece replaces the shown text with + * the whole text so far, and the last one ends the message. A platform that takes no streaming, or a piece it refuses, + * leaves the reply to the ordinary message path: the parts are then not shown, and only the final message is sent. + */ +internal class QqBotStream( + private val sender: QqBotSender, + private val chat: ChatAddress, + private val msgId: String, + private val msgSeq: Int, +) { + private var streamId: String? = null + private var index = 0 + private var shown = "" + private var failed = false + + /** The id of the platform message this stream writes, null before the first piece was taken. */ + val messageId: String? get() = streamId + + /** What the platform has accepted as the message so far. */ + val shownText: String get() = shown + + /** Shows [text], the whole text of the streaming message so far; false once the platform refuses. */ + suspend fun show(text: String): Boolean { + if (failed || text.isEmpty()) return false + val delta = delta(text) + if (delta.isEmpty()) return true + val answer = sender.stream(chat, delta, msgId, msgSeq, streamId, index, last = false, append = true) + if (answer !is QqBotAnswer.Ok) { + failed = true + return false + } + val id = sender.sentId(answer) ?: run { + failed = true + return false + } + streamId = id + shown += delta + index++ + return true + } + + /** Ends the streaming message with [text], the whole answer; null when it was never started or is refused. */ + suspend fun finish(text: String): Delivery? { + if (failed) return null + val id = streamId ?: return null + val delta = delta(text.ifBlank { shown }) + val answer = sender.stream(chat, delta, msgId, msgSeq, id, index, last = true, append = true) + return if (answer is QqBotAnswer.Ok) sender.delivery(chat, answer) else null + } + + /** Appends [text] as the next piece, without comparing it with what was shown. */ + suspend fun append(text: String): Boolean { + if (failed || text.isEmpty()) return false + val answer = sender.stream(chat, text, msgId, msgSeq, streamId, index, last = false, append = true) + if (answer !is QqBotAnswer.Ok) { + failed = true + return false + } + val id = sender.sentId(answer) ?: run { + failed = true + return false + } + streamId = id + shown += text + index++ + return true + } + + /** Ends the streaming message after its pieces were appended. */ + suspend fun finishAppended(): Delivery? { + if (failed) return null + val id = streamId ?: return null + val answer = sender.stream(chat, "", msgId, msgSeq, id, index, last = true, append = true) + return if (answer is QqBotAnswer.Ok) sender.delivery(chat, answer) else null + } + + /** + * The part of [text] the platform does not hold yet. + * + * A stream is appended in pieces: a preview that grows contributes its suffix, and text that does not continue + * what was shown (an answer replacing a tool activity, a rewritten segment) starts a new paragraph. Append mode + * has no prefix restriction, unlike replace, which refused such a body with `40113003`. + */ + private fun delta(text: String): String = when { + shown.isEmpty() -> text + text.startsWith(shown) -> text.removePrefix(shown) + else -> "\n\n" + text + } + + /** Ends a stream that is not carried on, so the chat does not show it as still being written. */ + suspend fun abandon() { + val id = streamId ?: return + if (failed) return + runCatching { sender.stream(chat, shown, msgId, msgSeq, id, index, last = true) } + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotTyping.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotTyping.kt new file mode 100644 index 00000000..a1a8135c --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotTyping.kt @@ -0,0 +1,93 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotChats +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.slf4j.LoggerFactory +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Shows that the bot is writing in each single chat with an open reply, one ticker per chat. + * + * The platform serves the input state for single chats only; a group has none, and no ticker runs there. A tick says + * the state lasts a minute, and the next one renews it while the reply is open. + */ +@ChannelInstanceScoped +internal class QqBotTyping(private val instance: ChannelInstance, private val sender: QqBotSender) : Lifecycle { + private val tickers = HashMap() + private val ticking = SupervisorJob(instance.scope.coroutineContext[Job]) + private val scope = CoroutineScope(instance.scope.coroutineContext + ticking) + + /** Starts typing in [chat] for one more reply. */ + fun start(chat: ChatAddress) { + if (QqBotChats.kind(chat) != ChatKind.DIRECT) return + synchronized(tickers) { + val ticker = tickers.getOrPut(chat, ::Ticker) + ticker.replies++ + if (ticker.job?.isActive != true) { + ticker.job = scope.launch(CoroutineName("$chat typing")) { tick(chat) } + } + } + } + + /** Stops typing in [chat] for one reply, and for good after the last. */ + fun stop(chat: ChatAddress) { + synchronized(tickers) { + val ticker = tickers[chat] ?: return + if (--ticker.replies > 0) return + tickers.remove(chat) + ticker.job?.cancel() + } + } + + override fun onStop() { + ticking.cancel() + synchronized(tickers) { tickers.clear() } + } + + private suspend fun tick(chat: ChatAddress) { + withTimeoutOrNull(LIMIT) { + while (true) { + try { + val delivery = sender.sendTyping(chat) + if (delivery is Delivery.NotDelivered) { + logger.debug("{}: no typing state in {}: {}", instance.id, chat, delivery) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.debug("{}: could not show typing in {}: {}", instance.id, chat, e.toString()) + } + delay(INTERVAL) + } + } + } + + private class Ticker { + var replies = 0 + var job: Job? = null + } + + private companion object { + /** The platform's input state lasts a minute; a tick renews it before it runs out. */ + val INTERVAL = 50.seconds + + /** How long a ticker types at most. */ + val LIMIT = 30.minutes + + val logger = LoggerFactory.getLogger(QqBotTyping::class.java) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotWords.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotWords.kt new file mode 100644 index 00000000..b50badd8 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotWords.kt @@ -0,0 +1,58 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.TurnId +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.hook.Hook +import org.foedusprogramme.alexandrite.sdk.hook.HookDecision +import org.foedusprogramme.alexandrite.sdk.hook.InterceptorHook +import org.foedusprogramme.alexandrite.sdk.hook.InterceptorPoint +import org.foedusprogramme.alexandrite.sdk.transcript.AssistantEntry +import org.foedusprogramme.alexandrite.sdk.transcript.ToolCallPart +import org.foedusprogramme.alexandrite.sdk.turn.ModelReply +import org.foedusprogramme.alexandrite.sdk.turn.TurnPoints +import java.util.concurrent.ConcurrentHashMap + +/** The reply of a turn, as the words of a model's request reach it. */ +internal interface QqBotWords { + /** Shows [message], the response of one request of this turn. */ + suspend fun showResponse(message: AssistantEntry) +} + +/** The replies of the turns of this instance that are open, by turn. */ +@Singleton +internal class QqBotReplyRegistry { + private val open = ConcurrentHashMap() + + /** Remembers [words] as the reply of [turn]. */ + fun open(turn: TurnId, words: QqBotWords) { + open[turn] = words + } + + /** Forgets the reply of [turn]. */ + fun close(turn: TurnId) { + open.remove(turn) + } + + /** The reply of [turn], null when this instance speaks for no such turn. */ + fun of(turn: TurnId): QqBotWords? = open[turn] +} + +/** + * Tells the reply of a turn the words of each of its requests, as OneBot does for a chat whose messages cannot be + * rewritten. A streaming single chat already shows those words in its preview, so this only sends the responses of a + * non-streaming chat. + */ +@Singleton +@Contribute(Hook::class) +internal class QqBotWordsHook(private val replies: QqBotReplyRegistry) : InterceptorHook { + override val point: InterceptorPoint get() = TurnPoints.LLM_RESPONSE + + override suspend fun intercept(payload: ModelReply): HookDecision { + val words = replies.of(payload.turn.id) ?: return HookDecision.Continue + // A response that calls no tool is the answer of the turn, which the message that ends it carries. + if (payload.response.message.parts.none { it is ToolCallPart }) return HookDecision.Continue + words.showResponse(payload.response.message) + return HookDecision.Continue + } +} From 7d2a4929160a5cbc3368c42255fde99ce96ab9cd Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 01:37:45 +0800 Subject: [PATCH 12/37] qqbot: Add approvals, menus and platform tools Asks approvals with Markdown keyboard buttons, publishes command menus and panels, uploads files in the platform's chunks, and adds the qqbot.act tool. --- .../channel/qqbot/channel/QqBotApprovals.kt | 215 ++++++++++++ .../channel/qqbot/channel/QqBotMenuSync.kt | 156 +++++++++ .../channel/qqbot/channel/QqBotUploads.kt | 312 ++++++++++++++++++ .../channel/qqbot/tools/QqBotActionTool.kt | 178 ++++++++++ .../channel/qqbot/tools/QqBotActions.kt | 252 ++++++++++++++ .../channel/qqbot/tools/QqBotApis.kt | 31 ++ 6 files changed, 1144 insertions(+) create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotApprovals.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotUploads.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotActionTool.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotActions.kt create mode 100644 libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotApis.kt diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotApprovals.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotApprovals.kt new file mode 100644 index 00000000..fb7bdc8c --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotApprovals.kt @@ -0,0 +1,215 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.qqbot.i18n.TextKeys +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalAnswer +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalRequest +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +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.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.chat.LanguageTag +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.foedusprogramme.alexandrite.sdk.i18n.Texts +import org.slf4j.LoggerFactory +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.minutes + +/** + * Asks administrators for approval with a message whose buttons carry the answer. + * + * A button is an instruction button: a press writes `/approve ` or `/deny ` as a message, and the platform may + * send it by itself where it supports that. The message comes back through the inbox like any other message, which + * answers the waiting request if the sender is an administrator. A message of this platform cannot be changed after it + * is sent, so the outcome is said in a second message. + */ +@ChannelInstanceScoped +internal class QqBotApprovals( + private val instance: ChannelInstance, + private val config: QqBotInstanceConfig, + private val sender: QqBotSender, + private val progress: QqBotProgress, + private val texts: Texts, +) : Lifecycle { + private val pending = ConcurrentHashMap() + + /** The answer of the first administrator who presses a button of [request], else how the request ended. */ + suspend fun ask(request: ApprovalRequest): ApprovalAnswer { + val waiting = Pending(request) + if (pending.putIfAbsent(request.id, waiting) != null) { + return ApprovalAnswer.Unavailable("another request has the id ${request.id}") + } + try { + val shown = show(waiting) + if (shown.isEmpty()) { + logger.warn("{} could not show approval {} in any chat", instance.id, request.id) + return ApprovalAnswer.Unavailable(text(TextKeys.APPROVAL_UNREACHABLE, request.turn.language)) + } + // The answer comes after the approval message, so the turn's stream ends here rather than showing it above + // the request. + progress.defer(request.turn.id) + logger.info( + "{} asks for approval {} of {} in turn {} in {} chat(s)", + instance.id, + request.id, + request.tool, + request.turn.id, + shown.size, + ) + val answered = withTimeoutOrNull(config.approvalMinutes.minutes) { waiting.ending.await() } + val answer = answered ?: ApprovalAnswer.Expired + waiting.ending.complete(answer) + conclude(waiting, answer) + return answer + } catch (e: CancellationException) { + waiting.ending.complete(ApprovalAnswer.Unavailable("the turn was withdrawn")) + conclude(waiting, ApprovalAnswer.Unavailable("the turn was withdrawn")) + throw e + } finally { + pending.remove(request.id) + } + } + + /** Whether [name] is a command this desk answers. */ + fun answers(name: String): Boolean = name == QqBotApprovalCommands.APPROVE || name == QqBotApprovalCommands.DENY + + /** Takes [name] and [id] that [by] wrote in [chat], returning what to reply, null when nothing should be said. */ + suspend fun answer(name: String, id: String, always: Boolean, by: ChatUser, chat: ChatAddress): String? { + val waiting = pending[id] + ?: return text(TextKeys.APPROVAL_NOTHING_WAITING, null) + if (chat !in waiting.chats) return null + if (!by.isAdmin) return text(TextKeys.APPROVAL_ADMINS_ONLY, waiting.request.turn.language) + val answer = if (name == QqBotApprovalCommands.DENY) { + ApprovalAnswer.Denied(by) + } else { + ApprovalAnswer.Allowed(by, always && waiting.request.always != null) + } + if (!waiting.ending.complete(answer)) return null + logger.info("{}: approval {} of {} answered by {}", instance.id, id, waiting.request.tool, by.address) + return null + } + + override fun onStop() { + pending.values.forEach { it.ending.complete(ApprovalAnswer.Unavailable("the bot stopped")) } + } + + /** Shows the request of [waiting] in each chat it is meant for, returning the chats that took it. */ + private suspend fun show(waiting: Pending): List { + val request = waiting.request + val shown = mutableListOf() + for (chat in recipients(request)) { + val delivery = sender.sendApproval( + chat, + render(request), + request.id, + text(TextKeys.APPROVAL_ALLOW, request.turn.language), + text(TextKeys.APPROVAL_DENY, request.turn.language), + request.always?.let { text(TextKeys.APPROVAL_ALWAYS, request.turn.language, "rule" to it.display) }, + ) + if (delivery is Delivery.Delivered) { + waiting.chats += chat + shown += chat + } else { + logger.warn("{}: approval {} was not shown in {}: {}", instance.id, request.id, chat, delivery) + } + } + return shown + } + + /** The chats [request] is shown in: the chat of the turn, and the administrators' own chats for a member's turn. */ + private fun recipients(request: ApprovalRequest): List { + val chats = mutableListOf(request.turn.chat) + if (!request.turn.actorIsAdmin) { + config.admins.mapTo(chats) { ChatAddress(instance.id, "private:$it") } + } + return chats.distinct() + } + + /** Says [answer] in every chat that showed [waiting]. */ + private suspend fun conclude(waiting: Pending, answer: ApprovalAnswer) { + val request = waiting.request + val said = when (answer) { + is ApprovalAnswer.Allowed -> text(TextKeys.APPROVAL_ALLOWED, request.turn.language, "id" to request.id) + is ApprovalAnswer.Denied -> text(TextKeys.APPROVAL_DENIED, request.turn.language, "id" to request.id) + ApprovalAnswer.Expired -> text(TextKeys.APPROVAL_EXPIRED, request.turn.language, "id" to request.id) + else -> return + } + for (chat in waiting.chats) { + try { + sender.send(chat, OutboundMessage.builder(said, MessageKind.NOTICE).build()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn( + "{}: could not say the outcome of approval {} in {}: {}", + instance.id, + request.id, + chat, + e.message, + ) + } + } + } + + /** The text of [request] shown with its buttons. */ + private fun render(request: ApprovalRequest): String = buildString { + appendLine(text(TextKeys.APPROVAL_TITLE, request.turn.language, "id" to request.id)) + appendLine( + text( + TextKeys.APPROVAL_TOOL, + request.turn.language, + "tool" to request.tool, + "risk" to request.risk.name.lowercase(), + ), + ) + appendLine(text(TextKeys.APPROVAL_REASON, request.turn.language, "reason" to request.reason)) + appendLine(text(TextKeys.APPROVAL_ARGUMENTS, request.turn.language)) + append(request.arguments.take(MAX_ARGUMENTS)) + } + + private fun text(key: String, language: LanguageTag?, vararg args: Pair): String = + texts.text(key, language, *args) + + private class Pending(val request: ApprovalRequest) { + val ending = CompletableDeferred() + + val chats = CopyOnWriteArrayList() + } + + private companion object { + const val MAX_ARGUMENTS = 1_500 + + val logger = LoggerFactory.getLogger(QqBotApprovals::class.java) + } +} + +/** The approval commands a message of this channel may carry. */ +internal object QqBotApprovalCommands { + const val APPROVE = "approve" + + const val DENY = "deny" + + private val PRESS = Regex( + "^/($APPROVE|$DENY)(?:@[A-Za-z0-9_]+)?\\s+([A-Za-z0-9_-]{1,32})(?:\\s+(always))?\\s*$", + RegexOption.IGNORE_CASE, + ) + + /** The command [text] carries, null when it is no approval command. */ + fun of(text: String): Parsed? { + // A group may put the mention of the bot before the data of a button; the command starts after it. + val command = text.trim().replace(LEADING_MENTIONS, "") + val match = PRESS.matchEntire(command) ?: return null + return Parsed(match.groupValues[1].lowercase(), match.groupValues[2], match.groupValues[3].isNotEmpty()) + } + + private val LEADING_MENTIONS = Regex("^(@\\S+\\s+)+") + + class Parsed(val name: String, val id: String, val always: Boolean) +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt new file mode 100644 index 00000000..d81767d2 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt @@ -0,0 +1,156 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.sdk.chat.LanguageTag +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.foedusprogramme.alexandrite.sdk.turn.CommandHandler +import org.foedusprogramme.alexandrite.sdk.turn.CommandSpec +import org.slf4j.LoggerFactory + +/** + * Publishes the bot's slash commands as the single-chat menu and as command panels of the open platform. + * + * The platform calls these configurations the global custom menu (`/v2/menu`, C2C only) and command panels + * (`/v2/panels`, C2C and groups). Both are what a user sees and clicks; a click only fills the command into the input + * box, so the usual command handling still answers it. + */ +@ChannelInstanceScoped +internal class QqBotMenuSync( + private val api: QqBotApi, + private val config: QqBotInstanceConfig, + handlers: List, +) : Lifecycle { + private val entries: List> = + handlers.flatMap { handler -> handler.commands.map { handler to it } }.sortedBy { (_, it) -> it.name } + private val commands: List = entries.map { it.second } + private val language: LanguageTag? = config.syncLanguage?.let(LanguageTag::of) + + override suspend fun onOpen() { + if (!config.syncCommands || commands.isEmpty()) return + putMenu() + putPanel("c2c") + putPanel("group") + } + + private suspend fun putMenu() { + val body = menuBody(commands) + when (val answer = api.put("/v2/menu", body)) { + is QqBotAnswer.Ok -> logger.info("{}: published the menu of {} commands", PLUGIN, commands.size) + + is QqBotAnswer.Failed -> + logger.warn( + "{}: could not publish the menu: {} {}", + PLUGIN, + answer.failure.code, + answer.failure.message, + ) + } + } + + private suspend fun putPanel(scope: String) { + val body = panelBody(scope, entries, language) + val panel = body["panel"] as JsonObject + val existing = panelId(scope) + val answer = if (existing == null) { + api.post("/v2/panels", body) + } else { + api.put("/v2/panels/$existing", buildJsonObject { put("panel", panel) }) + } + when (answer) { + is QqBotAnswer.Ok -> + logger.info("{}: published the {} command panel", PLUGIN, scope) + + is QqBotAnswer.Failed -> + logger.warn( + "{}: could not publish the {} command panel: {} {}", + PLUGIN, + scope, + answer.failure.code, + answer.failure.message, + ) + } + } + + /** The id of the panel this bot published for [scope], null when it has none yet. */ + private suspend fun panelId(scope: String): String? { + val answer = api.get("/v2/panels", mapOf("scope" to scope, "limit" to "50")) as? QqBotAnswer.Ok ?: return null + val records = (answer.body as? JsonObject)?.get("records") as? JsonArray ?: return null + return records.asSequence() + .mapNotNull { it as? JsonObject } + .firstOrNull { ((it["panel"] as? JsonObject)?.get("remark") as? JsonPrimitive)?.content == REMARK } + ?.let { (it["panel_id"] as? JsonPrimitive)?.content } + } + + companion object { + private const val PLUGIN = "alexandrite-channel-qqbot" + + private const val REMARK = "alexandrite commands" + + /** The platform's menu of [commands], a C2C menu of at most ten send-message items. */ + internal fun menuBody(commands: List): JsonObject = buildJsonObject { + put( + "menu", + buildJsonObject { + put( + "items", + buildJsonArray { + for (command in commands.take(10)) { + add( + buildJsonObject { + put("type", JsonPrimitive("send_message")) + put("name", JsonPrimitive(command.name.take(10))) + put("send_message", JsonPrimitive("/${command.name}")) + }, + ) + } + }, + ) + }, + ) + } + + /** The platform's [scope] panel of [entries], with at most twenty command items. */ + internal fun panelBody( + scope: String, + entries: List>, + language: LanguageTag?, + ): JsonObject = buildJsonObject { + put("scope", JsonPrimitive(scope)) + put("target_type", JsonPrimitive("all")) + put( + "panel", + buildJsonObject { + put( + "items", + buildJsonArray { + for ((handler, command) in entries.take(20)) { + val description = runCatching { handler.description(command.name, language) } + .getOrElse { command.description } + .trim() + .ifEmpty { command.description } + add( + buildJsonObject { + put("type", JsonPrimitive("command")) + put("name", JsonPrimitive("/${command.name}".take(14))) + put("desc", JsonPrimitive(description.take(30))) + }, + ) + } + }, + ) + put("remark", JsonPrimitive(REMARK)) + }, + ) + } + + private val logger = LoggerFactory.getLogger(QqBotMenuSync::class.java) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotUploads.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotUploads.kt new file mode 100644 index 00000000..112dc379 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotUploads.kt @@ -0,0 +1,312 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotChats +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.asTextOrNull +import org.foedusprogramme.alexandrite.sdk.channel.OutboundFile +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.security.MessageDigest +import java.time.Duration +import java.util.Base64 + +/** The largest file the platform takes in a message, whatever its type. */ +internal const val QQ_BOT_MAX_FILE_BYTES: Long = 200L * 1024 * 1024 + +/** The answer to uploading one file: the platform's `file_info`, or why it gave none. */ +internal sealed class QqBotUpload { + class Uploaded(val fileInfo: String) : QqBotUpload() + + class Failed(val reason: String, val code: Int?) : QqBotUpload() +} + +/** + * Uploads a file of a message to the platform, which sends it in a later `msg_type=7` message. + * + * The platform takes a file in one of three ways. A small one goes as base64 in the upload call, whose data must stay + * under 5 MiB. A larger one is cut into the blocks the pre-upload call names, each block is PUT to its presigned URL, + * the block is confirmed, and the upload call merges them. Single chats and groups have their own endpoints, and a + * file uploaded for one cannot be sent to the other. + */ +@ChannelInstanceScoped +internal class QqBotUploads(private val api: QqBotApi, private val config: QqBotInstanceConfig) { + private val client: HttpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(config.connectTimeoutMillis)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + + /** Uploads [file] for a message to [chat], or says why the platform did not take it. */ + suspend fun upload(chat: ChatAddress, file: OutboundFile): QqBotUpload { + val kind = QqBotChats.kind(chat) + ?: return QqBotUpload.Failed("This instance serves no $chat.", null) + val openid = QqBotChats.openid(chat) + ?: return QqBotUpload.Failed("This instance serves no $chat.", null) + if (file.size > QQ_BOT_MAX_FILE_BYTES) { + return QqBotUpload.Failed( + "'${file.name}' holds ${file.size} bytes, more than the $QQ_BOT_MAX_FILE_BYTES bytes a message of " + + "this platform may take.", + null, + ) + } + val endpoints = endpoints(kind, openid) + val type = fileType(file) + val first = uploadAs(endpoints, file, type) + // A format the platform will not show can still be sent as an ordinary file, which it takes as it is. + if (first is QqBotUpload.Failed && first.code == UNSUPPORTED_FORMAT && type != FILE) { + return uploadAs(endpoints, file, FILE) + } + return first + } + + /** Uploads [file] as [type], in the call the file's size allows. */ + private suspend fun uploadAs(endpoints: Endpoints, file: OutboundFile, type: Int): QqBotUpload = + if (file.size <= INLINE_LIMIT) { + inline(endpoints.files, type, file) + } else { + chunked(endpoints, type, file) + } + + /** Uploads [file] as base64 in the upload call, which the platform takes for at most [INLINE_LIMIT] bytes. */ + private suspend fun inline(path: String, type: Int, file: OutboundFile): QqBotUpload { + val body = buildJsonObject { + put("file_type", type) + put("file_name", file.name) + put("file_data", Base64.getEncoder().encodeToString(file.bytes())) + put("srv_send_msg", false) + } + return uploaded(api.post(path, body)) + } + + /** Uploads [file] in the blocks the pre-upload call names, then has the platform merge them. */ + private suspend fun chunked(endpoints: Endpoints, type: Int, file: OutboundFile): QqBotUpload { + val bytes = file.bytes() + val prepare = api.post( + endpoints.prepare, + buildJsonObject { + put("file_type", type) + put("file_size", bytes.size.toString()) + put("file_name", file.name) + put("md5", digest("MD5", bytes)) + put("sha1", digest("SHA-1", bytes)) + put("md5_10m", digest("MD5", bytes.copyOf(minOf(bytes.size, MD5_10M_BYTES)))) + }, + ) + val plan = prepare.objectOrNull() ?: return failed(prepare, "the pre-upload answered no plan") + val uploadId = plan.text("upload_id") ?: return QqBotUpload.Failed("The pre-upload named no upload id.", null) + val parts = plan["parts"] as? JsonArray + ?: return QqBotUpload.Failed("The pre-upload named no parts.", null) + if (parts.isEmpty()) return QqBotUpload.Failed("The pre-upload named no parts.", null) + val fallbackBlock = plan.long("block_size") + val ordered = parts.mapNotNull { it as? JsonObject } + .sortedBy { it.int("index") ?: Int.MAX_VALUE } + var offset = 0L + val blocks = mutableListOf() + for (part in ordered) { + val index = part.int("index") ?: return QqBotUpload.Failed("A part named no index.", null) + val url = part.text("presigned_url") ?: return QqBotUpload.Failed("A part named no URL.", null) + val size = part.long("block_size") ?: fallbackBlock + ?: return QqBotUpload.Failed("A part named no block size.", null) + if (offset + size > bytes.size) { + return QqBotUpload.Failed("A part of '${file.name}' reaches past the file.", null) + } + blocks += Part(index, url, offset, offset + size) + offset += size + } + if (offset != bytes.size.toLong()) { + return QqBotUpload.Failed("The blocks of '${file.name}' did not cover the file.", null) + } + val concurrency = (plan["upload_config"] as? JsonObject)?.int("concurrency") + ?.coerceIn(1, blocks.size) + ?: 1 + val outcomes = if (concurrency == 1) { + blocks.map { sendPart(endpoints, uploadId, it, bytes) } + } else { + coroutineScope { + val gate = Semaphore(concurrency) + blocks.map { part -> + async { gate.withPermit { sendPart(endpoints, uploadId, part, bytes) } } + }.awaitAll() + } + } + outcomes.firstOrNull { it is QqBotUpload.Failed }?.let { return it } + return uploaded( + api.post( + endpoints.files, + buildJsonObject { + put("file_type", type) + put("upload_id", uploadId) + put("file_name", file.name) + put("srv_send_msg", false) + }, + ), + ) + } + + /** Sends [part], the bytes of [bytes] it covers, and tells the platform the part is complete. */ + private suspend fun sendPart(endpoints: Endpoints, uploadId: String, part: Part, bytes: ByteArray): QqBotUpload { + val block = bytes.copyOfRange(part.start.toInt(), part.end.toInt()) + when (val put = put(part.url, block)) { + is QqBotUpload.Failed -> return put + is QqBotUpload.Uploaded -> Unit + } + val finish = api.post( + endpoints.partFinish, + buildJsonObject { + put("upload_id", uploadId) + put("part_index", part.index) + put("block_size", (part.end - part.start).toString()) + put("md5", digest("MD5", block)) + }, + ) + if (finish !is QqBotAnswer.Ok) return failed(finish, "the platform did not take part ${part.index}") + return QqBotUpload.Uploaded("") + } + + /** PUTs [block] to the presigned [url], which is the platform's own storage rather than its API. */ + private suspend fun put(url: String, block: ByteArray): QqBotUpload = withContext(Dispatchers.IO) { + try { + val request = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofMillis(config.firstByteTimeoutMillis)) + .PUT(HttpRequest.BodyPublishers.ofByteArray(block)) + .build() + val response = client.send(request, HttpResponse.BodyHandlers.discarding()) + if (response.statusCode() in 200..299) { + QqBotUpload.Uploaded("") + } else { + QqBotUpload.Failed("The platform's storage answered HTTP ${response.statusCode()} for a block.", null) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + QqBotUpload.Failed("The block could not be sent to the platform's storage: $e", null) + } + } + + /** What the platform answered to an upload call, as the file info it gave or the failure it named. */ + private fun uploaded(answer: QqBotAnswer): QqBotUpload = when (answer) { + is QqBotAnswer.Ok -> { + val fileInfo = (answer.body as? JsonObject)?.text("file_info") + if (fileInfo.isNullOrBlank()) { + QqBotUpload.Failed("The platform took the file but named no file_info.", null) + } else { + QqBotUpload.Uploaded(fileInfo) + } + } + + is QqBotAnswer.Failed -> failed(answer, "the platform did not take the file") + } + + /** [answer] as a failure, with the platform's code when it named one. */ + private fun failed(answer: QqBotAnswer, what: String): QqBotUpload.Failed { + val failure = (answer as? QqBotAnswer.Failed)?.failure + val said = failure?.message?.takeIf { it.isNotBlank() } ?: "the platform gave no reason" + return QqBotUpload.Failed("$what: $said" + (failure?.code?.let { " (code $it)" } ?: ""), failure?.code) + } + + /** The endpoints of [kind] for [openid], which the platform keeps separate between chats and groups. */ + private fun endpoints(kind: ChatKind, openid: String): Endpoints = when (kind) { + ChatKind.DIRECT -> Endpoints( + files = "/v2/users/$openid/files", + prepare = "/v2/users/$openid/upload_prepare", + partFinish = "/v2/users/$openid/upload_part_finish", + ) + + ChatKind.GROUP -> Endpoints( + files = "/v2/groups/$openid/files", + prepare = "/v2/groups/$openid/upload_prepare", + partFinish = "/v2/groups/$openid/upload_part_finish", + ) + + else -> error("A chat this channel serves is a direct or a group chat.") + } + + /** + * The platform's `file_type` for [file]: an image, video or voice it shows as such, otherwise a file. + * + * A kind of file the platform shows may be sent as that kind only within its soft limit; past it the platform + * turns the upload into a file, which is the type this names from the start. + */ + private fun fileType(file: OutboundFile): Int { + if (file.asFile) return FILE + val name = file.name.lowercase() + val mediaType = file.mediaType.lowercase() + val type = when { + name.endsWithAny(IMAGE_EXTENSIONS) || mediaType in IMAGE_TYPES -> IMAGE + name.endsWithAny(VIDEO_EXTENSIONS) || mediaType in VIDEO_TYPES -> VIDEO + name.endsWithAny(AUDIO_EXTENSIONS) || mediaType in AUDIO_TYPES -> VOICE + else -> null + } ?: return FILE + val soft = when (type) { + IMAGE -> IMAGE_SOFT_BYTES + VIDEO -> VIDEO_SOFT_BYTES + VOICE -> VOICE_SOFT_BYTES + else -> QQ_BOT_MAX_FILE_BYTES + } + return if (file.size > soft) FILE else type + } + + private fun String.endsWithAny(extensions: Set): Boolean = extensions.any { endsWith(it) } + + private fun digest(name: String, bytes: ByteArray): String = + MessageDigest.getInstance(name).digest(bytes).joinToString("") { "%02x".format(it) } + + private fun QqBotAnswer.objectOrNull(): JsonObject? = (this as? QqBotAnswer.Ok)?.body as? JsonObject + + private fun JsonObject.text(name: String): String? = this[name]?.asTextOrNull()?.takeIf { it.isNotEmpty() } + + private fun JsonObject.long(name: String): Long? = this[name]?.asTextOrNull()?.toLongOrNull() + + private fun JsonObject.int(name: String): Int? = this[name]?.asTextOrNull()?.toIntOrNull() + + private class Endpoints(val files: String, val prepare: String, val partFinish: String) + + /** One block of a file: where it starts in the file, where it ends, and where it is PUT. */ + private class Part(val index: Int, val url: String, val start: Long, val end: Long) + + private companion object { + const val IMAGE = 1 + const val VIDEO = 2 + const val VOICE = 3 + const val FILE = 4 + + /** The platform's code for a format it will not show, which an ordinary file may still pass. */ + const val UNSUPPORTED_FORMAT = 850019 + + /** The largest file the upload call takes as base64 data. */ + const val INLINE_LIMIT: Long = 5L * 1024 * 1024 + + /** The bytes of a file whose MD5 is the `md5_10m` the pre-upload asks for. */ + const val MD5_10M_BYTES = 10_002_432 + + const val IMAGE_SOFT_BYTES = 20L * 1024 * 1024 + const val VIDEO_SOFT_BYTES = 30L * 1024 * 1024 + const val VOICE_SOFT_BYTES = 20L * 1024 * 1024 + + val IMAGE_EXTENSIONS = setOf(".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") + val VIDEO_EXTENSIONS = setOf(".mp4") + val AUDIO_EXTENSIONS = setOf(".silk", ".mp3", ".wav", ".ogg") + + val IMAGE_TYPES = setOf("image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp") + val VIDEO_TYPES = setOf("video/mp4") + val AUDIO_TYPES = setOf("audio/silk", "audio/mpeg", "audio/wav", "audio/ogg") + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotActionTool.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotActionTool.kt new file mode 100644 index 00000000..a852907f --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotActionTool.kt @@ -0,0 +1,178 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.tools + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonArray +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 kotlinx.serialization.json.putJsonObject +import org.foedusprogramme.alexandrite.channel.qqbot.channel.QqBotMessageIndex +import org.foedusprogramme.alexandrite.channel.qqbot.channel.QqBotSentMessages +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.tool.Tool +import org.foedusprogramme.alexandrite.sdk.tool.ToolContext +import org.foedusprogramme.alexandrite.sdk.tool.ToolDefinition +import org.foedusprogramme.alexandrite.sdk.tool.ToolResult +import org.foedusprogramme.alexandrite.sdk.tool.ToolRisk +import kotlin.time.Duration.Companion.minutes + +/** + * Reads what the open platform knows about the group of the turn or the bot itself. + * + * The tool takes the action and its arguments rather than one tool per action, because the platform's surface is a + * table of endpoints. Only the actions of [QqBotActions.reading] can be named; a read that answers makes the turn + * carry content the bot cannot vouch for. + */ +@Singleton +@Contribute(Tool::class) +internal class QqBotReadTool(private val apis: QqBotApis) : Tool { + override val definition: ToolDefinition = ToolDefinition( + name = "qqbot.read", + description = describe("Reads what the QQ open platform knows", QqBotActions.reading), + parameters = schema(), + risk = ToolRisk.READ_ONLY, + ) + + override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult = + call(apis, QqBotActions.reading, arguments, context, null, null) +} + +/** Changes something of the group this bot acts in: its members, their mute, its blacklist, a message. */ +@Singleton +@Contribute(Tool::class) +internal class QqBotActTool( + private val apis: QqBotApis, + private val sent: QqBotSentMessages, + private val index: QqBotMessageIndex, +) : Tool { + override val definition: ToolDefinition = ToolDefinition( + name = "qqbot.act", + description = describe( + "Changes something of a QQ group this bot acts in. A call that ends without an answer may still have " + + "been carried out, so read what that group holds before calling again", + QqBotActions.changing, + ), + parameters = schema(), + risk = ToolRisk.EXTERNAL_ACTION, + timeout = 2.minutes, + ) + + override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult = + call(apis, QqBotActions.changing, arguments, context, sent, index) +} + +/** The arguments every call takes: the action and whatever that action takes. */ +private fun schema(): JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("action") { + put("type", "string") + put("description", "The action to call, named as one of the actions the description lists.") + } + putJsonObject("params") { + put("type", "object") + put("description", "That action's arguments, by the names the description gives them.") + put("additionalProperties", true) + } + } + put("required", JsonArray(listOf(JsonPrimitive("action")))) + put("additionalProperties", false) +} + +/** What the model is told these tools are for: every action and its arguments. */ +private fun describe(header: String, rows: List): String = buildString { + append(header) + append(". It calls one action per call, named as 'action', with that action's arguments under 'params'. ") + append("The group is the chat of the turn: it is never an argument. The actions, with a ? on an argument that ") + append("may be left out:\n") + rows.forEach { row -> + append("- ").append(row.name).append('(') + append(row.parameters.joinToString(", ") { it.name + (if (it.required) "" else "?") }) + append(") — ").append(row.said).append('\n') + row.parameters.forEach { parameter -> + append(" ").append(parameter.name).append(": ").append(parameter.description).append('\n') + } + } +} + +/** Calls [name] of [rows] in the chat of [context], and says what the platform answered. */ +private suspend fun call( + apis: QqBotApis, + rows: List, + arguments: JsonObject, + context: ToolContext, + sent: QqBotSentMessages?, + index: QqBotMessageIndex?, +): ToolResult { + val name = text(arguments, "action") + ?: return ToolResult("A call names an action, as action.", isError = true) + val row = rows.firstOrNull { it.name == name } + ?: return ToolResult( + "'$name' is no action this call may ask for. It may ask for: " + rows.joinToString(", ") { it.name } + ".", + isError = true, + ) + val params = arguments["params"] as? JsonObject ?: JsonObject(emptyMap()) + val missing = row.parameters.filter { it.required && !given(params, it) } + if (missing.isNotEmpty()) { + return ToolResult( + "The action '$name' needs " + missing.joinToString { it.name } + ": " + + missing.joinToString { "${it.name} (${it.description})" }, + isError = true, + ) + } + val chat = context.turn.chat + val api = apis.serving(chat) + ?: return ToolResult( + "The channel instance of $chat is not open, so the platform cannot be asked.", + isError = true, + ) + return withContext(Dispatchers.IO) { + currentCoroutineContext().ensureActive() + val quoted = index?.quotedTarget(chat, context.trigger?.id) + val trigger = when { + name != "recall" -> context.trigger?.id + + text(params, "message_id") != null -> context.trigger?.id + + quoted != null -> quoted + + else -> sent?.lastOf(chat) ?: return@withContext ToolResult( + "The bot knows no message it sent or this turn quotes, so a recall without message_id names nothing.", + isError = true, + ) + } + when (val answer = row.call(api, chat, params, trigger)) { + is QqBotAnswer.Ok -> { + if (!row.changes) context.untrusted("the platform answered what '$name' asked for") + val body = answer.body?.takeIf { it !is JsonNull }?.toString() + ToolResult(if (body == null) row.said else "${row.said}\n$body") + } + + is QqBotAnswer.Failed -> ToolResult( + "The platform answered no to '$name': ${answer.failure.message}" + + (answer.failure.code?.let { " (code $it)" } ?: ""), + isError = true, + ) + } + } +} + +/** Whether the call gave [parameter], as an array for an array argument. */ +private fun given(params: JsonObject, parameter: QqBotActions.Parameter): Boolean { + val value = params[parameter.name] ?: return false + if (value is JsonNull) return false + if (parameter.array) return value is JsonArray + return value is JsonPrimitive && value.contentOrNull?.isNotEmpty() == true +} + +/** The text a call gave for [name], null when it gave none. */ +private fun text(params: JsonObject, name: String): String? = + (params[name] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotEmpty() } diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotActions.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotActions.kt new file mode 100644 index 00000000..6707cedc --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotActions.kt @@ -0,0 +1,252 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.tools + +import kotlinx.serialization.json.JsonArray +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.qqbot.mapping.QqBotChats +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFailure +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind + +/** + * The actions of the open platform this channel offers the agent, each declared once. + * + * The official API is a set of REST endpoints and has no action names; the names here are this repository's own, and a + * call still makes the endpoint the row names. A row says what an action is for and what it takes, so a tool can + * describe the table to the model and refuse an action that is not in it. + */ +internal object QqBotActions { + internal class Parameter( + val name: String, + val description: String, + val required: Boolean = true, + /** Whether the value is an array of strings, which is sent as it arrives. */ + val array: Boolean = false, + ) + + /** + * One action: the endpoint it calls, the arguments it takes, and whether it changes something. + * + * The group id always comes from the chat of the call, never from the model, so a call cannot reach another chat. + */ + internal class Row( + val name: String, + val said: String, + val parameters: List, + val changes: Boolean, + val call: suspend (QqBotApi, ChatAddress, JsonObject, String?) -> QqBotAnswer, + ) + + /** The reads: what the platform knows about a group or the bot. */ + val reading: List = listOf( + Row("bot_info", "The bot was read.", emptyList(), changes = false) { api, _, _, _ -> + api.get("/users/@me") + }, + Row("group_info", "The group was read.", emptyList(), changes = false) { api, chat, _, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group, so it has no group information.") + api.get("/v2/groups/$group/info") + }, + Row("bot_state", "The state of the bot in the group was read.", emptyList(), changes = false) { + api, + chat, + _, + _, + -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + api.get("/v2/groups/$group/bot_state") + }, + Row( + "join_requests", + "The requests to join the group were read.", + listOf( + Parameter("cursor", "The cursor of the page to read, empty for the first page.", required = false), + Parameter("limit", "How many requests one page holds, at most 50.", required = false), + ), + changes = false, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + api.get("/v2/groups/$group/join_request_list", query(params, "cursor", "limit")) + }, + Row("group_mute_state", "The mute state of the group was read.", emptyList(), changes = false) { + api, + chat, + _, + _, + -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + api.get("/v2/groups/$group/restrict_chat_setting") + }, + Row( + "group_members", + "The members of the group were read.", + listOf(Parameter("cursor", "The cursor of the page to read, empty for the first page.", required = false)), + changes = false, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + api.get("/v2/groups/$group/members", query(params, "cursor")) + }, + Row( + "group_member", + "One member of the group was read.", + listOf(Parameter("member_openid", "The openid of the member in this group.")), + changes = false, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + val member = text(params, "member_openid") ?: return@Row refused("A call names member_openid.") + api.get("/v2/groups/$group/members/$member") + }, + Row( + "blacklist", + "The blacklist of the group was read.", + listOf( + Parameter("cursor", "The cursor of the page to read, empty for the first page.", required = false), + Parameter("limit", "How many entries one page holds, at most 100.", required = false), + ), + changes = false, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + api.get("/v2/groups/$group/member_blacklist", query(params, "cursor", "limit")) + }, + ) + + /** The actions that change something. */ + val changing: List = listOf( + Row( + "recall", + "The message was taken back.", + listOf( + Parameter( + "message_id", + "The id of the message to take back; the bot's last message in this chat when left out. A " + + "user's message must always be named explicitly.", + required = false, + ), + ), + changes = true, + ) { api, chat, params, trigger -> + val id = text(params, "message_id") ?: trigger + ?: return@Row refused("A call names message_id when the bot sent no message this process knows.") + val openid = QqBotChats.openid(chat) ?: return@Row refused("This instance serves no $chat.") + when (QqBotChats.kind(chat)) { + ChatKind.GROUP -> api.delete("/v2/groups/$openid/messages/$id") + ChatKind.DIRECT -> api.delete("/v2/users/$openid/messages/$id") + else -> refused("This instance serves no $chat.") + } + }, + Row( + "approve_join_request", + "The request to join the group was answered.", + listOf( + Parameter("member_openid", "The openid of the member who asked to join."), + Parameter("op", "approve to let the member in, decline to refuse."), + Parameter("join_request_id", "The id of the request, from join_requests.", required = false), + Parameter("reject_reason", "Why a request was declined.", required = false), + Parameter("add_to_member_blacklist", "Whether a declined member is blacklisted too.", required = false), + ), + changes = true, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + val member = text(params, "member_openid") ?: return@Row refused("A call names member_openid.") + if (text(params, "op") == null) return@Row refused("A call names op: approve or decline.") + api.post( + "/v2/groups/$group/approval_join_request/$member", + bodyOf(params, "op", "join_request_id", "reject_reason", "add_to_member_blacklist"), + ) + }, + Row( + "set_member_mute", + "The mute of members was changed.", + listOf( + Parameter( + "members", + "The changes, each {op: add|update|del, member_openid, mute_expire_at?}; at most 20.", + array = true, + ), + ), + changes = true, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + val members = params["members"] as? JsonArray ?: return@Row refused("A call names members as an array.") + api.post("/v2/groups/$group/restrict_chat_setting", buildJsonObject { put("members", members) }) + }, + Row( + "remove_members", + "The members were removed from the group.", + listOf( + Parameter("member_openids", "The openids of the members to remove, at most 20.", array = true), + Parameter( + "add_to_member_blacklist", + "Whether the removed members are blacklisted too.", + required = false, + ), + ), + changes = true, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + val ids = params["member_openids"] as? JsonArray ?: return@Row refused("A call names member_openids.") + api.post( + "/v2/groups/$group/batch_remove_members", + bodyOf(params, "add_to_member_blacklist").let { extra -> + buildJsonObject { + put("member_openids", ids) + extra.forEach { (name, value) -> put(name, value) } + } + }, + ) + }, + Row( + "blacklist_action", + "The blacklist of the group was changed.", + listOf( + Parameter("op", "add to put members on the blacklist, del to take them off."), + Parameter("member_openids", "The openids to change, at most 20.", array = true), + ), + changes = true, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + val ids = params["member_openids"] as? JsonArray ?: return@Row refused("A call names member_openids.") + val op = text(params, "op") ?: return@Row refused("A call names op: add or del.") + api.post( + "/v2/groups/$group/member_blacklist", + buildJsonObject { + put("op", op) + put("member_openids", ids) + }, + ) + }, + ) + + /** Every action, in the order the tools describe them. */ + val all: List get() = reading + changing + + /** [params] as the JSON body of the keys of [names] that the call gave and that are not null. */ + private fun bodyOf(params: JsonObject, vararg names: String): JsonObject = buildJsonObject { + for (name in names) { + val value = params[name] + if (value != null && value !is JsonNull) put(name, value) + } + } + + /** The query of the keys of [names] that the call gave. */ + private fun query(params: JsonObject, vararg names: String): Map = buildMap { + for (name in names) text(params, name)?.let { put(name, it) } + } + + /** The text a call gave for [name], null when it gave none. */ + private fun text(params: JsonObject, name: String): String? = + (params[name] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotEmpty() } + + /** The group openid of [chat], null when it is no group. */ + private fun groupOf(chat: ChatAddress): String? = + if (QqBotChats.kind(chat) == ChatKind.GROUP) QqBotChats.openid(chat) else null + + /** A call the platform is not asked about, with why. */ + private fun refused(said: String): QqBotAnswer = QqBotAnswer.Failed(QqBotFailure(null, said, null), null) +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotApis.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotApis.kt new file mode 100644 index 00000000..691d6372 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotApis.kt @@ -0,0 +1,31 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.tools + +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import java.util.concurrent.ConcurrentHashMap + +/** + * The API of each open channel instance, which a tool of the shared tool layer looks up by the chat of its call. + * + * A tool is a singleton and the API of an instance lives in that instance's scope, so the instance registers its API + * while it is open. A call of a chat whose instance is not open reaches nothing, which is the answer the tool gives. + */ +@Singleton +internal class QqBotApis { + private val apis = ConcurrentHashMap() + + /** Makes [api] the API of [instance] while it is open. */ + fun register(instance: ChannelInstanceId, api: QqBotApi) { + apis[instance] = api + } + + /** Forgets the API of [instance]. */ + fun forget(instance: ChannelInstanceId) { + apis.remove(instance) + } + + /** The API that serves [chat], null when its instance is not open. */ + fun serving(chat: ChatAddress): QqBotApi? = apis[chat.instance] +} From 277a138cc19c09bbdfabc5b4507e1c93dbab3cf9 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 01:37:47 +0800 Subject: [PATCH 13/37] qqbot: Add its tests Covers token and gateway behaviour, config, mapping, menus, commands, progress, sending, uploads, tools and a channel end to end. --- .../qqbot/QqBotChannelIntegrationTest.kt | 461 ++++++++++++++++++ .../channel/qqbot/auth/QqBotTokensTest.kt | 60 +++ .../qqbot/channel/QqBotCommandRulesTest.kt | 45 ++ .../qqbot/channel/QqBotMenuSyncTest.kt | 139 ++++++ .../qqbot/channel/QqBotProgressTest.kt | 74 +++ .../qqbot/channel/QqBotPromptHookTest.kt | 39 ++ .../channel/qqbot/channel/QqBotSenderTest.kt | 292 +++++++++++ .../channel/qqbot/channel/QqBotUploadsTest.kt | 119 +++++ .../channel/qqbot/config/QqBotConfigTest.kt | 71 +++ .../channel/qqbot/gateway/QqBotGatewayTest.kt | 65 +++ .../qqbot/mapping/QqBotMarkdownTest.kt | 72 +++ .../qqbot/mapping/QqBotMessagesTest.kt | 162 ++++++ .../channel/qqbot/testing/FakePlatform.kt | 161 ++++++ .../channel/qqbot/testing/TestConfig.kt | 23 + .../channel/qqbot/tools/QqBotToolsTest.kt | 159 ++++++ 15 files changed, 1942 insertions(+) create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/auth/QqBotTokensTest.kt create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRulesTest.kt create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgressTest.kt create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotPromptHookTest.kt create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSenderTest.kt create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotUploadsTest.kt create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/config/QqBotConfigTest.kt create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGatewayTest.kt create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMarkdownTest.kt create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMessagesTest.kt create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/testing/FakePlatform.kt create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/testing/TestConfig.kt create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotToolsTest.kt diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt new file mode 100644 index 00000000..96e9d192 --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt @@ -0,0 +1,461 @@ +package org.foedusprogramme.alexandrite.channel.qqbot + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.foedusprogramme.alexandrite.channel.qqbot.channel.QQ_BOT_MAX_FILE_BYTES +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeGatewayServer +import org.foedusprogramme.alexandrite.sdk.InternalAlexandriteApi +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalAnswer +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalRequest +import org.foedusprogramme.alexandrite.sdk.channel.ChannelDirectory +import org.foedusprogramme.alexandrite.sdk.channel.DelegatedRun +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.Markup +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.channel.ReplyRequest +import org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus +import org.foedusprogramme.alexandrite.sdk.channel.TurnEvent +import org.foedusprogramme.alexandrite.sdk.chat.AgentId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.RunId +import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId +import org.foedusprogramme.alexandrite.sdk.di.key +import org.foedusprogramme.alexandrite.sdk.tool.ToolActivity +import org.foedusprogramme.alexandrite.sdk.tool.ToolRisk +import org.foedusprogramme.alexandrite.sdk.turn.Submission +import org.foedusprogramme.alexandrite.testkit.PluginHarness +import org.foedusprogramme.alexandrite.testkit.RecordingTurnSubmitter +import org.foedusprogramme.alexandrite.testkit.testTurn +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The QQ bot channel inside a real runtime. + * + * A harness builds the plugin set and the container, a fake platform serves the token and the gateway, and the test + * drives what the platform would do: it pushes a message of a single chat, the agent is asked for a turn, and the + * answer of that turn goes back to the fake platform as a reply to the message. + */ +class QqBotChannelIntegrationTest { + private val instance = ChannelInstanceId(ChannelType("qqbot"), "main") + + private val directory = key() + + @Test + fun `a message the platform pushes reaches the agent and its answer goes back as a reply`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(SEND_DIRECT, """{"id":"sent1","timestamp":"2026-07-21T10:00:00+08:00"}""") + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(EVENT) + val message = assertIs(awaitSubmission(submitter)).message + assertEquals("private:u1", message.chat.chat) + assertEquals("hello", message.text) + + val channel = get(directory).channel(instance) ?: error("the instance has no channel") + val sink = channel.openReply(ReplyRequest(testTurn(chat = message.chat), message.ref)) + val answer = OutboundMessage.builder("hello back", MessageKind.REPLY).build() + assertIs(sink.complete(answer)) + + val sent = api.calls(SEND_DIRECT).first { "\"msg_type\":0" in it.body } + assertTrue("\"content\":\"hello back\"" in sent.body, sent.body) + assertTrue("\"msg_id\":\"msg1\"" in sent.body, sent.body) + } + } + } finally { + platform.stop() + api.close() + } + } + + @Test + fun `whoami tells the person the address the instance knows them by`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(SEND_DIRECT, """{"id":"who1"}""") + val platform = FakeGatewayServer().startAndAwait() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(RecordingTurnSubmitter()) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(WHOAMI_EVENT) + val answer = assertNotNull(api.awaitRequest(SEND_DIRECT)) + assertTrue("private:u1" in answer.body, answer.body) + } + } + } finally { + platform.stop() + api.close() + } + } + + @Test + fun `a full-mode group message that names the bot reaches the agent`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(ME_PATH, """{"id":"bot1","username":"YosBot"}""") + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(FULL_MODE_EVENT) + val message = assertIs(awaitSubmission(submitter)).message + + assertEquals("group:g1", message.chat.chat) + assertEquals("hello", message.text) + } + } + } finally { + platform.stop() + api.close() + } + } + + @Test + fun `a full-mode group message names the bot by its member openid when the mention list does not`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(ME_PATH, """{"id":"global-bot-id"}""") + api.on(BOT_STATE_PATH, """{"member_openid":"member-bot-id"}""") + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(FULL_MODE_MENTION_EVENT) + val message = assertIs(awaitSubmission(submitter)).message + + assertEquals("group:g1", message.chat.chat) + assertEquals("hello", message.text) + } + } + } finally { + platform.stop() + api.close() + } + } + + @OptIn(InternalAlexandriteApi::class) + @Test + fun `a reply in a single chat is streamed into one message and ended there`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(STREAM_DIRECT, """{"id":"stream1","timestamp":"2026-07-21T10:00:00+08:00"}""") + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(EVENT) + val message = assertIs(awaitSubmission(submitter)).message + + val channel = get(directory).channel(instance) ?: error("the instance has no channel") + assertTrue(channel.capabilities(message.chat).streaming, "a single chat streams") + assertTrue(channel.capabilities(message.chat).files, "a message may carry files") + assertEquals(QQ_BOT_MAX_FILE_BYTES, channel.capabilities(message.chat).maxFileBytes) + val sink = channel.openReply(ReplyRequest(testTurn(chat = message.chat), message.ref)) + sink.event( + TurnEvent.ToolCallStarted( + ToolCallId("call1"), + "web.search", + ToolActivity.SEARCH, + "搜索资料", + ), + ) + sink.preview(0, "hel") + sink.preview(0, "hello") + val answer = OutboundMessage.builder("hello there", MessageKind.REPLY) + .markup(Markup.MARKDOWN) + .build() + assertIs(sink.complete(answer)) + + val pieces = api.calls(STREAM_DIRECT) + assertEquals(5, pieces.size) + assertTrue("\"content_raw\":\"⏳ web.search — 搜索资料\"" in pieces[0].body, pieces[0].body) + assertTrue("\"input_state\":1" in pieces[0].body, pieces[0].body) + assertTrue("hel" in pieces[1].body, pieces[1].body) + assertTrue("\"stream_msg_id\":\"stream1\"" in pieces[2].body, pieces[2].body) + assertTrue(" there" in pieces[3].body, pieces[3].body) + assertTrue("\"input_state\":10" in pieces[4].body, pieces[4].body) + assertTrue( + api.calls(SEND_DIRECT).none { "\"msg_type\":6" !in it.body }, + "the answer was sent as an ordinary message too", + ) + } + } + } finally { + platform.stop() + api.close() + } + } + + @Test + fun `a reply that starts a new segment keeps one streaming message`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(STREAM_DIRECT, """{"id":"stream1","timestamp":"2026-07-21T10:00:00+08:00"}""") + val platform = FakeGatewayServer().startAndAwait() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(RecordingTurnSubmitter()) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(EVENT) + val channel = get(directory).channel(instance) ?: error("the instance has no channel") + val chat = ChatAddress(instance, "private:u1") + val sink = channel.openReply( + ReplyRequest(testTurn(chat = chat), ChannelMessageRef(chat, "msg1")), + ) + sink.preview(0, "first") + sink.startSegment(1) + sink.preview(1, "second") + val answer = OutboundMessage.builder("answer", MessageKind.REPLY).build() + assertIs(sink.complete(answer)) + + val pieces = api.calls(STREAM_DIRECT) + assertEquals(4, pieces.size) + assertTrue("\"stream_msg_id\":\"stream1\"" in pieces[1].body, pieces[1].body) + assertTrue("\"stream_msg_id\":\"stream1\"" in pieces[2].body, pieces[2].body) + assertTrue("\"input_state\":10" in pieces[3].body, pieces[3].body) + assertTrue(api.calls(SEND_DIRECT).none { "\"msg_type\":6" !in it.body }) + } + } + } finally { + platform.stop() + api.close() + } + } + + @OptIn(InternalAlexandriteApi::class) + @Test + fun `an approval is asked with buttons and answered by a press`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(SEND_DIRECT, """{"id":"sent1","timestamp":"2026-07-21T10:00:00+08:00"}""") + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url, admins = setOf("u1"))) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + val channel = get(directory).channel(instance) ?: error("the instance has no channel") + val chat = ChatAddress(instance, "private:u1") + val request = ApprovalRequest( + "req1", + testTurn(chat = chat), + "command.exec", + ToolRisk.EXEC, + "{}", + null, + "it runs a command", + null, + ) + val waiting = async(start = CoroutineStart.UNDISPATCHED) { channel.askApproval(request) } + val shown = assertNotNull(api.awaitRequest(SEND_DIRECT)) + assertTrue("\"msg_type\":2" in shown.body, shown.body) + assertTrue("\"markdown\"" in shown.body, shown.body) + assertTrue("\"keyboard\"" in shown.body, shown.body) + assertTrue("\"data\":\"/approve req1\"" in shown.body, shown.body) + assertTrue("\"data\":\"/deny req1\"" in shown.body, shown.body) + assertTrue("\"enter\":true" in shown.body, shown.body) + + platform.push(APPROVAL_PRESS) + val answer = waiting.await() + assertIs(answer) + } + } + } finally { + platform.stop() + api.close() + } + } + + @Test + fun `a command reaches the agent as a command rather than as a message`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(COMMAND_EVENT) + val command = assertIs(awaitSubmission(submitter)) + assertEquals("model", command.invocation.name) + assertEquals("deepseek/deepseek-flash", command.invocation.arguments) + } + } + } finally { + platform.stop() + api.close() + } + } + + private suspend fun awaitSubmission(submitter: RecordingTurnSubmitter): Submission { + val deadline = System.nanoTime() + 10_000_000_000L + while (System.nanoTime() < deadline) { + submitter.submissions.firstOrNull()?.let { return it } + delay(20) + } + error("the agent was asked for no turn") + } + + @Test + fun `a detached background run keeps writing to the reply's stream until it closes`() = runBlocking { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(STREAM_DIRECT, """{"id":"stream1","timestamp":"2026-07-21T10:00:00+08:00"}""") + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(EVENT) + val message = assertIs(awaitSubmission(submitter)).message + val channel = get(directory).channel(instance) ?: error("the instance has no channel") + val sink = channel.openReply(ReplyRequest(testTurn(chat = message.chat), message.ref)) + val call = ToolCallId("delegate1") + sink.event(TurnEvent.ToolCallStarted(call, "agent.delegate", ToolActivity.DELEGATE, "general")) + val activity = assertNotNull( + sink.detachActivity(DelegatedRun(call, RunId("r1"), AgentId("general"))), + ) + activity.event( + TurnEvent.ToolCallStarted( + ToolCallId("child1"), + "fs.read", + ToolActivity.READ, + "a file", + RunId("r1"), + AgentId("general"), + ), + ) + assertIs( + sink.complete(OutboundMessage.builder("done", MessageKind.REPLY).build()), + ) + assertTrue( + api.calls(STREAM_DIRECT).none { "\"input_state\":10" in it.body }, + "the stream closed while a background run still wrote to it", + ) + activity.close(ToolCallStatus.COMPLETED) + val last = api.calls(STREAM_DIRECT).last() + assertTrue("\"input_state\":10" in last.body, last.body) + } + } finally { + platform.stop() + api.close() + } + } + + private fun config(apiBase: String, gatewayUrl: String, admins: Set = emptySet()): String = """ + { + "instances": { + "main": { + "appId": "10000", + "appSecret": "secret", + "apiBase": "$apiBase", + "gatewayUrl": "$gatewayUrl", + "admins": [${admins.joinToString(",") { "\"$it\"" }}], + "connectTimeoutMillis": 5000, + "firstByteTimeoutMillis": 5000, + "idleTimeoutMillis": 5000, + "reconnectIntervalMillis": 200 + } + } + } + """.trimIndent() + + private companion object { + const val TOKEN_PATH = "/app/getAppAccessToken" + + const val ME_PATH = "/users/@me" + + const val BOT_STATE_PATH = "/v2/groups/g1/bot_state" + + const val SEND_DIRECT = "/v2/users/u1/messages" + + const val STREAM_DIRECT = "/v2/users/u1/stream_messages" + + const val APPROVAL_PRESS = """{"id":"event-approve","op":0,"s":2,"t":"C2C_MESSAGE_CREATE","d":""" + + """{"id":"msg-approve","author":{"id":"u1","user_openid":"u1","username":"Ada","bot":false},""" + + """"content":"/approve req1","message_type":0,"timestamp":"2026-07-21T10:00:00+08:00"}}""" + + const val COMMAND_EVENT = """{"id":"event-command","op":0,"s":3,"t":"C2C_MESSAGE_CREATE","d":""" + + """{"id":"msg-command","author":{"id":"u1","user_openid":"u1","username":"Ada","bot":false},""" + + """"content":"/model deepseek/deepseek-flash","message_type":0,"timestamp":"2026-07-21T10:00:01+08:00"}}""" + + const val EVENT = """{"id":"event1","op":0,"s":2,"t":"C2C_MESSAGE_CREATE","d":""" + + """{"id":"msg1","author":{"id":"u1","user_openid":"u1","username":"Ada","bot":false},""" + + """"content":"hello","message_type":0,"timestamp":"2026-07-21T10:00:00+08:00"}}""" + + const val WHOAMI_EVENT = """{"id":"event-who","op":0,"s":2,"t":"C2C_MESSAGE_CREATE","d":""" + + """{"id":"msg-who","author":{"id":"u1","user_openid":"u1","username":"Ada","bot":false},""" + + """"content":"/whoami","message_type":0,"timestamp":"2026-07-21T10:00:02+08:00"}}""" + + const val FULL_MODE_EVENT = """{"id":"event-full","op":0,"s":3,"t":"GROUP_MESSAGE_CREATE","d":""" + + """{"id":"msg-full","author":{"id":"u1","member_openid":"u1","username":"Ada","bot":false},""" + + """"content":"<@bot1> hello","group_openid":"g1","message_type":0,""" + + """"mentions":[{"id":"bot1","member_openid":"bot1","bot":true}],""" + + """"timestamp":"2026-07-21T10:00:02+08:00"}}""" + + const val FULL_MODE_MENTION_EVENT = """{"id":"event-full-mention","op":0,"s":4,""" + + """"t":"GROUP_MESSAGE_CREATE","d":{"id":"msg-full-mention",""" + + """"author":{"id":"u1","member_openid":"u1","username":"Ada","bot":false},""" + + """"content":"<@member-bot-id> hello","group_openid":"g1","message_type":0,""" + + """"timestamp":"2026-07-21T10:00:03+08:00"}}""" + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/auth/QqBotTokensTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/auth/QqBotTokensTest.kt new file mode 100644 index 00000000..43650d65 --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/auth/QqBotTokensTest.kt @@ -0,0 +1,60 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.auth + +import kotlinx.coroutines.runBlocking +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.MutableClock +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class QqBotTokensTest { + private val clock = MutableClock(Instant.parse("2026-07-21T00:00:00Z")) + + @Test + fun `a token is bought once and used until it is close to expiring`() = runBlocking { + val server = FakeApiServer() + server.on(TOKEN_PATH, """{"access_token":"one","expires_in":"7200"}""") + val http = QqBotHttp(testConfig(server.base)) + try { + val tokens = QqBotTokens(testConfig(server.base), http, clock) + assertEquals("one", tokens.token()) + assertEquals("one", tokens.token()) + assertEquals(1, server.calls(TOKEN_PATH).size, "the token was bought again while it was valid") + + clock.at = clock.at.plusSeconds(7_200) + assertEquals("one", tokens.token()) + + server.on(TOKEN_PATH, """{"access_token":"two","expires_in":"7200"}""") + clock.at = clock.at.plusSeconds(7_200) + assertEquals("two", tokens.token()) + assertEquals(3, server.calls(TOKEN_PATH).size) + } finally { + http.close() + server.close() + } + } + + @Test + fun `a refusal of the platform names its code and message`() = runBlocking { + val server = FakeApiServer() + server.on(TOKEN_PATH, """{"code":100016,"message":"invalid appid or secret"}""") + val http = QqBotHttp(testConfig(server.base)) + try { + val tokens = QqBotTokens(testConfig(server.base), http, clock) + val failure = assertFailsWith { tokens.token() } + assertTrue("100016" in failure.message.orEmpty(), failure.message) + assertTrue("invalid appid or secret" in failure.message.orEmpty(), failure.message) + } finally { + http.close() + server.close() + } + } + + private companion object { + const val TOKEN_PATH = "/app/getAppAccessToken" + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRulesTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRulesTest.kt new file mode 100644 index 00000000..471fb730 --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRulesTest.kt @@ -0,0 +1,45 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +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.turn.CommandContext +import org.foedusprogramme.alexandrite.sdk.turn.CommandHandler +import org.foedusprogramme.alexandrite.sdk.turn.CommandInvocation +import org.foedusprogramme.alexandrite.sdk.turn.CommandSpec +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class QqBotCommandRulesTest { + private val instance = ChannelInstanceId(ChannelType("qqbot"), "main") + private val direct = ChatAddress(instance, "private:u1") + private val group = ChatAddress(instance, "group:g1") + private val rules = QqBotCommandRules(listOf(Commands), control = null) + + @Test + fun `mayRun answers the matrix for a member`() { + assertTrue(rules.mayRun(direct, admin = false, "model", withArguments = false)) + assertFalse(rules.mayRun(direct, admin = false, "model", withArguments = true)) + assertTrue(rules.mayRun(direct, admin = false, "help", withArguments = false)) + assertTrue(rules.mayRun(group, admin = false, "help", withArguments = false)) + assertTrue(rules.mayRun(group, admin = false, "context", withArguments = true)) + assertFalse(rules.mayRun(group, admin = false, "agent", withArguments = true)) + assertFalse(rules.mayRun(group, admin = false, "trust", withArguments = false)) + assertTrue(rules.mayRun(group, admin = true, "trust", withArguments = false)) + assertTrue(rules.mayRun(group, admin = false, "not_a_command", withArguments = true)) + } + + private object Commands : CommandHandler { + override val commands: List = listOf( + "help", + "settings", + "context", + "agent", + "model", + "trust", + ).map { CommandSpec.builder(it, "a test command").build() } + + override suspend fun handle(invocation: CommandInvocation, context: CommandContext) = Unit + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt new file mode 100644 index 00000000..f37e9d47 --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt @@ -0,0 +1,139 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.runBlocking +import org.foedusprogramme.alexandrite.channel.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.MutableClock +import org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformRequest +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +import org.foedusprogramme.alexandrite.sdk.turn.CommandContext +import org.foedusprogramme.alexandrite.sdk.turn.CommandHandler +import org.foedusprogramme.alexandrite.sdk.turn.CommandInvocation +import org.foedusprogramme.alexandrite.sdk.turn.CommandSpec +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class QqBotMenuSyncTest { + private val clock = MutableClock(Instant.parse("2026-07-21T00:00:00Z")) + + @Test + fun `the slash commands become a single-chat menu and command panels`() = runBlocking { + val platform = Platform() + try { + platform.server.on("/v2/panels") { request -> + if (request.method == "GET") { + PlatformAnswer(200, """{"records":[],"next_cursor":"","is_end":true}""") + } else { + PlatformAnswer(200, """{"panel_id":"p1"}""") + } + } + platform.server.on("/v2/menu", """{"version":1}""") + QqBotMenuSync(platform.api, platform.config, listOf(Commands)).onOpen() + + val menu = platform.server.calls("/v2/menu").single() + assertEquals("PUT", menu.method) + assertTrue("\"name\":\"model\"" in menu.body, menu.body) + assertTrue("\"send_message\":\"/model\"" in menu.body, menu.body) + assertTrue("\"desc\":\"Pick the model\"" in platform.server.calls("/v2/panels").last().body) + + val panels = platform.server.calls("/v2/panels") + assertEquals(4, panels.size) + assertEquals(listOf("GET", "POST", "GET", "POST"), panels.map { it.method }) + assertTrue("\"scope\":\"c2c\"" in panels[1].body, panels[1].body) + assertTrue("\"scope\":\"group\"" in panels[3].body, panels[3].body) + } finally { + platform.close() + } + } + + @Test + fun `the sync language picks the description the handler gives it`() = runBlocking { + val platform = Platform("zh-CN") + try { + platform.server.on("/v2/panels") { request -> + if (request.method == "GET") { + PlatformAnswer(200, """{"records":[],"next_cursor":"","is_end":true}""") + } else { + PlatformAnswer(200, """{"panel_id":"p1"}""") + } + } + platform.server.on("/v2/menu", """{"version":1}""") + QqBotMenuSync(platform.api, platform.config, listOf(Commands)).onOpen() + + assertTrue("\"desc\":\"选模型\"" in platform.server.calls("/v2/panels").last().body) + } finally { + platform.close() + } + } + + @Test + fun `an already published panel is updated instead of created again`() = runBlocking { + val platform = Platform() + try { + platform.server.on("/v2/panels") { request -> + if (request.method == "GET") { + PlatformAnswer( + 200, + """{"records":[{"panel_id":"p1","panel":{"remark":"alexandrite commands"}}],"is_end":true}""", + ) + } else { + PlatformAnswer(200, """{"id":"p1"}""") + } + } + platform.server.on("/v2/menu", """{"version":2}""") + platform.server.on("/v2/panels/p1", """{"version":2}""") + QqBotMenuSync(platform.api, platform.config, listOf(Commands)).onOpen() + + val calls = platform.server.calls("/v2/panels/p1") + assertEquals(2, calls.size) + assertTrue(calls.all { it.method == "PUT" }, calls.joinToString { it.method }) + } finally { + platform.close() + } + } + + private object Commands : CommandHandler { + override val commands: List = listOf( + CommandSpec.builder("model", "Pick the model").build(), + CommandSpec.builder("whoami", "Show the chat and sender").build(), + ) + + override fun description( + command: String, + language: org.foedusprogramme.alexandrite.sdk.chat.LanguageTag?, + ): String = if (language?.value?.startsWith("zh") == true) { + when (command) { + "model" -> "选模型" + else -> "查看 $command" + } + } else { + commands.first { it.name == command }.description + } + + override suspend fun handle(invocation: CommandInvocation, context: CommandContext) = + throw UnsupportedOperationException("not used") + } + + private inner class Platform(private val syncLanguage: String? = null) : AutoCloseable { + val server = FakeApiServer() + private val http: QqBotHttp + val api: QqBotApi + val config = testConfig(server.base, syncLanguage = syncLanguage) + + init { + server.on("/app/getAppAccessToken", """{"access_token":"access","expires_in":"7200"}""") + http = QqBotHttp(config) + api = QqBotApi(http, QqBotTokens(config, http, clock)) + } + + override fun close() { + http.close() + server.close() + } + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgressTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgressTest.kt new file mode 100644 index 00000000..9b8fb4ec --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgressTest.kt @@ -0,0 +1,74 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.runBlocking +import org.foedusprogramme.alexandrite.channel.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.MutableClock +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +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.TurnId +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class QqBotProgressTest { + private val instance = ChannelInstanceId(ChannelType("qqbot"), "main") + private val direct = ChatAddress(instance, "private:u1") + private val turn = TurnId("turn1") + private val clock = MutableClock(Instant.parse("2026-07-21T00:00:00Z")) + + @Test + fun `an approval closes the stream and defers the answer`() = runBlocking { + val platform = Platform() + try { + val stream = QqBotStream(platform.sender, direct, "msg1", platform.sender.sequenceFor("msg1")) + val progress = QqBotProgress() + progress.attach(turn, direct, stream) + stream.show("working") + + progress.defer(turn) + + assertTrue(progress.isDeferred(turn)) + val pieces = platform.server.calls(STREAM_DIRECT) + assertEquals(2, pieces.size) + assertTrue("\"input_state\":1" in pieces[0].body, pieces[0].body) + assertTrue("\"input_state\":10" in pieces[1].body, pieces[1].body) + assertTrue("\"content_raw\"" !in pieces[1].body, pieces[1].body) + + progress.done(turn) + assertTrue(!progress.isDeferred(turn)) + } finally { + platform.close() + } + } + + private inner class Platform : AutoCloseable { + val server = FakeApiServer() + private val http: QqBotHttp + val sender: QqBotSender + + init { + server.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + server.on(STREAM_DIRECT, """{"id":"stream1"}""") + val config = testConfig(server.base) + http = QqBotHttp(config) + val api = QqBotApi(http, QqBotTokens(config, http, clock)) + sender = QqBotSender(api, QqBotUploads(api, config), QqBotSentMessages(), QqBotMessageIndex()) + } + + override fun close() { + http.close() + server.close() + } + } + + private companion object { + const val TOKEN_PATH = "/app/getAppAccessToken" + const val STREAM_DIRECT = "/v2/users/u1/stream_messages" + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotPromptHookTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotPromptHookTest.kt new file mode 100644 index 00000000..6ba21d81 --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotPromptHookTest.kt @@ -0,0 +1,39 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.runBlocking +import org.foedusprogramme.alexandrite.sdk.InternalAlexandriteApi +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.hook.HookDecision +import org.foedusprogramme.alexandrite.sdk.turn.PromptSections +import org.foedusprogramme.alexandrite.testkit.testTurn +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(InternalAlexandriteApi::class) +class QqBotPromptHookTest { + private val hook = QqBotPromptHook() + + @Test + fun `the prompt of a qqbot turn says what the client renders`() = runBlocking { + val turn = testTurn(chat = chat("qqbot")) + val decision = hook.intercept(PromptSections(turn, emptyList())) + + val payload = assertIs>(decision).payload + val section = payload.sections.single { it.id == "qqbot.platform" } + assertTrue("GFM tables" in section.text) + assertTrue("[TOC]" in section.text) + } + + @Test + fun `the prompt of another channel is left alone`() = runBlocking { + val turn = testTurn(chat = chat("onebot")) + + assertEquals(HookDecision.Continue, hook.intercept(PromptSections(turn, emptyList()))) + } + + private fun chat(type: String) = ChatAddress(ChannelInstanceId(ChannelType(type), "main"), "private:u1") +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSenderTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSenderTest.kt new file mode 100644 index 00000000..69f8894c --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSenderTest.kt @@ -0,0 +1,292 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.runBlocking +import org.foedusprogramme.alexandrite.channel.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.MutableClock +import org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +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.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundFile +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.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class QqBotSenderTest { + private val instance = ChannelInstanceId(ChannelType("qqbot"), "main") + private val group = ChatAddress(instance, "group:g1") + private val direct = ChatAddress(instance, "private:u1") + private val clock = MutableClock(Instant.parse("2026-07-21T00:00:00Z")) + + @Test + fun `a reply names the message it answers and counts its answers`() = runBlocking { + val platform = Platform() + try { + val message = OutboundMessage.builder("hi", MessageKind.REPLY) + .replyTo(ChannelMessageRef(group, "m1")) + .build() + assertIs(platform.sender.send(group, message)) + assertIs(platform.sender.send(group, message)) + + val calls = platform.server.calls(SEND_GROUP) + assertEquals(2, calls.size) + assertTrue("\"msg_id\":\"m1\"" in calls[0].body, calls[0].body) + assertTrue("\"msg_seq\":1" in calls[0].body, calls[0].body) + assertTrue("\"msg_seq\":2" in calls[1].body, calls[1].body) + assertEquals( + "QQBot access", + calls[0].headers.entries.firstOrNull { + it.key.equals("Authorization", true) + }?.value, + ) + } finally { + platform.close() + } + } + + @Test + fun `a reply carries the reference the platform needs for a quote bar`() = runBlocking { + val platform = Platform() + try { + platform.index.record(group, "REF1", "m1") + val message = OutboundMessage.builder("hi", MessageKind.REPLY) + .replyTo(ChannelMessageRef(group, "m1")) + .build() + assertIs(platform.sender.send(group, message)) + + val body = platform.server.calls(SEND_GROUP).single().body + assertTrue("\"message_reference\":{\"message_id\":\"REF1\"}" in body, body) + } finally { + platform.close() + } + } + + @Test + fun `a message with a file uploads it and sends the file alone`() = runBlocking { + val platform = Platform() + platform.server.on(UPLOAD_GROUP, """{"file_info":"FI","ttl":300}""") + platform.server.on(SEND_GROUP) { request -> + PlatformAnswer(200, if ("\"msg_type\":7" in request.body) """{"id":"media1"}""" else """{"id":"text1"}""") + } + try { + val file = OutboundFile.builder("a.png", "image/png", "hello".toByteArray()).build() + val message = OutboundMessage.builder("a caption", MessageKind.REPLY).files(listOf(file)).build() + + val delivery = assertIs(platform.sender.send(group, message)) + + assertEquals(listOf("media1"), delivery.messages.map { it.id }) + val upload = platform.server.calls(UPLOAD_GROUP).single() + assertTrue("\"file_type\":1" in upload.body, upload.body) + assertTrue("\"file_data\":\"aGVsbG8=\"" in upload.body, upload.body) + val messages = platform.server.calls(SEND_GROUP) + assertEquals( + 1, + messages.size, + "the caption was sent as a message beside the file: ${messages.map { + it.body + }}", + ) + assertTrue("\"msg_type\":7" in messages[0].body, messages[0].body) + assertTrue("\"file_info\":\"FI\"" in messages[0].body, messages[0].body) + } finally { + platform.close() + } + } + + @Test + fun `a message written as markdown goes as a markdown message`() = runBlocking { + val platform = Platform() + try { + val message = OutboundMessage.builder("## hi", MessageKind.REPLY).markup(Markup.MARKDOWN).build() + assertIs(platform.sender.send(direct, message)) + + val body = platform.server.calls(SEND_DIRECT).single().body + assertTrue("\"msg_type\":2" in body, body) + assertTrue("\"markdown\"" in body, body) + assertFalse("\"msg_id\"" in body, "a message that answers nothing is an active one: $body") + } finally { + platform.close() + } + } + + @Test + fun `a reply the platform no longer takes is sent as an active message`() = runBlocking { + val platform = Platform() + platform.server.on(SEND_DIRECT) { + if (it.body.contains("msg_id")) { + PlatformAnswer(200, """{"err_code":40034005,"message":"回复消息msg_id已过期"}""") + } else { + PlatformAnswer(200, """{"id":"sent2","timestamp":"2026-07-21T10:00:00+08:00"}""") + } + } + try { + val message = OutboundMessage.builder("late", MessageKind.REPLY) + .replyTo(ChannelMessageRef(direct, "m2")) + .build() + val delivery = platform.sender.send(direct, message) + + assertEquals("sent2", assertIs(delivery).messages.single().id) + val calls = platform.server.calls(SEND_DIRECT) + assertEquals(2, calls.size) + assertFalse("\"msg_id\"" in calls[1].body, calls[1].body) + } finally { + platform.close() + } + } + + @Test + fun `a message the platform finds too long comes back as one a caller may shorten`() = runBlocking { + val platform = Platform() + platform.server.on(SEND_DIRECT, """{"err_code":40054007,"message":"消息长度超限"}""") + try { + val message = OutboundMessage.builder("long", MessageKind.REPLY).build() + val delivery = platform.sender.send(direct, message) + + val failure = assertIs(delivery) + assertEquals(DeliveryFailure.TOO_LONG, failure.kind) + assertTrue("消息长度超限" in failure.detail.orEmpty(), failure.detail) + } finally { + platform.close() + } + } + + @Test + fun `a chat this channel serves none of is refused before any call`() = runBlocking { + val platform = Platform() + try { + val message = OutboundMessage.builder("hi", MessageKind.REPLY).build() + val delivery = platform.sender.send(ChatAddress(instance, "topic:1"), message) + + assertIs(delivery) + assertTrue(platform.server.requests().isEmpty(), "a chat of another channel reached the platform") + } finally { + platform.close() + } + } + + @Test + fun `a message the platform takes no markdown of is sent as plain text`() = runBlocking { + val platform = Platform() + platform.server.on(SEND_DIRECT) { + if ("\"msg_type\":2" in it.body) { + PlatformAnswer(200, """{"err_code":40034127,"message":"无markdown模板权限"}""") + } else { + PlatformAnswer(200, """{"id":"plain1","timestamp":"2026-07-21T10:00:00+08:00"}""") + } + } + try { + val message = OutboundMessage.builder("## hi", MessageKind.REPLY).markup(Markup.MARKDOWN).build() + val delivery = platform.sender.send(direct, message) + + assertEquals("plain1", assertIs(delivery).messages.single().id) + val calls = platform.server.calls(SEND_DIRECT) + assertEquals(2, calls.size) + assertTrue("\"msg_type\":0" in calls[1].body, calls[1].body) + assertTrue("\"content\":\"## hi\"" in calls[1].body, calls[1].body) + } finally { + platform.close() + } + } + + @Test + fun `a single chat is streamed in one message the pieces replace`() = runBlocking { + val platform = Platform() + platform.server.on(STREAM_DIRECT, """{"id":"stream1","timestamp":"2026-07-21T10:00:00+08:00"}""") + try { + val first = platform.sender.stream(direct, "he", "m1", 1, null, 0, last = false) + assertEquals("stream1", platform.sender.sentId(first)) + val second = platform.sender.stream(direct, "hello", "m1", 1, "stream1", 1, last = false) + assertEquals("stream1", platform.sender.sentId(second)) + + val calls = platform.server.calls(STREAM_DIRECT) + assertEquals(2, calls.size) + assertTrue("\"input_state\":1" in calls[0].body, calls[0].body) + assertTrue("\"index\":0" in calls[0].body, calls[0].body) + assertTrue("\"content_type\":\"markdown\"" in calls[0].body, calls[0].body) + assertTrue("\"content_raw\":\"he\"" in calls[0].body, calls[0].body) + assertTrue("\"msg_id\":\"m1\"" in calls[0].body, calls[0].body) + assertTrue("\"stream_msg_id\"" !in calls[0].body, calls[0].body) + assertTrue("\"stream_msg_id\":\"stream1\"" in calls[1].body, calls[1].body) + assertTrue("\"index\":1" in calls[1].body, calls[1].body) + } finally { + platform.close() + } + } + + @Test + fun `a group has no streaming endpoint so no piece is sent`() = runBlocking { + val platform = Platform() + try { + val answer = platform.sender.stream(group, "he", "m1", 1, null, 0, last = false) + + assertIs(answer) + assertTrue(platform.server.calls(STREAM_GROUP).isEmpty(), "a group piece reached the platform") + } finally { + platform.close() + } + } + + @Test + fun `a single chat is told the bot is writing`() = runBlocking { + val platform = Platform() + platform.server.on(SEND_DIRECT) { PlatformAnswer(200, """{"id":"typing1"}""") } + try { + assertIs(platform.sender.sendTyping(direct)) + val body = platform.server.calls(SEND_DIRECT).single().body + assertTrue("\"msg_type\":6" in body, body) + assertTrue("\"input_type\":1" in body, body) + assertTrue("\"input_second\":60" in body, body) + + assertIs(platform.sender.sendTyping(group)) + assertTrue(platform.server.calls(SEND_GROUP).isEmpty(), "a group was told of an input state") + } finally { + platform.close() + } + } + + /** A sender that talks to a fake platform, which answers the token and every message with a success. */ + private inner class Platform : AutoCloseable { + val server = FakeApiServer() + private val http: QqBotHttp + val sender: QqBotSender + val index = QqBotMessageIndex() + + init { + server.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + server.on(SEND_GROUP, """{"id":"sent1","timestamp":"2026-07-21T10:00:00+08:00"}""") + server.on(SEND_DIRECT, """{"id":"sent1","timestamp":"2026-07-21T10:00:00+08:00"}""") + val config = testConfig(server.base) + http = QqBotHttp(config) + val tokens = QqBotTokens(config, http, clock) + val api = QqBotApi(http, tokens) + sender = QqBotSender(api, QqBotUploads(api, config), QqBotSentMessages(), index) + } + + override fun close() { + http.close() + server.close() + } + } + + private companion object { + const val TOKEN_PATH = "/app/getAppAccessToken" + const val SEND_GROUP = "/v2/groups/g1/messages" + const val UPLOAD_GROUP = "/v2/groups/g1/files" + const val SEND_DIRECT = "/v2/users/u1/messages" + const val STREAM_DIRECT = "/v2/users/u1/stream_messages" + const val STREAM_GROUP = "/v2/groups/g1/stream_messages" + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotUploadsTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotUploadsTest.kt new file mode 100644 index 00000000..abb9a545 --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotUploadsTest.kt @@ -0,0 +1,119 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.runBlocking +import org.foedusprogramme.alexandrite.channel.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.MutableClock +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +import org.foedusprogramme.alexandrite.sdk.channel.OutboundFile +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import java.time.Instant +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class QqBotUploadsTest { + private val instance = ChannelInstanceId(ChannelType("qqbot"), "main") + private val direct = ChatAddress(instance, "private:u1") + private val clock = MutableClock(Instant.parse("2026-07-21T00:00:00Z")) + + @Test + fun `a small file goes as base64 in the upload call`() = runBlocking { + val platform = Platform() + platform.server.on(FILES, """{"file_info":"FI","ttl":300}""") + try { + val file = OutboundFile.builder("a.png", "image/png", "hello".toByteArray()).build() + + val uploaded = assertIs(platform.uploads.upload(direct, file)) + + assertEquals("FI", uploaded.fileInfo) + val body = platform.server.calls(FILES).single().body + assertTrue("\"file_type\":1" in body, body) + assertTrue("\"file_name\":\"a.png\"" in body, body) + assertTrue("\"file_data\":\"aGVsbG8=\"" in body, body) + } finally { + platform.close() + } + } + + @Test + fun `a large file goes in blocks and the platform merges them`() = runBlocking { + val platform = Platform() + platform.server.on(PREPARE) { + val base = platform.server.base + org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformAnswer( + 200, + """{"upload_id":"up","block_size":"3145728","upload_config":{"concurrency":2},"parts":[""" + + """{"index":0,"presigned_url":"$base/upload/0","block_size":"3145728"},""" + + """{"index":1,"presigned_url":"$base/upload/1","block_size":"3145728"}]}""", + ) + } + val active = AtomicInteger() + val peak = AtomicInteger() + for (path in listOf("/upload/0", "/upload/1")) { + platform.server.on(path) { + val now = active.incrementAndGet() + peak.updateAndGet { maxOf(it, now) } + Thread.sleep(80) + active.decrementAndGet() + org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformAnswer(200, "{}") + } + } + platform.server.on(PART_FINISH, "{}") + platform.server.on(FILES, """{"file_info":"FI2"}""") + try { + val bytes = ByteArray(6 * 1024 * 1024) { (it % 251).toByte() } + val file = OutboundFile.builder("big.bin", "application/octet-stream", bytes).build() + + val uploaded = assertIs(platform.uploads.upload(direct, file)) + + assertEquals("FI2", uploaded.fileInfo) + val prepare = platform.server.calls(PREPARE).single() + assertTrue("\"file_size\":\"6291456\"" in prepare.body, prepare.body) + assertTrue("\"md5\":" in prepare.body, prepare.body) + assertTrue("\"sha1\":" in prepare.body, prepare.body) + assertTrue("\"md5_10m\":" in prepare.body, prepare.body) + val puts = platform.server.calls("/upload/0") + platform.server.calls("/upload/1") + assertEquals(2, puts.size) + assertTrue(puts.all { it.method == "PUT" }, puts.map { it.method }.toString()) + assertEquals(2, platform.server.calls(PART_FINISH).size) + assertTrue("\"upload_id\":\"up\"" in platform.server.calls(FILES).single().body) + assertTrue(peak.get() >= 2, "the two blocks were not uploaded at the same time: peak ${peak.get()}") + } finally { + platform.close() + } + } + + /** Uploads that talk to a fake platform, which keeps what it read. */ + private inner class Platform : AutoCloseable { + val server = FakeApiServer() + private val http: QqBotHttp + val uploads: QqBotUploads + + init { + server.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + val config = testConfig(server.base) + http = QqBotHttp(config) + val tokens = QqBotTokens(config, http, clock) + uploads = QqBotUploads(QqBotApi(http, tokens), config) + } + + override fun close() { + http.close() + server.close() + } + } + + private companion object { + const val TOKEN_PATH = "/app/getAppAccessToken" + const val FILES = "/v2/users/u1/files" + const val PREPARE = "/v2/users/u1/upload_prepare" + const val PART_FINISH = "/v2/users/u1/upload_part_finish" + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/config/QqBotConfigTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/config/QqBotConfigTest.kt new file mode 100644 index 00000000..6bee4e7d --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/config/QqBotConfigTest.kt @@ -0,0 +1,71 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.config + +import org.foedusprogramme.alexandrite.sdk.config.Secret +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class QqBotConfigTest { + @Test + fun `an instance names the bot and the platform`() { + val config = QqBotInstanceConfig(appId = "10000", appSecret = Secret("secret")) + + assertEquals("10000", config.appId) + assertEquals(QqBotInstanceConfig.DEFAULT_API_BASE, config.apiBase) + assertEquals(1L shl 25, config.intents) + assertEquals(true, config.streaming) + assertEquals(10_000L, config.connectTimeoutMillis) + assertEquals(10L, config.approvalMinutes) + } + + @Test + fun `a blank app id is refused`() { + assertFailsWith { QqBotInstanceConfig(appId = "", appSecret = Secret("secret")) } + } + + @Test + fun `an app id that is no number or word is refused`() { + assertFailsWith { + QqBotInstanceConfig(appId = "app id", appSecret = Secret("secret")) + } + } + + @Test + fun `an api base that is no HTTP address is refused`() { + assertFailsWith { + QqBotInstanceConfig(appId = "10000", appSecret = Secret("secret"), apiBase = "api.bot.qq.com") + } + } + + @Test + fun `a gateway address that is no websocket address is refused`() { + assertFailsWith { + QqBotInstanceConfig( + appId = "10000", + appSecret = Secret("secret"), + gatewayUrl = "https://api.bot.qq.com/websocket", + ) + } + } + + @Test + fun `intents that name no event group are refused`() { + assertFailsWith { + QqBotInstanceConfig(appId = "10000", appSecret = Secret("secret"), intents = 0) + } + } + + @Test + fun `a timeout that is not positive is refused`() { + assertFailsWith { + QqBotInstanceConfig(appId = "10000", appSecret = Secret("secret"), connectTimeoutMillis = 0) + } + } + + @Test + fun `a sync language that is no language tag is refused`() { + assertFailsWith { + QqBotInstanceConfig(appId = "10000", appSecret = Secret("secret"), syncLanguage = "中文") + } + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGatewayTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGatewayTest.kt new file mode 100644 index 00000000..85627f0e --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGatewayTest.kt @@ -0,0 +1,65 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.gateway + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.foedusprogramme.alexandrite.channel.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeGatewayServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.MutableClock +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class QqBotGatewayTest { + private val clock = MutableClock(Instant.parse("2026-07-21T00:00:00Z")) + + @Test + fun `the gateway identifies, becomes ready and hands the pushed events on`() = runBlocking { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + val platform = FakeGatewayServer().startAndAwait() + val config = testConfig(api.base, gatewayUrl = platform.url) + val http = QqBotHttp(config) + try { + val tokens = QqBotTokens(config, http, clock) + val gateway = QqBotGateway(config, QqBotApi(http, tokens), tokens) + gateway.onStart() + + val identify = assertNotNull(platform.awaitFrame(predicate = { it.contains("\"op\":2") })) + assertTrue("QQBot access" in identify, identify) + assertTrue("\"intents\":${1L shl 25}" in identify, identify) + assertTrue("\"shard\":[0,1]" in identify, identify) + assertEquals("bot1", gateway.botId) + + platform.push(EVENT) + val frame = withTimeout(5_000) { gateway.frames.first { it.t == "C2C_MESSAGE_CREATE" } } + assertEquals("event1", frame.id) + assertEquals( + "msg1", + frame.d?.let { + org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire.message(it)?.id + }, + ) + + gateway.onStop() + } finally { + platform.stop() + http.close() + api.close() + } + } + + private companion object { + const val TOKEN_PATH = "/app/getAppAccessToken" + + const val EVENT = """{"id":"event1","op":0,"s":2,"t":"C2C_MESSAGE_CREATE","d":""" + + """{"id":"msg1","author":{"id":"u1","user_openid":"u1","username":"Ada"},"content":"hi",""" + + """"message_type":0,"timestamp":"2026-07-21T10:00:00+08:00"}}""" + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMarkdownTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMarkdownTest.kt new file mode 100644 index 00000000..bfcef4cb --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMarkdownTest.kt @@ -0,0 +1,72 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.mapping + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class QqBotMarkdownTest { + @Test + fun `supported markdown is passed through`() { + val text = "# Title\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```kotlin\nval x = 1\n```\n" + + assertEquals(text, QqBotMarkdown.render(text)) + } + + @Test + fun `a highlight becomes bold`() { + assertEquals("**important**", QqBotMarkdown.render("==important==")) + } + + @Test + fun `a superscript and a subscript become the inline HTML the client renders`() { + assertEquals("E = mc2", QqBotMarkdown.render("E = mc^2^")) + assertEquals("H2O", QqBotMarkdown.render("H~2~O")) + assertEquals("~~removed~~", QqBotMarkdown.render("~~removed~~")) + } + + @Test + fun `a definition list becomes plain lines`() { + assertEquals("term:its meaning", QqBotMarkdown.render("term\n: its meaning")) + } + + @Test + fun `a table of contents and a footnote definition are dropped`() { + val text = "[TOC]\n\n[^note]: a footnote\n\nA paragraph." + + assertEquals("\n\nA paragraph.", QqBotMarkdown.render(text)) + } + + @Test + fun `an HTML block loses its tags but keeps its text`() { + val text = "
More\n\nSome text\n\n
" + + assertEquals("More\n\nSome text\n\n", QqBotMarkdown.render(text)) + } + + @Test + fun `a break and a rule become markdown the client renders`() { + assertEquals("a\nb\n---", QqBotMarkdown.render("a
b
")) + } + + @Test + fun `an image with a local path becomes its alt text and a public one stays`() { + assertEquals("a picture", QqBotMarkdown.render("![a picture](./images/a.png)")) + val remote = "![a picture](https://example.com/a.png)" + assertEquals(remote, QqBotMarkdown.render(remote)) + } + + @Test + fun `a code fence and an inline code span are not changed`() { + val text = "```\n==not bold==
\n```\n\n`==not bold==`" + + assertEquals(text, QqBotMarkdown.render(text)) + } + + @Test + fun `withoutUrls keeps the label and drops the target`() { + assertEquals("see the site", QqBotMarkdown.render("see [the site](https://example.com)", stripUrls = true)) + assertFalse("example.com" in QqBotMarkdown.render("see https://example.com", stripUrls = true)) + assertTrue("the site" in QqBotMarkdown.withoutUrls("see [the site](https://example.com)")) + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMessagesTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMessagesTest.kt new file mode 100644 index 00000000..481e6d8e --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMessagesTest.kt @@ -0,0 +1,162 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.mapping + +import org.foedusprogramme.alexandrite.channel.qqbot.channel.QqBotMessageIndex +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +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 org.foedusprogramme.alexandrite.sdk.transcript.MediaKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class QqBotMessagesTest { + private val instance = ChannelInstanceId(ChannelType("qqbot"), "main") + + private val http = QqBotHttp(testConfig("http://127.0.0.1:1")) + private val media = QqBotMedia(http) + + @Test + fun `a message of a single chat is the direct chat of its author`() { + val event = eventOf(c2c()) + val message = QqBotMessages.incoming(instance, event, admins = emptySet(), media = media) + + assertEquals("private:u1", message.chat.chat) + assertEquals(ChatKind.DIRECT, message.chatInfo.kind) + assertEquals("msg1", message.ref.id) + assertEquals("Ada", message.sender.displayName) + assertEquals(" hello ", message.text) + assertEquals(1, message.media.size) + assertEquals(MediaKind.IMAGE, message.media.single().kind) + assertEquals("a.jpg", message.media.single().name) + assertEquals(10L, message.media.single().size) + } + + @Test + fun `a message of a group keeps its group and the role of its author`() { + val message = QqBotMessages.incoming(instance, eventOf(group()), admins = emptySet(), media = media) + + assertEquals("group:g1", message.chat.chat) + assertEquals(ChatKind.GROUP, message.chatInfo.kind) + assertEquals("member1", message.sender.address.user) + assertTrue(message.sender.isAdmin, "the owner and admins of a group operate the bot") + } + + @Test + fun `an account the config names is an admin`() { + val message = QqBotMessages.incoming(instance, eventOf(c2c()), admins = setOf("u1"), media = media) + + assertTrue(message.sender.isAdmin) + } + + @Test + fun `a card message reaches the model as a card`() { + val message = QqBotMessages.incoming(instance, eventOf(framed("C2C_MESSAGE_CREATE", card())), emptySet(), media) + + val fact = message.facts.singleOrNull { it.label == "card" } + assertNotNull(fact) + assertTrue("小程序" in fact.value, fact.value) + } + + @Test + fun `a quote message carries what it quoted`() { + val message = QqBotMessages.incoming( + instance, + eventOf(framed("C2C_MESSAGE_CREATE", quoted())), + emptySet(), + media, + ) + + val quote = assertNotNull(message.quote) + assertTrue("每天坚持阅读" in quote.text, quote.text) + } + + @Test + fun `a quote resolves its reference to the message id`() { + val index = QqBotMessageIndex() + val chat = ChatAddress(instance, "private:u1") + index.record(chat, "REF2", "original") + val message = QqBotMessages.incoming( + instance, + eventOf(framed("C2C_MESSAGE_CREATE", quoted())), + emptySet(), + media, + index, + ) + + assertEquals("original", message.quote?.target?.id) + } + + private fun eventOf(frame: org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFrame) = + assertNotNull(QqBotWire.message(frame.d)) + + private fun framed(name: String, data: String): org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFrame = + assertNotNull(QqBotWire.frame("""{"id":"event1","op":0,"s":2,"t":"$name","d":$data}""")) + + private fun c2c(): org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFrame = framed( + "C2C_MESSAGE_CREATE", + """ + { + "id": "msg1", + "author": {"id": "u1", "user_openid": "u1", "username": "Ada", "bot": false}, + "content": " hello ", + "message_type": 0, + "timestamp": "2026-07-21T10:00:00+08:00", + "message_scene": {"source": "default", "ext": ["msg_idx=REF1"]}, + "attachments": [ + { + "url": "https://multimedia.example.com/1.jpg", + "filename": "a.jpg", + "content_type": "image/jpeg", + "size": 10, + "width": 2, + "height": 3 + } + ] + } + """.trimIndent(), + ) + + private fun group(): org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFrame = framed( + "GROUP_AT_MESSAGE_CREATE", + """ + { + "id": "msg2", + "author": {"id": "m1", "member_openid": "member1", "member_role": "owner", "username": "小明"}, + "content": "hi", + "group_openid": "g1", + "message_type": 0, + "timestamp": "2026-07-21T10:01:00+08:00" + } + """.trimIndent(), + ) + + private fun card(): String = """ + { + "id": "msg3", + "author": {"id": "u1", "user_openid": "u1", "username": "Ada"}, + "content": "[卡片消息]", + "message_type": 3, + "ark_data": {"prompt": "[每日打卡]", "ark_type": "miniapp", "ark_name": "小程序"}, + "timestamp": "2026-07-21T10:02:00+08:00" + } + """.trimIndent() + + private fun quoted(): String = """ + { + "id": "msg4", + "author": {"id": "u1", "user_openid": "u1", "username": "Ada"}, + "content": "", + "message_type": 103, + "message_scene": {"source": "default", "ext": ["msg_idx=REF3", "ref_msg_idx=REF2"]}, + "msg_elements": [ + {"msg_idx": "REF2", "message_type": 0, "content": "每天坚持阅读半小时"} + ], + "timestamp": "2026-07-21T10:03:00+08:00" + } + """.trimIndent() +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/testing/FakePlatform.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/testing/FakePlatform.kt new file mode 100644 index 00000000..fd493dfc --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/testing/FakePlatform.kt @@ -0,0 +1,161 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.testing + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.time.Clock +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +/** A clock a test moves by hand. */ +internal class MutableClock(var at: Instant) : Clock() { + override fun instant(): Instant = at + + override fun getZone(): ZoneId = ZoneOffset.UTC + + override fun withZone(zone: ZoneId?): Clock = this +} + +/** One request a fake platform read. */ +internal class PlatformRequest( + val method: String, + val path: String, + val query: String, + val body: String, + val headers: Map, +) + +/** What a fake platform answers to one path. */ +internal class PlatformAnswer(val status: Int = 200, val body: String = "{}") + +/** An HTTP server that stands for the open platform's API and keeps what it read. */ +internal class FakeApiServer : AutoCloseable { + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + private val routes = CopyOnWriteArrayList PlatformAnswer>>() + private val requests = CopyOnWriteArrayList() + + val base: String + get() = "http://127.0.0.1:${server.address.port}" + + init { + server.createContext("/") { exchange -> answer(exchange) } + server.executor = java.util.concurrent.Executors.newCachedThreadPool() + server.start() + } + + /** Answers calls to [path] with [answer], which the last route added for a path wins. */ + fun on(path: String, answer: (PlatformRequest) -> PlatformAnswer) { + routes += path to answer + } + + /** Answers every call to [path] with [body] as JSON. */ + fun on(path: String, body: String, status: Int = 200) = on(path) { PlatformAnswer(status, body) } + + /** Every request read so far, oldest first. */ + fun requests(): List = requests.toList() + + /** The request calls of [path] read so far. */ + fun calls(path: String): List = requests.filter { it.path == path } + + /** The next request that [predicate] takes, waiting for it at most [timeoutMillis]. */ + fun awaitRequest(path: String, timeoutMillis: Long = 5_000): PlatformRequest? { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + while (System.nanoTime() < deadline) { + calls(path).lastOrNull()?.let { return it } + Thread.sleep(10) + } + return null + } + + private fun answer(exchange: HttpExchange) { + val body = exchange.requestBody.readBytes().decodeToString() + val request = PlatformRequest( + exchange.requestMethod, + exchange.requestURI.rawPath, + exchange.requestURI.rawQuery ?: "", + body, + exchange.requestHeaders.entries.associate { it.key to it.value.first() }, + ) + requests += request + val answer = routes.lastOrNull { it.first == request.path }?.second?.invoke(request) + ?: PlatformAnswer(404, """{"err_code":404,"message":"no route"}""") + val bytes = answer.body.toByteArray() + exchange.responseHeaders.add("Content-Type", "application/json; charset=utf-8") + exchange.sendResponseHeaders(answer.status, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + + override fun close() = server.stop(0) +} + +/** A gateway that stands for the platform's WebSocket: it says hello, takes an identify and pushes what it is told. */ +internal class FakeGatewayServer : WebSocketServer(InetSocketAddress("127.0.0.1", 0)) { + private val started = CountDownLatch(1) + private val connected = CountDownLatch(1) + private val received = LinkedBlockingQueue() + private val clients = CopyOnWriteArrayList() + + /** The address the channel connects to. */ + val url: String get() = "ws://127.0.0.1:$port" + + /** Starts the server and returns once it listens. */ + fun startAndAwait(): FakeGatewayServer { + start() + check(started.await(5, TimeUnit.SECONDS)) { "the fake gateway did not start" } + return this + } + + /** Whether a client connected within [timeoutMillis]. */ + fun awaitConnection(timeoutMillis: Long = 5_000): Boolean = connected.await(timeoutMillis, TimeUnit.MILLISECONDS) + + /** The next frame the client sent that [predicate] takes, waiting for it at most [timeoutMillis]. */ + fun awaitFrame(predicate: (String) -> Boolean = { true }, timeoutMillis: Long = 5_000): String? { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + val kept = mutableListOf() + while (System.nanoTime() < deadline) { + val frame = received.poll(20, TimeUnit.MILLISECONDS) ?: continue + kept += frame + if (predicate(frame)) return frame + } + return null + } + + /** Pushes [frame] to every connected client. */ + fun push(frame: String) { + clients.forEach { it.send(frame) } + } + + override fun onStart() { + started.countDown() + } + + override fun onOpen(connection: WebSocket, handshake: ClientHandshake) { + clients += connection + connected.countDown() + connection.send("""{"op":10,"d":{"heartbeat_interval":60000}}""") + } + + override fun onMessage(connection: WebSocket, message: String) { + received += message + if (message.contains("\"op\":2")) { + connection.send(READY) + } + } + + override fun onClose(connection: WebSocket, code: Int, reason: String, remote: Boolean) = Unit + + override fun onError(connection: WebSocket?, ex: Exception) = Unit + + private companion object { + const val READY = """{"op":0,"s":1,"t":"READY","d":{"version":1,"session_id":"s1",""" + + """"user":{"id":"bot1","username":"Bot","bot":true},"shard":[0,0]}}""" + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/testing/TestConfig.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/testing/TestConfig.kt new file mode 100644 index 00000000..1958c35b --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/testing/TestConfig.kt @@ -0,0 +1,23 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.testing + +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.sdk.config.Secret + +/** The config of one bot a test runs against [apiBase], with [gatewayUrl] when the test serves the gateway itself. */ +internal fun testConfig( + apiBase: String, + gatewayUrl: String? = null, + admins: Set = emptySet(), + syncLanguage: String? = null, +): QqBotInstanceConfig = QqBotInstanceConfig( + appId = "10000", + appSecret = Secret("secret"), + apiBase = apiBase, + gatewayUrl = gatewayUrl, + admins = admins, + syncLanguage = syncLanguage, + connectTimeoutMillis = 5_000, + firstByteTimeoutMillis = 5_000, + idleTimeoutMillis = 5_000, + reconnectIntervalMillis = 200, +) diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotToolsTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotToolsTest.kt new file mode 100644 index 00000000..64001a8b --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotToolsTest.kt @@ -0,0 +1,159 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.tools + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.channel.QqBotMessageIndex +import org.foedusprogramme.alexandrite.channel.qqbot.channel.QqBotSentMessages +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.MutableClock +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +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.testkit.TestToolContext +import org.foedusprogramme.alexandrite.testkit.testToolContext +import org.foedusprogramme.alexandrite.testkit.testTurn +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class QqBotToolsTest { + private val instance = ChannelInstanceId(ChannelType("qqbot"), "main") + private val group = ChatAddress(instance, "group:g1") + + @Test + fun `a read names its action and marks what it read as untrusted`() = runBlocking { + val platform = Platform() + platform.server.on("/v2/groups/g1/info", """{"group_name":"The group"}""") + try { + val context = context() + val result = platform.read.execute(action("group_info"), context) + + assertTrue(!result.isError, result.content.toString()) + assertTrue("The group" in result.content.toString(), result.content.toString()) + assertTrue(context.untrustedReasons.isNotEmpty(), "a read was not marked untrusted") + } finally { + platform.close() + } + } + + @Test + fun `a group is read by the openid of the chat rather than by an argument`() = runBlocking { + val platform = Platform() + platform.server.on("/v2/groups/g1/members", """{"members":[],"next_cursor":""}""") + try { + val result = platform.read.execute( + action("group_members", """{"cursor":"c1"}"""), + context(), + ) + + assertTrue(!result.isError, result.content.toString()) + val calls = platform.server.calls("/v2/groups/g1/members") + assertEquals(1, calls.size) + assertTrue("cursor=c1" in calls.single().query, calls.single().query) + } finally { + platform.close() + } + } + + @Test + fun `a permission the platform withholds is reported as an error`() = runBlocking { + val platform = Platform() + platform.server.on("/v2/groups/g1/info", """{"code":11253,"message":"the bot is not whitelisted"}""") + try { + val result = platform.read.execute(action("group_info"), context()) + + assertTrue(result.isError, result.content.toString()) + assertTrue("11253" in result.content.toString(), result.content.toString()) + } finally { + platform.close() + } + } + + @Test + fun `an action the table does not hold is refused`() = runBlocking { + val platform = Platform() + try { + val result = platform.read.execute(action("delete_everything"), context()) + + assertTrue(result.isError) + assertTrue("no action" in result.content.toString(), result.content.toString()) + } finally { + platform.close() + } + } + + @Test + fun `a message of the turn is taken back when the call names none`() = runBlocking { + val platform = Platform() + platform.server.on("/v2/groups/g1/messages/m1") { + org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformAnswer(200, "{}") + } + try { + val result = platform.act.execute( + action("recall", """{"message_id":"m1"}"""), + context(), + ) + + assertTrue(!result.isError, result.content.toString()) + val call = platform.server.calls("/v2/groups/g1/messages/m1").single() + assertEquals("DELETE", call.method) + } finally { + platform.close() + } + } + + @Test + fun `a recall without an id takes back the bot's last message`() = runBlocking { + val platform = Platform() + platform.server.on("/v2/groups/g1/messages/bot1") { + org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformAnswer(200, "{}") + } + platform.sent.record(group, "bot1") + try { + val result = platform.act.execute(action("recall"), context()) + + assertTrue(!result.isError, result.content.toString()) + assertEquals("DELETE", platform.server.calls("/v2/groups/g1/messages/bot1").single().method) + } finally { + platform.close() + } + } + + private fun context(): TestToolContext = testToolContext(turn = testTurn(chat = group)) + + private fun action(name: String, params: String = "{}"): JsonObject = + org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire.json.parseToJsonElement( + """{"action":"$name","params":$params}""", + ) as JsonObject + + /** An API of the fake platform, and the tools that talk through it. */ + private inner class Platform : AutoCloseable { + val server = FakeApiServer() + private val http: QqBotHttp + val apis = QqBotApis() + val sent = QqBotSentMessages() + val index = QqBotMessageIndex() + val read: QqBotReadTool + val act: QqBotActTool + + init { + server.on("/app/getAppAccessToken", """{"access_token":"access","expires_in":"7200"}""") + val config = testConfig(server.base) + http = QqBotHttp(config) + val tokens = QqBotTokens(config, http, MutableClock(Instant.parse("2026-07-21T00:00:00Z"))) + apis.register(instance, QqBotApi(http, tokens)) + read = QqBotReadTool(apis) + act = QqBotActTool(apis, sent, index) + } + + override fun close() { + http.close() + server.close() + } + } +} From e8bd05dd861244d650b50d2d41a0fa239b507ef3 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 01:37:50 +0800 Subject: [PATCH 14/37] app: Load and document the OneBot and QQ bot channels Wires both channel plugins into the app, their example config and Chinese texts, and updates the tests that list the built-in plugins. --- app/build.gradle.kts | 2 + app/src/dist/config/alexandrite.example.json | 32 +++++++ .../zh-CN.properties | 85 +++++++++++++++++++ .../zh-CN.properties | 15 ++++ .../alexandrite/app/AppTextsTest.kt | 2 + .../alexandrite/app/TelegramPermissionTest.kt | 7 +- 6 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-onebot/zh-CN.properties create mode 100644 app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-qqbot/zh-CN.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b5361d83..2d70c498 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,6 +11,8 @@ dependencies { implementation(project(":libraries:commands")) implementation(project(":libraries:channels:telegram")) implementation(project(":libraries:channel-common")) + implementation(project(":libraries:channels:onebot")) + implementation(project(":libraries:channels:qqbot")) implementation(project(":libraries:providers:openai-compatible")) implementation(project(":libraries:providers:openai-responses")) implementation(project(":libraries:providers:deepseek")) diff --git a/app/src/dist/config/alexandrite.example.json b/app/src/dist/config/alexandrite.example.json index dd3258a4..e4e6055b 100644 --- a/app/src/dist/config/alexandrite.example.json +++ b/app/src/dist/config/alexandrite.example.json @@ -5,5 +5,37 @@ "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, + "admins": [], + "approvalMinutes": 10 + } + } + }, + "qqbot": { + "enabled": false, + "instances": { + "main": { + "appId": "10000", + "appSecret": "replace-with-the-app-secret", + "apiBase": "https://api.bot.qq.com", + "intents": 33554432, + "streaming": true, + "approvalMinutes": 10, + "admins": [] + } + } + } } } diff --git a/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-onebot/zh-CN.properties b/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-onebot/zh-CN.properties new file mode 100644 index 00000000..5e6ae092 --- /dev/null +++ b/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-onebot/zh-CN.properties @@ -0,0 +1,85 @@ +approval.title=需要批准(#{id}) +approval.agent=智能体:{agent} +approval.chat=聊天:{chat} +approval.requester=发起人:{requester} +approval.reason=原因:{reason} +approval.tool=工具:{tool}({risk}) +approval.file=文件:{path} +approval.directory=目录:{directory} +approval.program=程序:{program} +approval.program_unknown=未找到 +approval.opaque=不透明:变石看不到这个程序运行了什么。 +approval.arguments=参数: +approval.answer=回复本条消息,发送 /approve 或 /deny。 +approval.always_rule=永久允许将授予:{rule} +approval.outcome_allowed=批准 {id} 已由 {name} 允许。 +approval.outcome_allowed_always=批准 {id} 已由 {name} 永久允许,同时授予 {rule}。 +approval.outcome_denied=批准 {id} 已由 {name} 拒绝。 +approval.outcome_expired=批准 {id} 已过期,无人应答。 +approval.outcome_withdrawn=批准 {id} 已撤回,不再等待应答。 +approval.admins_only=只有本实例的管理员可以应答。 +approval.not_pending=该请求已不再等待应答。 +approval.nothing_waits=本聊天没有等待中的批准请求。 +approval.list_title=本聊天等待中的批准请求: +approval.list_entry=- {id} {tool} {reason} +approval.list_hint=回复其中一条消息,发送 /approve 或 /deny。 +approval.several=这没有指出唯一的请求:{ids}。请写出所需那一个的完整 id。 +refused.admin=只有本实例的管理员可以使用 /{command}。 +refused.admin_here=只有本实例的管理员可以在本聊天使用 /{command}。 +pairing.code=本聊天尚未与变石配对。请把配对码 {code} 交给运维方,该码 {minutes} 分钟内有效。 +activity.verb.read.present=正在读取 +activity.verb.read.past=读取了 +activity.verb.list.present=正在列出 +activity.verb.list.past=列出了 +activity.verb.search.present=正在进行 +activity.verb.search.past=进行了 +activity.verb.edit.present=正在编辑 +activity.verb.edit.past=编辑了 +activity.verb.delete.present=正在删除 +activity.verb.delete.past=删除了 +activity.verb.move.present=正在移动 +activity.verb.move.past=移动了 +activity.verb.execute.present=正在运行 +activity.verb.execute.past=运行了 +activity.verb.fetch.present=正在获取 +activity.verb.fetch.past=获取了 +activity.verb.think.present=正在思考 +activity.verb.think.past=思考了 +activity.verb.other.present=正在使用 +activity.verb.other.past=使用了 +activity.noun.read.one={count} 个文件 +activity.noun.read.many={count} 个文件 +activity.noun.list.one={count} 个目录 +activity.noun.list.many={count} 个目录 +activity.noun.search.one={count} 次搜索 +activity.noun.search.many={count} 次搜索 +activity.noun.edit.one={count} 个文件 +activity.noun.edit.many={count} 个文件 +activity.noun.delete.one={count} 个文件 +activity.noun.delete.many={count} 个文件 +activity.noun.move.one={count} 个文件 +activity.noun.move.many={count} 个文件 +activity.noun.execute.one={count} 条命令 +activity.noun.execute.many={count} 条命令 +activity.noun.fetch.one={count} 个网页 +activity.noun.fetch.many={count} 个网页 +activity.noun.think.one={count} 步 +activity.noun.think.many={count} 步 +activity.noun.other.one={count} 个工具 +activity.noun.other.many={count} 个工具 +activity.verb.send.present=正在发送 +activity.verb.send.past=发送了 +activity.noun.send.one={count} 个文件 +activity.noun.send.many={count} 个文件 +activity.summary.not_run=未执行:{noun} +activity.omitted.one=较早的 {count} 项已省略 +activity.omitted.many=较早的 {count} 项已省略 +activity.call.waiting_approval=等待批准:{tool} {title} +activity.call.not_run=未执行:{tool} {title} +activity.call.backgrounded=已转入后台:{tool} {title} +activity.call.exit=退出码 {code} +activity.call.failed=失败 +activity.call.diff=+{added} -{removed} +activity.delegate.present=正在委派给 {agent} +activity.delegate.past=已委派给 {agent} +activity.call.by_agent={agent} · {line} \ No newline at end of file diff --git a/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-qqbot/zh-CN.properties b/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-qqbot/zh-CN.properties new file mode 100644 index 00000000..551d83b4 --- /dev/null +++ b/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-qqbot/zh-CN.properties @@ -0,0 +1,15 @@ +approval.title=需要批准(#{id}) +approval.tool=工具:{tool}({risk}) +approval.reason=原因:{reason} +approval.arguments=参数: +approval.allow=允许 +approval.deny=拒绝 +approval.always=始终允许:{rule} +approval.allowed=已允许(#{id}) +approval.denied=已拒绝(#{id}) +approval.expired=审批 #{id} 已过期 +approval.admins_only=只有管理员可以处理这条审批。 +approval.nothing_waiting=没有等待中的这条审批。 +approval.unreachable=没能把这条审批发给任何管理员。 +refused.admin=只有本实例的管理员可以使用 /{command}。 +refused.admin_here=只有本实例的管理员可以在本聊天使用 /{command}。 diff --git a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/AppTextsTest.kt b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/AppTextsTest.kt index 887a8062..4b57f30f 100644 --- a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/AppTextsTest.kt +++ b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/AppTextsTest.kt @@ -86,6 +86,8 @@ class AppTextsTest { "alexandrite-automation-heartbeat", "alexandrite-automation-schedule", "alexandrite-channel-telegram", + "alexandrite-channel-onebot", + "alexandrite-channel-qqbot", "alexandrite-commands", ), packs.plugins, diff --git a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/TelegramPermissionTest.kt b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/TelegramPermissionTest.kt index a1e9d8f5..38cc1b24 100644 --- a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/TelegramPermissionTest.kt +++ b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/TelegramPermissionTest.kt @@ -121,6 +121,7 @@ class TelegramPermissionTest { app.model.reply { toolCall("notes.write", """{"text": "Buy milk."}""") }.reply { text("Noted.") } .reply { text("It ran.") } .reply { text("It did.") } + lateinit var noted: FakeBotMessage lateinit var asked: FakeBotMessage var afterRequest: ConversationTaint? = null var afterAnswer: ConversationTaint? = null @@ -129,7 +130,7 @@ class TelegramPermissionTest { app.send(app.ada, app.adaChat, "Note that I need milk.") asked = app.fake.await(WAIT) { botMessages(ADA).firstOrNull { it.buttons.isNotEmpty() } } app.fake.press(app.ada, asked, "Allow") - val noted = app.message(app.adaChat, "Noted.") + noted = app.message(app.adaChat, "Noted.") idle() val conversation = get().current(app.key(app.adaChat)).id app.fake.send(app.ada, app.adaChat, "Did it run?", replyTo = asked.id) @@ -145,11 +146,11 @@ class TelegramPermissionTest { val id = Regex("#([a-z0-9]+)").find(asked.text)!!.groupValues[1] assertContains( framing("Did it run?"), - "\nQuoting: Alexandrite Test Bot (verified)\n" + + "\nQuoting: Alexandrite Test Bot (verified) [id ${asked.id}]\n" + "> Alexandrite's approval request $id for notes.write (allowed by Ada Lovelace (@ada_test))\n", ) assertNull(afterRequest) - assertContains(framing("Really?"), "\nQuoting: Alexandrite Test Bot (verified)\n> Noted.\n") + assertContains(framing("Really?"), "\nQuoting: Alexandrite Test Bot (verified) [id ${noted.id}]\n> Noted.\n") assertNull(afterAnswer) } From a48dd51c0fcade143b4c676817f9bf5fd017e2cb Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 01:37:55 +0800 Subject: [PATCH 15/37] onebot, qqbot: Add the English texts Adds the channel text catalogues the plugins read their replies from. --- .../alexandrite-channel-onebot/en.properties | 85 +++++++++++++++++++ .../alexandrite-channel-qqbot/en.properties | 15 ++++ 2 files changed, 100 insertions(+) create mode 100644 libraries/channels/onebot/src/main/resources/alexandrite/i18n/alexandrite-channel-onebot/en.properties create mode 100644 libraries/channels/qqbot/src/main/resources/alexandrite/i18n/alexandrite-channel-qqbot/en.properties diff --git a/libraries/channels/onebot/src/main/resources/alexandrite/i18n/alexandrite-channel-onebot/en.properties b/libraries/channels/onebot/src/main/resources/alexandrite/i18n/alexandrite-channel-onebot/en.properties new file mode 100644 index 00000000..e8520ab7 --- /dev/null +++ b/libraries/channels/onebot/src/main/resources/alexandrite/i18n/alexandrite-channel-onebot/en.properties @@ -0,0 +1,85 @@ +approval.title=Approval needed (#{id}) +approval.agent=Agent: {agent} +approval.chat=Chat: {chat} +approval.requester=Requested by: {requester} +approval.reason=Why: {reason} +approval.tool=Tool: {tool} ({risk}) +approval.file=File: {path} +approval.directory=Directory: {directory} +approval.program=Program: {program} +approval.program_unknown=not found +approval.opaque=Opaque: Alexandrite cannot see what this program runs. +approval.arguments=Arguments: +approval.answer=Reply to this message with /approve or /deny. +approval.always_rule=Allowing it for good grants: {rule} +approval.outcome_allowed=Approval {id} is allowed by {name}. +approval.outcome_allowed_always=Approval {id} is allowed for good by {name}, which also grants {rule}. +approval.outcome_denied=Approval {id} is denied by {name}. +approval.outcome_expired=Approval {id} expired, so nobody answered it. +approval.outcome_withdrawn=Approval {id} was withdrawn, so it no longer waits for an answer. +approval.admins_only=Only an admin of this bot can answer. +approval.not_pending=This request no longer waits for an answer. +approval.nothing_waits=No approval request waits in this chat. +approval.list_title=Approval requests waiting here: +approval.list_entry=- {id} {tool} {reason} +approval.list_hint=Reply to the message of one with /approve or /deny. +approval.several=That names no one request: {ids}. Write the whole id of the one meant. +refused.admin=Only an admin of this bot may use /{command}. +refused.admin_here=Only an admin of this bot may use /{command} in this chat. +pairing.code=This chat is not paired with this bot yet. To pair it, give the bot's operator the code {code}, which is valid for {minutes} minutes. +activity.verb.read.present=Reading +activity.verb.read.past=Read +activity.verb.list.present=Listing +activity.verb.list.past=Listed +activity.verb.search.present=Running +activity.verb.search.past=Ran +activity.verb.edit.present=Editing +activity.verb.edit.past=Edited +activity.verb.delete.present=Deleting +activity.verb.delete.past=Deleted +activity.verb.move.present=Moving +activity.verb.move.past=Moved +activity.verb.execute.present=Running +activity.verb.execute.past=Ran +activity.verb.fetch.present=Fetching +activity.verb.fetch.past=Fetched +activity.verb.think.present=Thinking +activity.verb.think.past=Thought +activity.verb.other.present=Using +activity.verb.other.past=Used +activity.noun.read.one={count} file +activity.noun.read.many={count} files +activity.noun.list.one={count} directory +activity.noun.list.many={count} directories +activity.noun.search.one={count} search +activity.noun.search.many={count} searches +activity.noun.edit.one={count} file +activity.noun.edit.many={count} files +activity.noun.delete.one={count} file +activity.noun.delete.many={count} files +activity.noun.move.one={count} file +activity.noun.move.many={count} files +activity.noun.execute.one={count} shell command +activity.noun.execute.many={count} shell commands +activity.noun.fetch.one={count} page +activity.noun.fetch.many={count} pages +activity.noun.think.one={count} step +activity.noun.think.many={count} steps +activity.noun.other.one={count} tool +activity.noun.other.many={count} tools +activity.verb.send.present=Sending +activity.verb.send.past=Sent +activity.noun.send.one={count} file +activity.noun.send.many={count} files +activity.summary.not_run=Not run: {noun} +activity.omitted.one={count} earlier call not shown +activity.omitted.many={count} earlier calls not shown +activity.call.waiting_approval=Waiting for approval: {tool} {title} +activity.call.not_run=Not run: {tool} {title} +activity.call.backgrounded=Moved to the background: {tool} {title} +activity.call.exit=exit {code} +activity.call.failed=failed +activity.call.diff=+{added} -{removed} +activity.delegate.present=Delegating to {agent} +activity.delegate.past=Delegated to {agent} +activity.call.by_agent={agent} · {line} \ No newline at end of file diff --git a/libraries/channels/qqbot/src/main/resources/alexandrite/i18n/alexandrite-channel-qqbot/en.properties b/libraries/channels/qqbot/src/main/resources/alexandrite/i18n/alexandrite-channel-qqbot/en.properties new file mode 100644 index 00000000..aac31c8a --- /dev/null +++ b/libraries/channels/qqbot/src/main/resources/alexandrite/i18n/alexandrite-channel-qqbot/en.properties @@ -0,0 +1,15 @@ +approval.title=Approval needed (#{id}) +approval.tool=Tool: {tool} ({risk}) +approval.reason=Why: {reason} +approval.arguments=Arguments: +approval.allow=Allow +approval.deny=Deny +approval.always=Always allow: {rule} +approval.allowed=Allowed (#{id}) +approval.denied=Denied (#{id}) +approval.expired=The approval #{id} expired +approval.admins_only=Only an administrator may answer this. +approval.nothing_waiting=No approval waits for that. +approval.unreachable=No administrator could be reached for this approval. +refused.admin=Only an admin of this bot may use /{command}. +refused.admin_here=Only an admin of this bot may use /{command} in this chat. From 1b84b04ee11ea680bad3bdc97e3e1cc860324761 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 02:28:42 +0800 Subject: [PATCH 16/37] qqbot: Register an approval recipient before sending its message The platform can send a button press as soon as the approval message is delivered, which may be before sendApproval returns. Register the chat before the send, remove it when delivery fails, and hold the send in the integration test so the press arrives during it. --- .../channel/qqbot/channel/QqBotApprovals.kt | 5 ++++- .../qqbot/QqBotChannelIntegrationTest.kt | 21 +++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotApprovals.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotApprovals.kt index fb7bdc8c..99617b57 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotApprovals.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotApprovals.kt @@ -105,6 +105,9 @@ internal class QqBotApprovals( val request = waiting.request val shown = mutableListOf() for (chat in recipients(request)) { + // Register the chat before the platform can send its press: the button can be pressed as soon as the + // message is delivered, which may be before sendApproval returns here. + waiting.chats += chat val delivery = sender.sendApproval( chat, render(request), @@ -114,9 +117,9 @@ internal class QqBotApprovals( request.always?.let { text(TextKeys.APPROVAL_ALWAYS, request.turn.language, "rule" to it.display) }, ) if (delivery is Delivery.Delivered) { - waiting.chats += chat shown += chat } else { + waiting.chats.remove(chat) logger.warn("{}: approval {} was not shown in {}: {}", instance.id, request.id, chat, delivery) } } diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt index 96e9d192..e5b90b3d 100644 --- a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt @@ -4,9 +4,11 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import org.foedusprogramme.alexandrite.channel.qqbot.channel.QQ_BOT_MAX_FILE_BYTES import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeGatewayServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformAnswer import org.foedusprogramme.alexandrite.sdk.InternalAlexandriteApi import org.foedusprogramme.alexandrite.sdk.channel.ApprovalAnswer import org.foedusprogramme.alexandrite.sdk.channel.ApprovalRequest @@ -33,11 +35,14 @@ import org.foedusprogramme.alexandrite.sdk.turn.Submission import org.foedusprogramme.alexandrite.testkit.PluginHarness import org.foedusprogramme.alexandrite.testkit.RecordingTurnSubmitter import org.foedusprogramme.alexandrite.testkit.testTurn +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.assertTrue +import kotlin.time.Duration.Companion.seconds /** * The QQ bot channel inside a real runtime. @@ -273,7 +278,13 @@ class QqBotChannelIntegrationTest { fun `an approval is asked with buttons and answered by a press`() { val api = FakeApiServer() api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") - api.on(SEND_DIRECT, """{"id":"sent1","timestamp":"2026-07-21T10:00:00+08:00"}""") + val sendReached = CountDownLatch(1) + val releaseSend = CountDownLatch(1) + api.on(SEND_DIRECT) { + sendReached.countDown() + check(releaseSend.await(5, TimeUnit.SECONDS)) { "the test did not release the approval send" } + PlatformAnswer(200, """{"id":"sent1","timestamp":"2026-07-21T10:00:00+08:00"}""") + } val platform = FakeGatewayServer().startAndAwait() val submitter = RecordingTurnSubmitter() try { @@ -297,6 +308,7 @@ class QqBotChannelIntegrationTest { null, ) val waiting = async(start = CoroutineStart.UNDISPATCHED) { channel.askApproval(request) } + assertTrue(sendReached.await(5, TimeUnit.SECONDS), "the approval message was not sent") val shown = assertNotNull(api.awaitRequest(SEND_DIRECT)) assertTrue("\"msg_type\":2" in shown.body, shown.body) assertTrue("\"markdown\"" in shown.body, shown.body) @@ -306,11 +318,16 @@ class QqBotChannelIntegrationTest { assertTrue("\"enter\":true" in shown.body, shown.body) platform.push(APPROVAL_PRESS) - val answer = waiting.await() + // Let the inbox handle the press while sendApproval is still waiting for its answer. The + // recipient has to be known before this point; otherwise the press is silently dropped. + Thread.sleep(100) + releaseSend.countDown() + val answer = withTimeout(10.seconds) { waiting.await() } assertIs(answer) } } } finally { + releaseSend.countDown() platform.stop() api.close() } From 6352e8596de661267f6ed937e9f1c1c06c76e3a6 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 18:57:37 +0800 Subject: [PATCH 17/37] qqbot: Serialize a reply and close its stream by chat An approval can be asked by a delegated run, so close every stream open in the approval's chat instead of only the asking turn's. Serialize the reply's stream and detached-run state, and close a stream before a final message carrying files, which the stream cannot carry. --- .../channel/qqbot/channel/QqBotApprovals.kt | 2 +- .../channel/qqbot/channel/QqBotChannel.kt | 91 +++++++++++-------- .../channel/qqbot/channel/QqBotProgress.kt | 26 ++++-- .../channel/qqbot/channel/QqBotStream.kt | 77 +++++++++++----- .../qqbot/QqBotChannelIntegrationTest.kt | 37 ++++++++ .../qqbot/channel/QqBotProgressTest.kt | 2 +- 6 files changed, 162 insertions(+), 73 deletions(-) diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotApprovals.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotApprovals.kt index 99617b57..65284586 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotApprovals.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotApprovals.kt @@ -54,7 +54,7 @@ internal class QqBotApprovals( } // The answer comes after the approval message, so the turn's stream ends here rather than showing it above // the request. - progress.defer(request.turn.id) + progress.deferChat(request.turn.chat) logger.info( "{} asks for approval {} of {} in turn {} in {} chat(s)", instance.id, diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotChannel.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotChannel.kt index 604f5b12..de5b593f 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotChannel.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotChannel.kt @@ -1,5 +1,7 @@ package org.foedusprogramme.alexandrite.channel.qqbot.channel +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotChats import org.foedusprogramme.alexandrite.sdk.channel.ApprovalAnswer @@ -96,6 +98,9 @@ internal class QqBotChannel( private var noticedAt: Long = 0 private var noticed: Boolean = false + /** Serializes this reply's stream, answer and detached-run state across its coroutines. */ + private val lock = Mutex() + /** The background runs whose activity this reply still shows after its own answer. */ private val detached = LinkedHashMap() @@ -105,19 +110,19 @@ internal class QqBotChannel( private val streaming = config.streaming && QqBotChats.kind(request.turn.chat) == ChatKind.DIRECT && request.trigger != null - override suspend fun detachActivity(run: DelegatedRun): ReplyActivity? { - if (progress.isDeferred(request.turn.id) || run.call.value !in activities) return null - return Detached(run.run).also { detached[run.run] = it } + override suspend fun detachActivity(run: DelegatedRun): ReplyActivity? = lock.withLock { + if (progress.isDeferred(request.turn.id) || run.call.value !in activities) return@withLock null + Detached(run.run).also { detached[run.run] = it } } /** The activity of a background run, shown after the reply that delegated it ended. */ private inner class Detached(private val run: RunId) : ReplyActivity { private var closed = false - override suspend fun event(event: TurnEvent) { - if (closed) return - val started = event as? TurnEvent.ToolCallStarted ?: return - if (started.run != run) return + override suspend fun event(event: TurnEvent) = lock.withLock { + if (closed) return@withLock + val started = event as? TurnEvent.ToolCallStarted ?: return@withLock + if (started.run != run) return@withLock val line = activityLine(started) val current = stream if (streaming && current != null && !progress.isDeferred(request.turn.id)) { @@ -135,8 +140,8 @@ internal class QqBotChannel( } } - override suspend fun close(status: ToolCallStatus) { - if (closed) return + override suspend fun close(status: ToolCallStatus) = lock.withLock { + if (closed) return@withLock closed = true detached.remove(run) if (detached.isEmpty() && parentFinal != null) { @@ -146,17 +151,17 @@ internal class QqBotChannel( } } - override suspend fun preview(segment: Int, text: String) { + override suspend fun preview(segment: Int, text: String) = lock.withLock { if (!streaming) { logger.debug( "The preview of segment {} of {} is not sent: a group message of this platform is not changed", segment, request.turn.id, ) - return + return@withLock } - if (text.isEmpty() || progress.isDeferred(request.turn.id)) return - val trigger = request.trigger ?: return + if (text.isEmpty() || progress.isDeferred(request.turn.id)) return@withLock + val trigger = request.trigger ?: return@withLock val current = streamOf(trigger) val chunk = next(answer, text) answer = text @@ -178,12 +183,12 @@ internal class QqBotChannel( ) } - override suspend fun event(event: TurnEvent) { - if (!config.progress || progress.isDeferred(request.turn.id)) return - val started = event as? TurnEvent.ToolCallStarted ?: return + override suspend fun event(event: TurnEvent) = lock.withLock { + if (!config.progress || progress.isDeferred(request.turn.id)) return@withLock + val started = event as? TurnEvent.ToolCallStarted ?: return@withLock activities[started.id.value] = activityLine(started) if (streaming) { - val trigger = request.trigger ?: return + val trigger = request.trigger ?: return@withLock val current = streamOf(trigger) val line = if (current.shownText.isEmpty()) activityLine(started) else "\n\n" + activityLine(started) current.append(line) @@ -194,32 +199,42 @@ internal class QqBotChannel( } } - override suspend fun complete(message: OutboundMessage): Delivery = try { - if (!streaming) tellWaiting() - if (streaming && !progress.isDeferred(request.turn.id)) { - val trigger = request.trigger - if (trigger != null) { - val current = streamOf(trigger) - val chunk = next(answer, message.text) - if (chunk.isNotEmpty()) current.append(chunk) - if (detached.isNotEmpty()) { - parentFinal = message.text - val id = current.messageId - if (id != null) return Delivery.Delivered(listOf(ChannelMessageRef(request.turn.chat, id))) - } else { - val finished = current.finishAppended() - if (finished != null) return finished + override suspend fun complete(message: OutboundMessage): Delivery = lock.withLock { + try { + if (message.files.isNotEmpty()) { + // A file message of this platform has no caption, and the stream cannot carry the file. Close the + // stream where it is and send the files as the message the platform takes. + stream?.abandon() + return@withLock sender.send(request.turn.chat, withTrigger(message)) + } + if (!streaming) tellWaiting() + if (streaming && !progress.isDeferred(request.turn.id)) { + val trigger = request.trigger + if (trigger != null) { + val current = streamOf(trigger) + val chunk = next(answer, message.text) + if (chunk.isNotEmpty()) current.append(chunk) + if (detached.isNotEmpty()) { + parentFinal = message.text + val id = current.messageId + if (id != null) { + return@withLock Delivery.Delivered(listOf(ChannelMessageRef(request.turn.chat, id))) + } + } else { + val finished = current.finishAppended() + if (finished != null) return@withLock finished + } } } + sender.send(request.turn.chat, withTrigger(message)) + } finally { + replies.close(request.turn.id) + progress.done(request.turn.id) + typing.stop(request.turn.chat) } - sender.send(request.turn.chat, withTrigger(message)) - } finally { - replies.close(request.turn.id) - progress.done(request.turn.id) - typing.stop(request.turn.chat) } - override suspend fun abandon(end: ReplyEnd) { + override suspend fun abandon(end: ReplyEnd) = lock.withLock { logger.info("The reply of {} was abandoned: {}", request.turn.id, end) replies.close(request.turn.id) progress.abandon(request.turn.id) diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgress.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgress.kt index f6fc10c4..b7e0957c 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgress.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgress.kt @@ -33,16 +33,22 @@ internal class QqBotProgress { } /** - * Closes the stream of [turn] before an approval message, whose answer then arrives as an ordinary message after it. + * Closes every open stream of [chat] before an approval message. * - * A streaming message cannot be moved below the approval request once more of it is written, so the turn's final - * answer is sent as a message of its own instead. + * An approval can be asked by a delegated run whose turn is not the turn that opened the stream. The chat is + * what the stream and the approval have in common, so an approval defers all replies open in it. */ - suspend fun defer(turn: TurnId) { - gate(turn).withLock { - open.remove(turn)?.let { entry -> entry.stream.finish(entry.stream.shownText) } - deferred += turn + suspend fun deferChat(chat: ChatAddress): Int { + var closed = 0 + for (turn in open.entries.filter { it.value.chat == chat }.map { it.key }) { + gate(turn).withLock { + val entry = open.remove(turn) ?: return@withLock + entry.stream.finish() + deferred += turn + closed++ + } } + return closed } /** Whether the final answer of [turn] is to be sent outside its stream, after an approval request. */ @@ -52,11 +58,15 @@ internal class QqBotProgress { fun done(turn: TurnId) { deferred -= turn open.remove(turn) + gates.remove(turn) } /** Forgets [turn], which ended without a stream to close. */ suspend fun abandon(turn: TurnId) { deferred -= turn - gate(turn).withLock { open.remove(turn) } + gate(turn).withLock { + open.remove(turn) + gates.remove(turn) + } } } diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotStream.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotStream.kt index 96e928b0..e6f03591 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotStream.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotStream.kt @@ -1,5 +1,7 @@ package org.foedusprogramme.alexandrite.channel.qqbot.channel +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer import org.foedusprogramme.alexandrite.sdk.channel.Delivery import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress @@ -17,10 +19,17 @@ internal class QqBotStream( private val msgId: String, private val msgSeq: Int, ) { - private var streamId: String? = null - private var index = 0 - private var shown = "" - private var failed = false + private val lock = Mutex() + + @Volatile private var streamId: String? = null + + @Volatile private var index = 0 + + @Volatile private var shown = "" + + @Volatile private var failed = false + + @Volatile private var finished = false /** The id of the platform message this stream writes, null before the first piece was taken. */ val messageId: String? get() = streamId @@ -29,58 +38,75 @@ internal class QqBotStream( val shownText: String get() = shown /** Shows [text], the whole text of the streaming message so far; false once the platform refuses. */ - suspend fun show(text: String): Boolean { - if (failed || text.isEmpty()) return false + suspend fun show(text: String): Boolean = lock.withLock { + if (failed || finished || text.isEmpty()) return@withLock false val delta = delta(text) - if (delta.isEmpty()) return true + if (delta.isEmpty()) return@withLock true val answer = sender.stream(chat, delta, msgId, msgSeq, streamId, index, last = false, append = true) if (answer !is QqBotAnswer.Ok) { failed = true - return false + return@withLock false } val id = sender.sentId(answer) ?: run { failed = true - return false + return@withLock false } streamId = id shown += delta index++ - return true + true } /** Ends the streaming message with [text], the whole answer; null when it was never started or is refused. */ - suspend fun finish(text: String): Delivery? { - if (failed) return null + suspend fun finish(text: String): Delivery? = lock.withLock { finishLocked(text) } + + /** Ends the streaming message with what it already showed. */ + suspend fun finish(): Delivery? = lock.withLock { finishLocked(shown) } + + private suspend fun finishLocked(text: String): Delivery? { + if (failed || finished) return null val id = streamId ?: return null val delta = delta(text.ifBlank { shown }) val answer = sender.stream(chat, delta, msgId, msgSeq, id, index, last = true, append = true) - return if (answer is QqBotAnswer.Ok) sender.delivery(chat, answer) else null + return if (answer is QqBotAnswer.Ok) { + finished = true + sender.delivery(chat, answer) + } else { + failed = true + null + } } /** Appends [text] as the next piece, without comparing it with what was shown. */ - suspend fun append(text: String): Boolean { - if (failed || text.isEmpty()) return false + suspend fun append(text: String): Boolean = lock.withLock { + if (failed || finished || text.isEmpty()) return@withLock false val answer = sender.stream(chat, text, msgId, msgSeq, streamId, index, last = false, append = true) if (answer !is QqBotAnswer.Ok) { failed = true - return false + return@withLock false } val id = sender.sentId(answer) ?: run { failed = true - return false + return@withLock false } streamId = id shown += text index++ - return true + true } /** Ends the streaming message after its pieces were appended. */ - suspend fun finishAppended(): Delivery? { - if (failed) return null - val id = streamId ?: return null + suspend fun finishAppended(): Delivery? = lock.withLock { + if (failed || finished) return@withLock null + val id = streamId ?: return@withLock null val answer = sender.stream(chat, "", msgId, msgSeq, id, index, last = true, append = true) - return if (answer is QqBotAnswer.Ok) sender.delivery(chat, answer) else null + if (answer is QqBotAnswer.Ok) { + finished = true + sender.delivery(chat, answer) + } else { + failed = true + null + } } /** @@ -97,9 +123,10 @@ internal class QqBotStream( } /** Ends a stream that is not carried on, so the chat does not show it as still being written. */ - suspend fun abandon() { - val id = streamId ?: return - if (failed) return + suspend fun abandon() = lock.withLock { + val id = streamId ?: return@withLock + if (failed || finished) return@withLock + finished = true runCatching { sender.stream(chat, shown, msgId, msgSeq, id, index, last = true) } } } diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt index e5b90b3d..19459502 100644 --- a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt @@ -17,6 +17,7 @@ import org.foedusprogramme.alexandrite.sdk.channel.DelegatedRun import org.foedusprogramme.alexandrite.sdk.channel.Delivery import org.foedusprogramme.alexandrite.sdk.channel.Markup import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundFile import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage import org.foedusprogramme.alexandrite.sdk.channel.ReplyRequest import org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus @@ -273,6 +274,42 @@ class QqBotChannelIntegrationTest { } } + @Test + fun `a streamed answer with a file sends the file and closes the stream`() = runBlocking { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(STREAM_DIRECT, """{"id":"stream1","timestamp":"2026-07-21T10:00:00+08:00"}""") + api.on("/v2/users/u1/files", """{"file_info":"FI","ttl":300}""") + api.on(SEND_DIRECT, """{"id":"media1","timestamp":"2026-07-21T10:00:00+08:00"}""") + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + val channel = get(directory).channel(instance) ?: error("the instance has no channel") + val chat = ChatAddress(instance, "private:u1") + val sink = channel.openReply(ReplyRequest(testTurn(chat = chat), ChannelMessageRef(chat, "msg1"))) + sink.preview(0, "draft") + val file = OutboundFile.builder("a.png", "image/png", "hello".toByteArray()).build() + val delivery = sink.complete( + OutboundMessage.builder("a caption", MessageKind.REPLY).files(listOf(file)).build(), + ) + + assertIs(delivery) + assertTrue(api.calls(STREAM_DIRECT).any { "\"input_state\":10" in it.body }) + assertEquals(1, api.calls("/v2/users/u1/files").size) + assertTrue(api.calls(SEND_DIRECT).any { "\"msg_type\":7" in it.body }) + } + } finally { + platform.stop() + api.close() + } + } + @OptIn(InternalAlexandriteApi::class) @Test fun `an approval is asked with buttons and answered by a press`() { diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgressTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgressTest.kt index 9b8fb4ec..6c87196f 100644 --- a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgressTest.kt +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgressTest.kt @@ -31,7 +31,7 @@ class QqBotProgressTest { progress.attach(turn, direct, stream) stream.show("working") - progress.defer(turn) + assertEquals(1, progress.deferChat(direct)) assertTrue(progress.isDeferred(turn)) val pieces = platform.server.calls(STREAM_DIRECT) From 9a9a4953afec000900228dabf4515812de40ecad Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 18:57:41 +0800 Subject: [PATCH 18/37] onebot, qqbot: Match the command rules with the other channels Adds the commands the Telegram rules already have: heartbeat and schedule, timezone, home, link, unlink and untaint. A member could otherwise be refused a command Telegram allows. --- .../channel/onebot/channel/OneBotCommandRules.kt | 7 +++++++ .../channel/onebot/channel/OneBotCommandRulesTest.kt | 12 ++++++++++++ .../channel/qqbot/channel/QqBotCommandRules.kt | 7 +++++++ .../channel/qqbot/channel/QqBotCommandRulesTest.kt | 12 ++++++++++++ 4 files changed, 38 insertions(+) diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRules.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRules.kt index b4d54f05..0f48cacb 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRules.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRules.kt @@ -92,6 +92,13 @@ internal class OneBotCommandRules(handlers: List, private val co "untrust" to CommandRule(Who.ADMIN), "allow" to CommandRule(Who.ADMIN), "streaming" to CommandRule(Who.ANYONE, Who.DIRECT), + "heartbeat" to CommandRule(Who.ANYONE), + "schedule" to CommandRule(Who.ANYONE, Who.ADMIN), + "timezone" to CommandRule(Who.ANYONE, Who.UNLINKED_DIRECT), + "home" to CommandRule(Who.ADMIN), + "link" to CommandRule(Who.ADMIN), + "unlink" to CommandRule(Who.ADMIN), + "untaint" to CommandRule(Who.ADMIN), ) private val ADMIN_ONLY = CommandRule(Who.ADMIN) diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt index 204c72ac..1cd5ae1d 100644 --- a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt @@ -103,6 +103,11 @@ class OneBotCommandRulesTest { assertFalse(rules.mayRun(group, admin = false, "agent", withArguments = true)) assertTrue(rules.mayRun(group, admin = true, "trust", withArguments = false)) assertTrue(rules.mayRun(group, admin = false, "not_a_command", withArguments = true)) + assertTrue(rules.mayRun(group, admin = false, "heartbeat", withArguments = false)) + assertFalse(rules.mayRun(direct, admin = false, "schedule", withArguments = true)) + assertTrue(rules.mayRun(direct, admin = false, "timezone", withArguments = true)) + assertFalse(rules.mayRun(group, admin = false, "timezone", withArguments = true)) + assertFalse(rules.mayRun(direct, admin = false, "home", withArguments = false)) } /** The key of the refusal of [name] for a member, null when the member may run it. */ @@ -141,6 +146,13 @@ class OneBotCommandRulesTest { "new", "compact", "cancel", + "schedule", + "heartbeat", + "timezone", + "home", + "link", + "unlink", + "untaint", ) } } diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRules.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRules.kt index 8c227620..fadbc33c 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRules.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRules.kt @@ -89,6 +89,13 @@ internal class QqBotCommandRules(handlers: List, private val con "untrust" to QqBotCommandRule(QqBotWho.ADMIN), "allow" to QqBotCommandRule(QqBotWho.ADMIN), "streaming" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.DIRECT), + "heartbeat" to QqBotCommandRule(QqBotWho.ANYONE), + "schedule" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.ADMIN), + "timezone" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.UNLINKED_DIRECT), + "home" to QqBotCommandRule(QqBotWho.ADMIN), + "link" to QqBotCommandRule(QqBotWho.ADMIN), + "unlink" to QqBotCommandRule(QqBotWho.ADMIN), + "untaint" to QqBotCommandRule(QqBotWho.ADMIN), ) private val ADMIN_ONLY = QqBotCommandRule(QqBotWho.ADMIN) diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRulesTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRulesTest.kt index 471fb730..9bbbcf8d 100644 --- a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRulesTest.kt +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRulesTest.kt @@ -28,6 +28,11 @@ class QqBotCommandRulesTest { assertFalse(rules.mayRun(group, admin = false, "trust", withArguments = false)) assertTrue(rules.mayRun(group, admin = true, "trust", withArguments = false)) assertTrue(rules.mayRun(group, admin = false, "not_a_command", withArguments = true)) + assertTrue(rules.mayRun(group, admin = false, "heartbeat", withArguments = false)) + assertFalse(rules.mayRun(direct, admin = false, "schedule", withArguments = true)) + assertTrue(rules.mayRun(direct, admin = false, "timezone", withArguments = true)) + assertFalse(rules.mayRun(group, admin = false, "timezone", withArguments = true)) + assertFalse(rules.mayRun(direct, admin = false, "home", withArguments = false)) } private object Commands : CommandHandler { @@ -38,6 +43,13 @@ class QqBotCommandRulesTest { "agent", "model", "trust", + "schedule", + "heartbeat", + "timezone", + "home", + "link", + "unlink", + "untaint", ).map { CommandSpec.builder(it, "a test command").build() } override suspend fun handle(invocation: CommandInvocation, context: CommandContext) = Unit From f925b7d644b09e833fed9ef5320c2911689e241b Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 18:57:41 +0800 Subject: [PATCH 19/37] app: Validate the example's channel sections Removes the onebot settings key the config does not have, adds the allowed chats list, and checks every example channel section against its config descriptor. --- app/src/dist/config/alexandrite.example.json | 4 +-- .../alexandrite/app/ExampleConfigTest.kt | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/src/dist/config/alexandrite.example.json b/app/src/dist/config/alexandrite.example.json index e4e6055b..b730110b 100644 --- a/app/src/dist/config/alexandrite.example.json +++ b/app/src/dist/config/alexandrite.example.json @@ -10,14 +10,14 @@ "onebot": { "enabled": false, "settings": { - "eventQueueCapacity": 1024, - "rateLimitIntervalMillis": 500 + "eventQueueCapacity": 1024 }, "instances": { "main": { "transport": "ws_reverse", "listenHost": "127.0.0.1", "listenPort": 8080, + "allowedChats": [], "admins": [], "approvalMinutes": 10 } diff --git a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/ExampleConfigTest.kt b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/ExampleConfigTest.kt index 7bef9429..b72e54ac 100644 --- a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/ExampleConfigTest.kt +++ b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/ExampleConfigTest.kt @@ -1,5 +1,10 @@ package org.foedusprogramme.alexandrite.app +import kotlinx.serialization.descriptors.SerialDescriptor +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.qqbot.config.QqBotInstanceConfig import org.foedusprogramme.alexandrite.runtime.AlexandriteRuntime import org.foedusprogramme.alexandrite.runtime.ConfigFile import org.foedusprogramme.alexandrite.sdk.chat.LanguageTag @@ -29,6 +34,33 @@ class ExampleConfigTest { assertEquals(List(settings.elementsCount, settings::getElementName).toSet(), tree?.keys) } + @Test + fun `the example config's channel sections name only their own settings`() { + val source = ConfigFile.read(example, emptyMap()) + + assertKnownFields( + checkNotNull(source.tree("channels.onebot.settings")), + OneBotConfig.serializer().descriptor, + "channels.onebot.settings", + ) + assertKnownFields( + checkNotNull(source.tree("channels.onebot.instances.main")), + OneBotInstanceConfig.serializer().descriptor, + "channels.onebot.instances.main", + ) + assertKnownFields( + checkNotNull(source.tree("channels.qqbot.instances.main")), + QqBotInstanceConfig.serializer().descriptor, + "channels.qqbot.instances.main", + ) + } + + /** Asserts that [tree] names no field [descriptor] does not know. */ + private fun assertKnownFields(tree: JsonObject, descriptor: SerialDescriptor, path: String) { + val known = (0 until descriptor.elementsCount).map(descriptor::getElementName).toSet() + assertEquals(emptySet(), tree.keys - known, "$path names a field its config does not have") + } + @Test fun `the host runs with the example config`() { val errors = ByteArrayOutputStream() From 7793d344ad12489dba98ad7f1b60b3e13618616f Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 18:59:22 +0800 Subject: [PATCH 20/37] onebot: Keep activity progress and its sender safe A chat shares one progress ledger between its replies. Serialize that ledger, guard the activity sender against a second launch, and close the sender exactly once; finish both reply endings through cleanup so a cancellation cannot leak the reply registry or the chat's progress. --- .../channel/onebot/channel/OneBotChannel.kt | 70 +++++++++++-------- .../channel/onebot/channel/OneBotProgress.kt | 7 ++ 2 files changed, 49 insertions(+), 28 deletions(-) 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 index baed1f12..74d07f72 100644 --- 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 @@ -131,6 +131,9 @@ internal class OneBotChannel( /** The sender working beside the turn, null until something waits to be said. */ private var sender: Job? = null + /** Whether the reply has ended, after which no more activity is launched for it. */ + private var closed = false + override suspend fun preview(segment: Int, text: String) { if (!channel.config.streaming) return // The previews of a growing segment are frequent and are never sent, so only a reader of the debug log @@ -196,24 +199,27 @@ internal class OneBotChannel( * turn does waits on a message about it. */ private fun post(said: String) { - synchronized(lock) { waiting = said } - if (sender == null) { - sender = channel.instance.scope.launch(CoroutineName("onebot activity of ${request.turn.id}")) { - while (true) { - wake.receive() + synchronized(lock) { + if (closed) return + waiting = said + if (sender == null) { + sender = channel.instance.scope.launch(CoroutineName("onebot activity of ${request.turn.id}")) { while (true) { - val next = synchronized(lock) { waiting?.also { waiting = null } } ?: break - try { - say(next) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - logger.warn( - "An activity of {} did not reach {}: {}", - request.turn.id, - request.turn.chat, - e.toString(), - ) + wake.receive() + while (true) { + val next = synchronized(lock) { waiting?.also { waiting = null } } ?: break + try { + say(next) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn( + "An activity of {} did not reach {}: {}", + request.turn.id, + request.turn.chat, + e.toString(), + ) + } } } } @@ -224,12 +230,14 @@ internal class OneBotChannel( /** Sends what the sender holds, and stops it, so that nothing of it is still on its way. */ private suspend fun settle() { + synchronized(lock) { closed = true } val last = synchronized(lock) { waiting?.also { waiting = null } } - sender?.let { + val current = synchronized(lock) { sender } + current?.let { it.cancel() it.join() } - sender = null + synchronized(lock) { sender = null } if (last != null) say(last) } @@ -245,11 +253,14 @@ internal class OneBotChannel( request.turn.chat, progress.counted(), ) - settle() - progress.flush()?.let { say(summaryMessage(it, channel.texts, request.turn.language)) } - channel.replies.close(request.turn.id) - channel.activities.forget(request.turn.chat) - return channel.sender.send(request.turn.chat, withTrigger(message)) + try { + settle() + progress.flush()?.let { say(summaryMessage(it, channel.texts, request.turn.language)) } + return channel.sender.send(request.turn.chat, withTrigger(message)) + } finally { + channel.replies.close(request.turn.id) + channel.activities.forget(request.turn.chat) + } } /** [message] as the answer to the message this turn answers, so the chat shows it as a quote reply. */ @@ -265,10 +276,13 @@ internal class OneBotChannel( request.turn.chat, progress.counted(), ) - settle() - progress.flush()?.let { say(summaryMessage(it, channel.texts, request.turn.language)) } - channel.replies.close(request.turn.id) - channel.activities.forget(request.turn.chat) + try { + settle() + progress.flush()?.let { say(summaryMessage(it, channel.texts, request.turn.language)) } + } finally { + channel.replies.close(request.turn.id) + channel.activities.forget(request.turn.chat) + } } } } diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgress.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgress.kt index d27401a5..099d3fbc 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgress.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgress.kt @@ -9,6 +9,7 @@ import org.foedusprogramme.alexandrite.sdk.chat.RunId import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId import org.foedusprogramme.alexandrite.sdk.i18n.Texts import org.foedusprogramme.alexandrite.sdk.tool.ToolActivity +import kotlin.jvm.Synchronized /** The statuses a call has ended with, after which it is spoken of in the past tense. */ internal val ENDED: Set = @@ -88,6 +89,7 @@ internal class OneBotProgress( * * The call is returned when it is the first of its conversation, which is the one told at once, and null after. */ + @Synchronized fun started( id: ToolCallId, activity: ToolActivity, @@ -105,6 +107,7 @@ internal class OneBotProgress( } /** Notes that the call [id] ended with [status] and [content], and keeps it to be told. */ + @Synchronized fun ended( id: ToolCallId, status: ToolCallStatus = ToolCallStatus.COMPLETED, @@ -124,6 +127,7 @@ internal class OneBotProgress( * Nothing waits once the reply is over, so its last steps are told rather than kept for a window that will not * come. */ + @Synchronized fun flush(): ProgressTally? { if (ended.isEmpty()) return null toldAt = now() @@ -131,6 +135,7 @@ internal class OneBotProgress( } /** What waits to be told, when it is worth telling: null while nothing waits or the interval has not passed. */ + @Synchronized fun take(): ProgressTally? { if (ended.isEmpty()) return null val told = toldAt @@ -149,6 +154,7 @@ internal class OneBotProgress( } /** How many calls ended and wait to be told. */ + @Synchronized fun counted(): Int = ended.size /** @@ -157,6 +163,7 @@ internal class OneBotProgress( * Only the pacing of what is told is kept besides, so a conversation that holds nothing may be forgotten rather * than kept for as long as the channel runs. */ + @Synchronized fun idle(): Boolean = calls.isEmpty() && ended.isEmpty() internal companion object { From 011fe8828f345ab04cf271e902f2e0cb602c6c63 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 19:06:12 +0800 Subject: [PATCH 21/37] onebot: Report socket timeouts as failures and drop repeated reports A socket call that times out is a failed delivery or unavailable approval, not a cancelled turn or withdrawn request. The inbox also keeps the ids it took, so an implementation that reports one message twice asks the agent for one turn. --- .../channel/onebot/channel/OneBotInbox.kt | 15 ++++++ .../transport/OneBotForwardWebSocket.kt | 15 +++++- .../transport/OneBotReverseWebSocket.kt | 5 ++ .../onebot/OneBotChannelIntegrationTest.kt | 48 ++++++++++++++----- .../onebot/transport/WsTransportTest.kt | 22 +++++++++ 5 files changed, 93 insertions(+), 12 deletions(-) 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 index f0ec57a2..10354c22 100644 --- 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 @@ -82,6 +82,14 @@ internal class OneBotInbox( ) : Lifecycle { private var reading: Job? = null + /** The message ids this inbox already took, which an implementation may report again. */ + private val seen = object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > KEPT + } + + /** Whether this is the first report of the message [id]. */ + private fun first(id: String): Boolean = seen.put(id, Unit) == null + override suspend fun onStart() { // The instance is served from here on, whatever else it does: a tool that acts on a chat of it finds its API // whether or not the runtime has an agent to take its messages. @@ -112,6 +120,10 @@ internal class OneBotInbox( OneBotNotices.of(instance.id, event, event.selfId.value)?.let { hooks.fire(ChannelPoints.NOTICE, it) } return@collect } + if (!first(event.messageId.value)) { + logger.debug("{}: the implementation reported message {} again", instance.id, event.messageId) + return@collect + } val chat = OneBotChats.addressOf(instance.id, event) // Every message is kept as it arrives, whether or not it is meant for the bot: what a later message answers // is usually one of these, and the one it answers need not have been meant for the bot at all. @@ -258,6 +270,9 @@ internal class OneBotInbox( private companion object { const val PLUGIN = "alexandrite-channel-onebot" + /** How many message ids the inbox keeps to tell a repeated report from a new message. */ + const val KEPT = 1024 + val logger: Logger = LoggerFactory.getLogger(OneBotInbox::class.java) } } 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 index 33dac0d1..0801627c 100644 --- 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 @@ -5,6 +5,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -14,6 +15,7 @@ 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.io.IOException import java.net.URI import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean @@ -42,7 +44,15 @@ internal class OneBotForwardWebSocket(settings: OneBotSettings) : AbstractOneBot val run = CoroutineScope(SupervisorJob() + Dispatchers.IO) scope = run loop = run.launch { connectLoop(opened) } - val failure = withTimeout(settings.connectTimeoutMillis) { opened.await() } + val failure = try { + withTimeout(settings.connectTimeoutMillis) { opened.await() } + } catch (e: TimeoutCancellationException) { + stop() + throw IOException( + "The implementation at ${settings.endpoint} did not open within ${settings.connectTimeoutMillis} ms.", + e, + ) + } if (failure != null) throw failure } @@ -68,6 +78,9 @@ internal class OneBotForwardWebSocket(settings: OneBotSettings) : AbstractOneBot } return try { withTimeout(waitMillis ?: settings.firstByteTimeoutMillis) { waiting.await() } + } catch (e: TimeoutCancellationException) { + socket.awaiting.remove(echo) + throw IOException("The implementation did not answer '$action' in time.", e) } catch (e: Exception) { socket.awaiting.remove(echo) throw e 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 index ceee7d96..fdd412a6 100644 --- 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 @@ -1,6 +1,7 @@ package org.foedusprogramme.alexandrite.channel.onebot.transport import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.JsonObject @@ -10,6 +11,7 @@ import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent import org.java_websocket.WebSocket import org.java_websocket.handshake.ClientHandshake import org.java_websocket.server.WebSocketServer +import java.io.IOException import java.net.InetSocketAddress import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch @@ -183,6 +185,9 @@ internal class OneBotReverseWebSocket(private val settings: OneBotSettings) : } return try { withTimeout(waitMillis ?: settings.firstByteTimeoutMillis) { waiting.await() } + } catch (e: TimeoutCancellationException) { + awaiting.remove(echo) + throw IOException("The implementation did not answer '$action' in time.", e) } catch (e: Exception) { awaiting.remove(echo) throw e 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 index 1852372e..388eef4b 100644 --- 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 @@ -80,6 +80,31 @@ class OneBotChannelIntegrationTest { } } + @Test + fun `a report the implementation sends twice asks for one turn`() { + 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()) + awaitSubmission(submitter) + peer.report(privateMessage()) + Thread.sleep(300) + assertEquals(1, submitter.submissions.size, "a repeated report became a second turn") + } + } + } finally { + peer.stop() + } + } + @Test fun `a group message that names nobody reaches no agent`() { val peer = ImplementationPeer() @@ -221,12 +246,12 @@ class OneBotChannelIntegrationTest { assertTrue(peer.awaitConnection(), "the channel did not connect to the implementation") val group = ChatAddress(instance, "group:987654") assertIs(sendTo(group)) - peer.report(groupMessage(text = "hello there", reply = "8")) + peer.report(groupMessage(text = "hello there", reply = "8", messageId = 12)) assertTrue( awaitNoSubmission(submitter), "the agent was asked for a turn of a message answering somebody else", ) - peer.report(groupMessage(text = "hello there", reply = "7")) + peer.report(groupMessage(text = "hello there", reply = "7", messageId = 13)) val message = assertIs(awaitSubmission(submitter)) assertEquals("hello there", message.message.text) } @@ -532,15 +557,16 @@ class OneBotChannelIntegrationTest { """.trimIndent() /** A group report of [text], whose message names [at] or answers [reply] when they are given. */ - private fun groupMessage(text: String, at: String? = null, reply: String? = null): String = buildString { - append("""{"time":1515204254,"self_id":10001000,"post_type":"message","message_type":"group",""") - append(""""sub_type":"normal","message_id":12,"group_id":987654,"user_id":12345678,"message":[""") - at?.let { append("""{"type":"at","data":{"qq":$it}},""") } - reply?.let { append("""{"type":"reply","data":{"id":$it}},""") } - append("""{"type":"text","data":{"text":"$text"}}],""") - append(""""raw_message":"$text",""") - append(""""sender":{"user_id":12345678,"nickname":"someone","role":"member"}}""") - } + private fun groupMessage(text: String, at: String? = null, reply: String? = null, messageId: Int = 12): String = + buildString { + append("""{"time":1515204254,"self_id":10001000,"post_type":"message","message_type":"group",""") + append(""""sub_type":"normal","message_id":$messageId,"group_id":987654,"user_id":12345678,"message":[""") + at?.let { append("""{"type":"at","data":{"qq":$it}},""") } + reply?.let { append("""{"type":"reply","data":{"id":$it}},""") } + append("""{"type":"text","data":{"text":"$text"}}],""") + append(""""raw_message":"$text",""") + append(""""sender":{"user_id":12345678,"nickname":"someone","role":"member"}}""") + } /** Whether the agent was asked for no turn within the time one would arrive in. */ private suspend fun awaitNoSubmission(submitter: RecordingTurnSubmitter): Boolean { 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 index 4f297d8d..758a1942 100644 --- 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 @@ -16,6 +16,7 @@ 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.io.IOException import java.net.InetSocketAddress import java.net.URI import java.util.concurrent.CopyOnWriteArrayList @@ -23,6 +24,7 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -128,6 +130,26 @@ class WsTransportTest { assertNotNull(refusedClientRole("Event"), "an Event client answers no call this instance would make") } + @Test + fun `a reverse call that times out is a failure rather than a cancellation`() { + 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) + + val failure = assertFailsWith { + connection.send("get_version_info", JsonObject(emptyMap()), waitMillis = 50) + } + peer.close() + } + } finally { + connection.close() + } + } + private fun settings(endpoint: String?, port: Int = 0): OneBotSettings = OneBotSettings( endpoint = endpoint, listenHost = "127.0.0.1", From 940dd9b94fdc096b4165eeebdcc4036c44957fca Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 19:08:17 +0800 Subject: [PATCH 22/37] qqbot: Keep reconnecting when the gateway or its token fails After startup, a missing gateway, a token failure or a connection that never becomes ready no longer ends the loop. Close the socket on a failed ready, keep one heartbeat, and drop a session the close code says can no longer be resumed. --- .../channel/qqbot/gateway/QqBotGateway.kt | 49 +++++++++++++++---- .../channel/qqbot/gateway/QqBotGatewayTest.kt | 12 +++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGateway.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGateway.kt index 364253b2..a7bc2edf 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGateway.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGateway.kt @@ -57,6 +57,7 @@ internal class QqBotGateway( /** The frames the platform pushed, which the channel reads. */ val frames: Flow = incoming.receiveAsFlow() + @Volatile private var scope: CoroutineScope? = null private var loop: Job? = null @@ -85,9 +86,13 @@ internal class QqBotGateway( val failure = try { withTimeout(config.connectTimeoutMillis * START_ATTEMPTS) { opened.await() } } catch (e: TimeoutCancellationException) { + onStop() IllegalStateException("The gateway did not become ready in time.", e) } - if (failure != null) throw failure + if (failure != null) { + onStop() + throw failure + } } override fun onStop() { @@ -103,14 +108,24 @@ internal class QqBotGateway( while (scope != null) { val url = gatewayUrl() if (url == null) { - if (!opened.isCompleted) opened.complete(IllegalStateException("The platform named no gateway.")) - return + if (!opened.isCompleted) { + opened.complete(IllegalStateException("The platform named no gateway.")) + return + } + logger.warn("{}: the platform named no gateway; trying again", PLUGIN) + delay(config.reconnectIntervalMillis) + continue } val token = try { tokens.token() } catch (e: Exception) { - if (!opened.isCompleted) opened.complete(e) - return + if (!opened.isCompleted) { + opened.complete(e) + return + } + logger.warn("{}: no token for the gateway: {}; trying again", PLUGIN, e.toString()) + delay(config.reconnectIntervalMillis) + continue } val ended = CompletableDeferred() val ready = CompletableDeferred() @@ -123,9 +138,11 @@ internal class QqBotGateway( opened.complete(e) return } + logger.warn("{}: the gateway connection failed: {}; trying again", PLUGIN, e.toString()) null } if (connected != true) { + socket.close() if (!opened.isCompleted) { opened.complete(IllegalStateException("The gateway at $url did not answer.")) return @@ -136,13 +153,17 @@ internal class QqBotGateway( } catch (e: TimeoutCancellationException) { IllegalStateException("The gateway at $url did not become ready in time.", e) } - if (failure != null && !opened.isCompleted) { - opened.complete(failure) + if (failure != null) { socket.close() - return + if (!opened.isCompleted) { + opened.complete(failure) + return + } + logger.warn("{}: the gateway did not become ready: {}; trying again", PLUGIN, failure.message) + } else { + if (!opened.isCompleted) opened.complete(null) + ended.await() } - if (!opened.isCompleted) opened.complete(null) - ended.await() } client = null if (scope == null) return @@ -219,6 +240,7 @@ internal class QqBotGateway( return false } val run = scope ?: return false + heartbeat?.cancel() heartbeat = run.launch { while (true) { delay(interval) @@ -305,6 +327,10 @@ internal class QqBotGateway( override fun onClose(code: Int, reason: String, remote: Boolean) { heartbeat?.cancel() heartbeat = null + if (dropsSession(code)) { + sessionId = null + lastSeq = null + } if (!stopping.get()) logger.warn("{}: the gateway closed: {} {}", PLUGIN, code, reason) if (!ready.isCompleted) { ready.complete(IllegalStateException("The gateway closed while starting: $code $reason")) @@ -354,3 +380,6 @@ internal class QqBotGateway( val logger = LoggerFactory.getLogger(QqBotGateway::class.java) } } + +/** Whether the close code [code] means the session a reconnect would resume is gone. */ +internal fun dropsSession(code: Int): Boolean = code == 4006 || code == 4007 || code in 4900..4913 diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGatewayTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGatewayTest.kt index 85627f0e..b6952e25 100644 --- a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGatewayTest.kt +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGatewayTest.kt @@ -13,6 +13,7 @@ import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig import java.time.Instant import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -55,6 +56,17 @@ class QqBotGatewayTest { } } + @Test + fun `a close code that ends the session is not resumed`() { + assertTrue(dropsSession(4006)) + assertTrue(dropsSession(4007)) + assertTrue(dropsSession(4900)) + assertTrue(dropsSession(4913)) + assertFalse(dropsSession(4008)) + assertFalse(dropsSession(4009)) + assertFalse(dropsSession(4914)) + } + private companion object { const val TOKEN_PATH = "/app/getAppAccessToken" From 1911866ce2414e9c1321c7f7990dd6a48d968b53 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 19:10:01 +0800 Subject: [PATCH 23/37] onebot: Render only public image URLs as images A model can write an image target that names a local path. The implementation would read that path and post it to the chat, outside the floor. Only an http or https target becomes an image segment; anything else stays as the text the model wrote. --- .../channel/onebot/mapping/OneBotMarkdown.kt | 11 +++++++++-- .../channel/onebot/mapping/OneBotMarkdownTest.kt | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdown.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdown.kt index 4e4b1023..0c70d94f 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdown.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdown.kt @@ -96,8 +96,15 @@ private class Renderer { val code = mutableListOf() var work = protectCode(source.trimEnd(), code) work = IMAGE.replace(work) { match -> - images += match.groupValues[2] - "$IMAGE_MARKER${images.lastIndex}$MARKER_END" + val target = match.groupValues[2] + if (target.startsWith("https://") || target.startsWith("http://")) { + images += target + "$IMAGE_MARKER${images.lastIndex}$MARKER_END" + } else { + // A model's image target is not a path this host may read: only a public URL becomes an image. + val label = match.groupValues[1].trim() + if (label.isEmpty()) target else "$label ($target)" + } } work = LINK.replace(work) { match -> val label = match.groupValues[1].trim() diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdownTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdownTest.kt index 41d16b57..5b4077a1 100644 --- a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdownTest.kt +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdownTest.kt @@ -56,6 +56,14 @@ class OneBotMarkdownTest { ) } + @Test + fun `an image that names a local file stays text`() { + assertEquals( + listOf(OneBotSegment.Text("picture (file:///etc/passwd)")), + OneBotMarkdown.segments("![picture](file:///etc/passwd)"), + ) + } + @Test fun `a list keeps its marker and loses the markers inside its item`() { assertEquals("- done\n* first\n1. second", text("- **done**\n* *first*\n1. **second**")) From 092400a33a115ef3d391b19cdb83bae3676fc2d2 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 19:11:29 +0800 Subject: [PATCH 24/37] qqbot, onebot: Bound the state an approval keeps Keep only the most recent 1024 quote references per chat, and cap an OneBot approval body at 1500 characters so a large diff cannot make the approval undeliverable. --- .../channel/onebot/channel/OneBotApprovals.kt | 42 +++++++++++-------- .../qqbot/channel/QqBotMessageIndex.kt | 19 +++++++-- .../qqbot/channel/QqBotMessageIndexTest.kt | 21 ++++++++++ 3 files changed, 60 insertions(+), 22 deletions(-) create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMessageIndexTest.kt diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApprovals.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApprovals.kt index 2f303c1c..4c76b4d3 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApprovals.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApprovals.kt @@ -346,26 +346,29 @@ internal class OneBotApprovals( } /** What [request] is about in [language]: its preview, else its arguments. */ - private fun body(request: ApprovalRequest, language: LanguageTag?): String = when (val preview = request.preview) { - is ReviewPreview.Diff -> - text(TextKeys.APPROVAL_FILE, language, "path" to preview.path) + "\n\n" + preview.unified - - is ReviewPreview.Command -> buildString { - append(text(TextKeys.APPROVAL_DIRECTORY, language, "directory" to preview.cwd)).append('\n') - append( - text( - TextKeys.APPROVAL_PROGRAM, - language, - "program" to (preview.executable ?: text(TextKeys.APPROVAL_PROGRAM_UNKNOWN, language)), - ), - ) - if (preview.opaque) append(' ').append(text(TextKeys.APPROVAL_OPAQUE, language)) - append("\n\n").append(commandLine(preview.argv)) - } + private fun body(request: ApprovalRequest, language: LanguageTag?): String { + val said = when (val preview = request.preview) { + is ReviewPreview.Diff -> + text(TextKeys.APPROVAL_FILE, language, "path" to preview.path) + "\n\n" + preview.unified + + is ReviewPreview.Command -> buildString { + append(text(TextKeys.APPROVAL_DIRECTORY, language, "directory" to preview.cwd)).append('\n') + append( + text( + TextKeys.APPROVAL_PROGRAM, + language, + "program" to (preview.executable ?: text(TextKeys.APPROVAL_PROGRAM_UNKNOWN, language)), + ), + ) + if (preview.opaque) append(' ').append(text(TextKeys.APPROVAL_OPAQUE, language)) + append("\n\n").append(commandLine(preview.argv)) + } - is ReviewPreview.Text -> preview.text + is ReviewPreview.Text -> preview.text - else -> text(TextKeys.APPROVAL_ARGUMENTS, language) + "\n" + request.arguments + else -> text(TextKeys.APPROVAL_ARGUMENTS, language) + "\n" + request.arguments + } + return if (said.length <= MAX_BODY) said else said.take(MAX_BODY) + "\n…" } /** One request on its way, with the messages that carry it by the chat they are in. */ @@ -455,4 +458,7 @@ private fun commandLine(argv: List): String = argv.joinToString(" ") { w /** The characters a shell takes as part of a word. */ private const val PLAIN = "-_./=:,+@%" +/** How much of an approval's body one chat message carries. */ +private const val MAX_BODY = 1_500 + private val logger = LoggerFactory.getLogger(OneBotApprovals::class.java) diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMessageIndex.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMessageIndex.kt index b1e7a5e6..98af2ed4 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMessageIndex.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMessageIndex.kt @@ -2,7 +2,7 @@ package org.foedusprogramme.alexandrite.channel.qqbot.channel import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress import org.foedusprogramme.alexandrite.sdk.di.Singleton -import java.util.concurrent.ConcurrentHashMap +import java.util.Collections /** * The message ids behind the reference indexes the platform uses for quotes. @@ -13,9 +13,9 @@ import java.util.concurrent.ConcurrentHashMap */ @Singleton internal class QqBotMessageIndex { - private val ids = ConcurrentHashMap() - private val references = ConcurrentHashMap() - private val quotes = ConcurrentHashMap() + private val ids = bounded() + private val references = bounded() + private val quotes = bounded() private data class Key(val chat: ChatAddress, val reference: String) @@ -40,4 +40,15 @@ internal class QqBotMessageIndex { /** The message id that the message [trigger] of [chat] quotes, null when it quotes none this process saw. */ fun quotedTarget(chat: ChatAddress, trigger: String?): String? = trigger?.let { quotes[Key(chat, it)] } + + /** A map that keeps the most recent [KEPT] entries, so a long-running bot does not grow without bound. */ + private fun bounded(): MutableMap = Collections.synchronizedMap( + object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > KEPT + }, + ) + + private companion object { + const val KEPT = 1024 + } } diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMessageIndexTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMessageIndexTest.kt new file mode 100644 index 00000000..b7ee38ba --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMessageIndexTest.kt @@ -0,0 +1,21 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class QqBotMessageIndexTest { + private val chat = ChatAddress(ChannelInstanceId(ChannelType("qqbot"), "main"), "private:u1") + + @Test + fun `the index keeps a bounded number of references`() { + val index = QqBotMessageIndex() + repeat(1_100) { index.record(chat, "ref$it", "id$it") } + + assertNull(index.resolve(chat, "ref0")) + assertEquals("id1099", index.resolve(chat, "ref1099")) + } +} From 29299931f6868930ac696480ea2ad14b8e99a5c5 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 19:13:47 +0800 Subject: [PATCH 25/37] qqbot: Retry a failed bot-state lookup A group whose bot_state call failed was remembered as asked and never looked up again, so a full-mode mention could stop naming the bot until restart. Forget the group after a failed lookup. --- .../channel/qqbot/channel/QqBotInbox.kt | 21 ++++++---- .../qqbot/QqBotChannelIntegrationTest.kt | 42 +++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotInbox.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotInbox.kt index c3b604da..d3511854 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotInbox.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotInbox.kt @@ -195,14 +195,21 @@ internal class QqBotInbox( val token = LEADING_MENTION.find(event.content)?.groupValues?.get(1) ?: return false if (token == me || token == member) return true if (member == null && botStateAsked.add(group)) { - val answer = api.get("/v2/groups/$group/bot_state") - val body = (answer as? QqBotAnswer.Ok)?.body as? JsonObject - val openid = body?.get("member_openid")?.asTextOrNull() - if (openid != null) { - botMemberOpenids[group] = openid - return token == openid + var found = false + try { + val answer = api.get("/v2/groups/$group/bot_state") + val body = (answer as? QqBotAnswer.Ok)?.body as? JsonObject + val openid = body?.get("member_openid")?.asTextOrNull() + if (openid != null) { + botMemberOpenids[group] = openid + found = true + return token == openid + } + logger.debug("{}: the platform did not name the bot's member openid of {}", PLUGIN, group) + } finally { + // A failed lookup must be tried again: the next message may be the one that makes this group work. + if (!found) botStateAsked.remove(group) } - logger.debug("{}: the platform did not name the bot's member openid of {}", PLUGIN, group) } return false } diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt index 19459502..b3788448 100644 --- a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt @@ -38,6 +38,7 @@ import org.foedusprogramme.alexandrite.testkit.RecordingTurnSubmitter import org.foedusprogramme.alexandrite.testkit.testTurn import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -146,6 +147,47 @@ class QqBotChannelIntegrationTest { } } + @Test + fun `a failed bot-state lookup is tried again`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(ME_PATH, """{"id":"global-bot-id"}""") + val calls = AtomicInteger() + api.on(BOT_STATE_PATH) { + if (calls.incrementAndGet() == 1) { + PlatformAnswer(500, """{"err_code":500,"message":"temporary"}""") + } else { + PlatformAnswer(200, """{"member_openid":"member-bot-id"}""") + } + } + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(FULL_MODE_MENTION_EVENT) + delay(500) + assertTrue(submitter.submissions.isEmpty(), "the first lookup should not reach the agent") + platform.push( + FULL_MODE_MENTION_EVENT + .replace("msg-full-mention", "msg-full-mention-2") + .replace("\"s\":4", "\"s\":5"), + ) + val message = assertIs(awaitSubmission(submitter)).message + assertEquals("hello", message.text) + } + } + } finally { + platform.stop() + api.close() + } + } + @Test fun `a full-mode group message names the bot by its member openid when the mention list does not`() { val api = FakeApiServer() From 7fca8d36d22f94de377f374bf008472af86cb428 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 19 Oct 2026 19:15:13 +0800 Subject: [PATCH 26/37] qqbot: Cover denying an approval by a press --- .../qqbot/QqBotChannelIntegrationTest.kt | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt index b3788448..2d7f59a9 100644 --- a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt @@ -412,6 +412,45 @@ class QqBotChannelIntegrationTest { } } + @Test + fun `an approval can be denied by a press`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(SEND_DIRECT, """{"id":"sent1","timestamp":"2026-07-21T10:00:00+08:00"}""") + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url, admins = setOf("u1"))) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + val channel = get(directory).channel(instance) ?: error("the instance has no channel") + val chat = ChatAddress(instance, "private:u1") + val request = ApprovalRequest( + "req1", + testTurn(chat = chat), + "command.exec", + ToolRisk.EXEC, + "{}", + null, + "it runs a command", + null, + ) + val waiting = async(start = CoroutineStart.UNDISPATCHED) { channel.askApproval(request) } + assertNotNull(api.awaitRequest(SEND_DIRECT)) + platform.push(APPROVAL_DENY) + assertIs(withTimeout(10.seconds) { waiting.await() }) + } + } + } finally { + platform.stop() + api.close() + } + } + @Test fun `a command reaches the agent as a command rather than as a message`() { val api = FakeApiServer() @@ -530,6 +569,10 @@ class QqBotChannelIntegrationTest { """{"id":"msg-approve","author":{"id":"u1","user_openid":"u1","username":"Ada","bot":false},""" + """"content":"/approve req1","message_type":0,"timestamp":"2026-07-21T10:00:00+08:00"}}""" + const val APPROVAL_DENY = """{"id":"event-deny","op":0,"s":2,"t":"C2C_MESSAGE_CREATE","d":""" + + """{"id":"msg-deny","author":{"id":"u1","user_openid":"u1","username":"Ada","bot":false},""" + + """"content":"/deny req1","message_type":0,"timestamp":"2026-07-21T10:00:00+08:00"}}""" + const val COMMAND_EVENT = """{"id":"event-command","op":0,"s":3,"t":"C2C_MESSAGE_CREATE","d":""" + """{"id":"msg-command","author":{"id":"u1","user_openid":"u1","username":"Ada","bot":false},""" + """"content":"/model deepseek/deepseek-flash","message_type":0,"timestamp":"2026-07-21T10:00:01+08:00"}}""" From 2e0438284b51224ae095eb50efe29952cdeaa630 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Tue, 20 Oct 2026 13:21:11 +0800 Subject: [PATCH 27/37] agent: Wrap the call that executes a turn's tool The channel branch passes the turn's trigger in the call as well, so the call does not fit a line any more and spotlessCheck asks for the wrapped form. --- .../alexandrite/agent/turn/AgentTurnRunner.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/libraries/agent/src/main/kotlin/org/unitedsoftware/alexandrite/agent/turn/AgentTurnRunner.kt b/libraries/agent/src/main/kotlin/org/unitedsoftware/alexandrite/agent/turn/AgentTurnRunner.kt index e8bc73f9..99df0969 100644 --- a/libraries/agent/src/main/kotlin/org/unitedsoftware/alexandrite/agent/turn/AgentTurnRunner.kt +++ b/libraries/agent/src/main/kotlin/org/unitedsoftware/alexandrite/agent/turn/AgentTurnRunner.kt @@ -981,7 +981,16 @@ internal class AgentTurnRunner( launch { slots.withPermit { val end = - executor.execute(turn, call, tools.offered, timeout, tools.callable, taint, tracked, plan.trigger) + executor.execute( + turn, + call, + tools.offered, + timeout, + tools.callable, + taint, + tracked, + plan.trigger, + ) reads.reason(turn, call.name, end.tainting)?.let { taint.mark(it) } taint.record() val found = projects.inspect(turn, end.touched, budget) From 05eb749fa02cdcbd2b4f89b07185f788833c9b73 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Tue, 20 Oct 2026 13:29:54 +0800 Subject: [PATCH 28/37] onebot, qqbot: Take the controls that master split the SDK's control into The master merge gives a channel AgentCatalog, ChatLinks and ChatSettings instead of one AgentControl, makes Pairings a final class to delegate to, and reports a chat's language through ChatSettings.snapshot(chat).effectiveLanguage. The gate, the command rules, the pairing desk and the approval text of both channels follow that, and the approval tests build a request without a chain and without always rules, as the new constructor means them. --- .../channel/onebot/channel/OneBotAccessGate.kt | 12 +++++++----- .../channel/onebot/channel/OneBotApprovals.kt | 2 +- .../channel/onebot/channel/OneBotCommandRules.kt | 6 +++--- .../channel/onebot/pairing/OneBotPairingDesk.kt | 15 ++++++++++++++- .../onebot/OneBotApprovalIntegrationTest.kt | 2 +- .../onebot/channel/OneBotCommandRulesTest.kt | 2 +- .../channel/qqbot/channel/QqBotCommandRules.kt | 6 +++--- .../channel/qqbot/QqBotChannelIntegrationTest.kt | 4 ++-- .../qqbot/channel/QqBotCommandRulesTest.kt | 2 +- 9 files changed, 33 insertions(+), 18 deletions(-) diff --git a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotAccessGate.kt b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotAccessGate.kt index 5f809684..1f7c3294 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotAccessGate.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotAccessGate.kt @@ -16,7 +16,8 @@ import org.unitedsoftware.alexandrite.sdk.chat.ChatUser import org.unitedsoftware.alexandrite.sdk.di.ChannelInstanceScoped import org.unitedsoftware.alexandrite.sdk.di.Lifecycle import org.unitedsoftware.alexandrite.sdk.i18n.Texts -import org.unitedsoftware.alexandrite.sdk.turn.AgentControl +import org.unitedsoftware.alexandrite.sdk.turn.AgentCatalog +import org.unitedsoftware.alexandrite.sdk.turn.ChatLinks import kotlin.coroutines.cancellation.CancellationException /** @@ -33,7 +34,8 @@ internal class OneBotAccessGate( private val desk: OneBotPairingDesk, private val sender: OneBotSender, private val texts: Texts, - private val control: AgentControl?, + private val catalog: AgentCatalog?, + private val links: ChatLinks?, ) : Lifecycle { private val logged = object : LinkedHashMap(16, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = size > LOGGED_CHATS @@ -91,13 +93,13 @@ internal class OneBotAccessGate( /** Warns once of each home chat and linked chat of this instance that is not paired. */ private fun warnUnpaired(pairing: InstancePairing) { - val control = control ?: return + if (catalog == null && links == null) return val uses = LinkedHashMap>() try { - for (agent in control.agents(instance.id)) { + for (agent in catalog?.agents(instance.id).orEmpty()) { agent.home?.let { uses.getOrPut(it.parent, ::LinkedHashSet) += "the home chat of agent ${agent.id}" } } - for (group in control.links()) { + for (group in links?.groups().orEmpty()) { for (chat in (listOf(group.anchor) + group.members).filter { it.instance == instance.id }) { uses.getOrPut(chat.parent, ::LinkedHashSet) += "linked at agent ${group.agent}" } diff --git a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotApprovals.kt b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotApprovals.kt index a463b828..9ca10b42 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotApprovals.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotApprovals.kt @@ -433,7 +433,7 @@ internal class OneBotApprovals( /** The language of [chat], null when the runtime has no agent to ask and the host's language applies. */ private suspend fun language(chat: ChatAddress): LanguageTag? = try { - settings?.language(chat) + settings?.snapshot(chat)?.effectiveLanguage } catch (e: CancellationException) { throw e } catch (e: Exception) { diff --git a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotCommandRules.kt b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotCommandRules.kt index 776cbabf..ae74b5f9 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotCommandRules.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotCommandRules.kt @@ -5,7 +5,7 @@ import org.unitedsoftware.alexandrite.channel.onebot.mapping.OneBotMessages import org.unitedsoftware.alexandrite.sdk.chat.ChatAddress import org.unitedsoftware.alexandrite.sdk.chat.ChatKind import org.unitedsoftware.alexandrite.sdk.di.Singleton -import org.unitedsoftware.alexandrite.sdk.turn.AgentControl +import org.unitedsoftware.alexandrite.sdk.turn.ChatLinks import org.unitedsoftware.alexandrite.sdk.turn.CommandHandler import org.unitedsoftware.alexandrite.sdk.turn.CommandInvocation import java.util.Locale @@ -37,7 +37,7 @@ internal class CommandRule(val bare: Who, val withArguments: Who = bare) * belong with the rest of what every channel shares, and they are here until that is decided. */ @Singleton -internal class OneBotCommandRules(handlers: List, private val control: AgentControl?) { +internal class OneBotCommandRules(handlers: List, private val links: ChatLinks?) { private val claimed = handlers.flatMap { it.commands }.mapTo(HashSet()) { it.name.lowercase(Locale.ROOT) } /** The key of the refusal of [invocation] in a chat of [kind], null when its issuer may run it. */ @@ -68,7 +68,7 @@ internal class OneBotCommandRules(handlers: List, private val co } } - private fun linked(chat: ChatAddress): Boolean = control?.links().orEmpty().any { group -> + private fun linked(chat: ChatAddress): Boolean = links?.groups().orEmpty().any { group -> (group.members + group.anchor).any { it.parent == chat.parent } } diff --git a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/pairing/OneBotPairingDesk.kt b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/pairing/OneBotPairingDesk.kt index e3d940c9..f812ad69 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/pairing/OneBotPairingDesk.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/pairing/OneBotPairingDesk.kt @@ -1,13 +1,26 @@ package org.unitedsoftware.alexandrite.channel.onebot.pairing +import org.unitedsoftware.alexandrite.channel.common.pairing.InstancePairing import org.unitedsoftware.alexandrite.channel.common.pairing.PairingDesk import org.unitedsoftware.alexandrite.channel.common.pairing.Pairings +import org.unitedsoftware.alexandrite.sdk.chat.ChannelInstanceId +import org.unitedsoftware.alexandrite.sdk.chat.ChatAddress import org.unitedsoftware.alexandrite.sdk.di.Contribute import org.unitedsoftware.alexandrite.sdk.di.Singleton import org.unitedsoftware.alexandrite.sdk.store.Records import java.time.Clock +import kotlin.time.Duration /** The pairing of the chats of every OneBot channel instance. */ @Singleton @Contribute(PairingDesk::class) -internal class OneBotPairingDesk(clock: Clock, records: Records) : Pairings(clock, records) +internal class OneBotPairingDesk private constructor(private val pairings: Pairings) : PairingDesk by pairings { + constructor(clock: Clock, records: Records) : this(Pairings(clock, records)) + + val codeLifetime: Duration get() = pairings.codeLifetime + + suspend fun open(instance: ChannelInstanceId, declared: Collection): InstancePairing = + pairings.open(instance, declared) + + fun close(instance: ChannelInstanceId) = pairings.close(instance) +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt b/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt index 65379d37..549e117c 100644 --- a/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt +++ b/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt @@ -236,7 +236,7 @@ class OneBotApprovalIntegrationTest { arguments = """{"path":"notes.md"}""", preview = null, reason = "the chat is in ask mode", - always = null, + alwaysRules = emptyList(), ) /** A report of the group that answers the message [replyTo] with [text], sent by [from]. */ diff --git a/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt b/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt index 537c4967..71198c3d 100644 --- a/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt +++ b/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt @@ -21,7 +21,7 @@ import kotlin.test.assertTrue class OneBotCommandRulesTest { private val instance = ChannelInstanceId(ChannelType("onebot"), "main") - private val rules = OneBotCommandRules(listOf(handler()), control = null) + private val rules = OneBotCommandRules(listOf(handler()), links = null) @Test fun `an admin of the instance runs anything`() { diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotCommandRules.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotCommandRules.kt index 84143c62..22f166db 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotCommandRules.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotCommandRules.kt @@ -5,7 +5,7 @@ import org.unitedsoftware.alexandrite.channel.qqbot.mapping.QqBotChats import org.unitedsoftware.alexandrite.sdk.chat.ChatAddress import org.unitedsoftware.alexandrite.sdk.chat.ChatKind import org.unitedsoftware.alexandrite.sdk.di.Singleton -import org.unitedsoftware.alexandrite.sdk.turn.AgentControl +import org.unitedsoftware.alexandrite.sdk.turn.ChatLinks import org.unitedsoftware.alexandrite.sdk.turn.CommandHandler import org.unitedsoftware.alexandrite.sdk.turn.CommandInvocation import java.util.Locale @@ -34,7 +34,7 @@ internal class QqBotCommandRule(val bare: QqBotWho, val withArguments: QqBotWho * does not judge it. */ @Singleton -internal class QqBotCommandRules(handlers: List, private val control: AgentControl?) { +internal class QqBotCommandRules(handlers: List, private val links: ChatLinks?) { private val claimed = handlers.flatMap { it.commands }.mapTo(HashSet()) { it.name.lowercase(Locale.ROOT) } /** The key of the refusal of [invocation] in a chat of [kind], null when its issuer may run it. */ @@ -65,7 +65,7 @@ internal class QqBotCommandRules(handlers: List, private val con } } - private fun linked(chat: ChatAddress): Boolean = control?.links().orEmpty().any { group -> + private fun linked(chat: ChatAddress): Boolean = links?.groups().orEmpty().any { group -> (group.members + group.anchor).any { it.parent == chat.parent } } diff --git a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt index f395563d..b1d6d0e0 100644 --- a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt +++ b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt @@ -384,7 +384,7 @@ class QqBotChannelIntegrationTest { "{}", null, "it runs a command", - null, + emptyList(), ) val waiting = async(start = CoroutineStart.UNDISPATCHED) { channel.askApproval(request) } assertTrue(sendReached.await(5, TimeUnit.SECONDS), "the approval message was not sent") @@ -437,7 +437,7 @@ class QqBotChannelIntegrationTest { "{}", null, "it runs a command", - null, + emptyList(), ) val waiting = async(start = CoroutineStart.UNDISPATCHED) { channel.askApproval(request) } assertNotNull(api.awaitRequest(SEND_DIRECT)) diff --git a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotCommandRulesTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotCommandRulesTest.kt index 51e58bcd..0d89ba29 100644 --- a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotCommandRulesTest.kt +++ b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotCommandRulesTest.kt @@ -15,7 +15,7 @@ class QqBotCommandRulesTest { private val instance = ChannelInstanceId(ChannelType("qqbot"), "main") private val direct = ChatAddress(instance, "private:u1") private val group = ChatAddress(instance, "group:g1") - private val rules = QqBotCommandRules(listOf(Commands), control = null) + private val rules = QqBotCommandRules(listOf(Commands), links = null) @Test fun `mayRun answers the matrix for a member`() { From b839eb89115f0d901b190a6661f6c6ab615ec01b Mon Sep 17 00:00:00 2001 From: Yos-X Date: Tue, 20 Oct 2026 13:37:44 +0800 Subject: [PATCH 29/37] qqbot: Publish only the command items the platform takes The open platform refuses a menu or command panel with 40030013 when it holds more items than it takes, and says in that answer's limit how many it takes, which is fewer than its documentation promises. A refused configuration is published once more with that many items, and the failure a second refusal leaves names the limit and the panels the scope already holds. The log said a menu of every command was published although only the first ten items were: it names the items now, and how many commands there were. --- .../channel/qqbot/channel/QqBotMenuSync.kt | 115 ++++++++++++++---- .../channel/qqbot/protocol/QqBotPayloads.kt | 5 +- .../qqbot/channel/QqBotMenuSyncTest.kt | 73 +++++++++++ 3 files changed, 167 insertions(+), 26 deletions(-) diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt index 7471e48b..eaff6e71 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt @@ -21,6 +21,10 @@ import org.unitedsoftware.alexandrite.sdk.turn.CommandSpec * The platform calls these configurations the global custom menu (`/v2/menu`, C2C only) and command panels * (`/v2/panels`, C2C and groups). Both are what a user sees and clicks; a click only fills the command into the input * box, so the usual command handling still answers it. + * + * The platform takes fewer items than a host may have commands, and how many it takes is not quite what its + * documentation says: an answer that refuses the number tells in `limit` how many it takes, so a configuration that + * the platform refused is published once more with that many items. */ @ChannelInstanceScoped internal class QqBotMenuSync( @@ -41,60 +45,121 @@ internal class QqBotMenuSync( } private suspend fun putMenu() { - val body = menuBody(commands) - when (val answer = api.put("/v2/menu", body)) { - is QqBotAnswer.Ok -> logger.info("{}: published the menu of {} commands", PLUGIN, commands.size) + var items = commands.take(MENU_ITEMS) + var answer = api.put("/v2/menu", menuBody(items)) + if (answer is QqBotAnswer.Failed) { + val limit = answer.failure.limit + if (limit != null && limit in 1 until items.size) { + logger.info( + "{}: the platform takes {} menu items, not {}: publishing {}", + PLUGIN, + limit, + items.size, + limit, + ) + items = items.take(limit) + answer = api.put("/v2/menu", menuBody(items)) + } + } + when (answer) { + is QqBotAnswer.Ok -> + logger.info("{}: published a menu of {} items from {} commands", PLUGIN, items.size, commands.size) is QqBotAnswer.Failed -> logger.warn( - "{}: could not publish the menu: {} {}", + "{}: could not publish the menu: {} {} (limit {})", PLUGIN, answer.failure.code, answer.failure.message, + answer.failure.limit, ) } } private suspend fun putPanel(scope: String) { - val body = panelBody(scope, entries, language) - val panel = body["panel"] as JsonObject - val existing = panelId(scope) - val answer = if (existing == null) { - api.post("/v2/panels", body) - } else { - api.put("/v2/panels/$existing", buildJsonObject { put("panel", panel) }) + val listing = listing(scope) + var items = entries.take(PANEL_ITEMS) + var answer = writePanel(scope, items, listing.id) + if (answer is QqBotAnswer.Failed) { + val limit = answer.failure.limit + if (limit != null && limit in 1 until items.size) { + logger.info( + "{}: the platform takes {} {} panel items, not {}: publishing {}", + PLUGIN, + limit, + scope, + items.size, + limit, + ) + items = items.take(limit) + answer = writePanel(scope, items, listing.id) + } } when (answer) { is QqBotAnswer.Ok -> - logger.info("{}: published the {} command panel", PLUGIN, scope) + logger.info( + "{}: published the {} command panel with {} of {} commands", + PLUGIN, + scope, + items.size, + entries.size, + ) is QqBotAnswer.Failed -> logger.warn( - "{}: could not publish the {} command panel: {} {}", + "{}: could not publish the {} command panel: {} {} (limit {}, {} panels in this scope)", PLUGIN, scope, answer.failure.code, answer.failure.message, + answer.failure.limit, + listing.count, ) } } - /** The id of the panel this bot published for [scope], null when it has none yet. */ - private suspend fun panelId(scope: String): String? { - val answer = api.get("/v2/panels", mapOf("scope" to scope, "limit" to "50")) as? QqBotAnswer.Ok ?: return null - val records = (answer.body as? JsonObject)?.get("records") as? JsonArray ?: return null - return records.asSequence() - .mapNotNull { it as? JsonObject } - .firstOrNull { ((it["panel"] as? JsonObject)?.get("remark") as? JsonPrimitive)?.content == REMARK } + /** Writes [items] as the [scope] panel of the bot, replacing the panel [existing], and creating one when null. */ + private suspend fun writePanel( + scope: String, + items: List>, + existing: String?, + ): QqBotAnswer { + val body = panelBody(scope, items, language) + return if (existing == null) { + api.post("/v2/panels", body) + } else { + api.put("/v2/panels/$existing", buildJsonObject { put("panel", body["panel"] as JsonObject) }) + } + } + + /** The panels of the bot in [scope]: the id of the one this bot publishes, null when it has none, and how many. */ + private suspend fun listing(scope: String): Listing { + val answer = api.get("/v2/panels", mapOf("scope" to scope, "limit" to "50")) + val records = ((answer as? QqBotAnswer.Ok)?.body as? JsonObject)?.get("records") as? JsonArray + ?: return Listing(null, null) + val panels = records.mapNotNull { it as? JsonObject } + val id = panels.firstOrNull { + ((it["panel"] as? JsonObject)?.get("remark") as? JsonPrimitive)?.content == REMARK + } ?.let { (it["panel_id"] as? JsonPrimitive)?.content } + return Listing(id, panels.size) } + /** The panels of a scope: the id of this bot's panel, null when it has none, and how many panels it holds. */ + private class Listing(val id: String?, val count: Int?) + companion object { private const val PLUGIN = "alexandrite-channel-qqbot" private const val REMARK = "alexandrite commands" - /** The platform's menu of [commands], a C2C menu of at most ten send-message items. */ + /** How many items the platform's menu takes, as its documentation says. */ + private const val MENU_ITEMS = 10 + + /** How many items the platform's command panel takes, as its documentation says. */ + private const val PANEL_ITEMS = 20 + + /** The platform's menu of [commands], a C2C menu of send-message items. */ internal fun menuBody(commands: List): JsonObject = buildJsonObject { put( "menu", @@ -102,7 +167,7 @@ internal class QqBotMenuSync( put( "items", buildJsonArray { - for (command in commands.take(10)) { + for (command in commands) { add( buildJsonObject { put("type", JsonPrimitive("send_message")) @@ -117,10 +182,10 @@ internal class QqBotMenuSync( ) } - /** The platform's [scope] panel of [entries], with at most twenty command items. */ + /** The platform's [scope] panel of [items], with the description each handler gives its command. */ internal fun panelBody( scope: String, - entries: List>, + items: List>, language: LanguageTag?, ): JsonObject = buildJsonObject { put("scope", JsonPrimitive(scope)) @@ -131,7 +196,7 @@ internal class QqBotMenuSync( put( "items", buildJsonArray { - for ((handler, command) in entries.take(20)) { + for ((handler, command) in items) { val description = runCatching { handler.description(command.name, language) } .getOrElse { command.description } .trim() diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/protocol/QqBotPayloads.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/protocol/QqBotPayloads.kt index 0b829465..db11ce1c 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/protocol/QqBotPayloads.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/protocol/QqBotPayloads.kt @@ -144,6 +144,8 @@ internal class QqBotFailure( val message: String, /** The platform's id of the request, which it asks for in a support request. */ val traceId: String?, + /** How many items the platform takes where it refused their number, null when it named none. */ + val limit: Int? = null, ) /** What a failed answer says, as the fields both kinds of answer use. */ @@ -152,7 +154,8 @@ internal fun failureOf(body: JsonElement?): QqBotFailure { val code = objectBody?.get("err_code")?.asIntOrNull() ?: objectBody?.get("code")?.asIntOrNull() val message = objectBody?.get("message")?.asTextOrNull().orEmpty() val trace = objectBody?.get("trace_id")?.asTextOrNull() - return QqBotFailure(code, message, trace) + val limit = objectBody?.get("limit")?.asIntOrNull() + return QqBotFailure(code, message, trace, limit) } /** The int value of the field, null when it is no number. */ diff --git a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt index 771119d2..02536e8a 100644 --- a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt +++ b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt @@ -16,6 +16,7 @@ import org.unitedsoftware.alexandrite.sdk.turn.CommandSpec import java.time.Instant import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class QqBotMenuSyncTest { @@ -97,6 +98,78 @@ class QqBotMenuSyncTest { } } + @Test + fun `a menu the platform refuses as too long is published with the count it names`() = runBlocking { + val platform = Platform() + try { + var menus = 0 + platform.server.on("/v2/menu") { _ -> + menus++ + if (menus == 1) { + PlatformAnswer(400, """{"code":40030013,"message":"超出数量限制","limit":5}""") + } else { + PlatformAnswer(200, """{"version":1}""") + } + } + platform.server.on("/v2/panels") { request -> + if (request.method == "GET") { + PlatformAnswer(200, """{"records":[],"next_cursor":"","is_end":true}""") + } else { + PlatformAnswer(200, """{"panel_id":"p1"}""") + } + } + QqBotMenuSync(platform.api, platform.config, listOf(ManyCommands)).onOpen() + + val calls = platform.server.calls("/v2/menu") + assertEquals(2, calls.size) + assertEquals(5, "\"type\":\"send_message\"".toRegex().findAll(calls[1].body).count(), calls[1].body) + } finally { + platform.close() + } + } + + @Test + fun `a command panel the platform refuses as too long is published with the count it names`() = runBlocking { + val platform = Platform() + try { + val posts = mutableListOf() + platform.server.on("/v2/panels") { request -> + if (request.method == "GET") { + PlatformAnswer(200, """{"records":[],"next_cursor":"","is_end":true}""") + } else { + posts += request.body + if (posts.size % 2 == 1) { + PlatformAnswer(400, """{"code":40030013,"message":"超出数量限制","limit":1}""") + } else { + PlatformAnswer(200, """{"panel_id":"p1"}""") + } + } + } + platform.server.on("/v2/menu", """{"version":1}""") + QqBotMenuSync(platform.api, platform.config, listOf(Commands)).onOpen() + + assertEquals(4, posts.size, posts.joinToString("\n")) + assertTrue("\"/model\"" in posts[0], posts[0]) + assertTrue("\"/whoami\"" in posts[0], posts[0]) + assertFalse("\"/whoami\"" in posts[1], posts[1]) + } finally { + platform.close() + } + } + + /** Twelve commands, so that the menu takes fewer than there are. */ + private object ManyCommands : CommandHandler { + override val commands: List = (1..12).map { CommandSpec.builder("c$it", "Command $it").build() } + + override fun description( + command: String, + language: org.unitedsoftware.alexandrite.sdk.chat.LanguageTag?, + ): String = commands.first { it.name == command }.description + + override suspend fun handle(invocation: CommandInvocation, context: CommandContext) = + throw UnsupportedOperationException("not used") + } + private object Commands : CommandHandler { override val commands: List = listOf( CommandSpec.builder("model", "Pick the model").build(), From 7e1a9260e96890b5d6bed00efc65c4e5ea90add1 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Tue, 20 Oct 2026 13:40:56 +0800 Subject: [PATCH 30/37] tools: Treat a Windows shell as a program that runs code cmd, powershell, pwsh, cscript, wscript, mshta, rundll32, regsvr32 and wsl are launchers like sh and bash: what a call gives them is code, which the floor cannot see, so they are opaque and no standing rule covers them. Their names are compared without case on a Windows host, where PowerShell.exe and cmd spell themselves that way. An opaque program's argument that names no path of the host is script text and no path to refuse: a command line that is passed to a shell is not refused as blocked_floor any more, and it is written as a file only when that is wanted. Every other program still refuses a path-shaped word that the host cannot name. --- .../alexandrite/tools/exec/Planner.kt | 24 +++++++++++-------- .../alexandrite/tools/exec/Programs.kt | 21 ++++++++++++---- .../alexandrite/tools/exec/ExecWindowsTest.kt | 16 +++++++++++++ 3 files changed, 46 insertions(+), 15 deletions(-) diff --git a/libraries/tools/src/main/kotlin/org/unitedsoftware/alexandrite/tools/exec/Planner.kt b/libraries/tools/src/main/kotlin/org/unitedsoftware/alexandrite/tools/exec/Planner.kt index e7ff2c82..532e957b 100644 --- a/libraries/tools/src/main/kotlin/org/unitedsoftware/alexandrite/tools/exec/Planner.kt +++ b/libraries/tools/src/main/kotlin/org/unitedsoftware/alexandrite/tools/exec/Planner.kt @@ -218,7 +218,7 @@ internal class Planner( val executable = allowed(floor.checkRead(resolved, agent, conversation)) { "The program $name lies in $it" } val program = baseName(name) val arguments = argv.drop(1) - val real = executable.fileName.toString() + val real = baseName(executable.fileName.toString()) val opaque = isOpaque(program, arguments) || (!isMultiCall(real) && isOpaque(real, arguments)) val system = resolved.parent in SYSTEM_DIRECTORIES val vetted = !opaque && system && program in VETTED_READ_ONLY && environment.isEmpty() @@ -238,10 +238,10 @@ internal class Planner( } } else { for (candidate in parsed.operands) { - checkUnparsed(candidate, directory, glued = false) - for (piece in pathPieces(candidate)) checkUnparsed(piece, directory, glued = true) + checkUnparsed(candidate, directory, glued = false, opaque = opaque) + for (piece in pathPieces(candidate)) checkUnparsed(piece, directory, glued = true, opaque = opaque) } - for (candidate in gluedValues(argv)) checkUnparsed(candidate, directory, glued = true) + for (candidate in gluedValues(argv)) checkUnparsed(candidate, directory, glued = true, opaque = opaque) } return PlannedRun( argv = argv, @@ -299,9 +299,12 @@ internal class Planner( return canonical } - /** Checks [candidate] as a tree, or as a plain write when it is a [glued] value naming the root. */ - private fun checkUnparsed(candidate: String, directory: Path, glued: Boolean) { - val path = candidatePath(candidate, directory) ?: return + /** + * Checks [candidate] as a tree, or as a plain write when it is a [glued] value naming the root, where an [opaque] + * program runs, whose arguments are code that the floor cannot check anyway. + */ + private fun checkUnparsed(candidate: String, directory: Path, glued: Boolean, opaque: Boolean) { + val path = candidatePath(candidate, directory, refuseUnnameable = !opaque) ?: return val root = glued && candidate.all { it == '/' } val check = if (root) { floor.checkWrite( @@ -401,14 +404,15 @@ private val SCP_ADDRESS = Regex("^[^/\\\\:]+@[^/\\\\:]+:.+") * host at all, such as an address or a word that holds a character no file name of the host may hold. * * A word that names no place has nothing in it that the floor could check, as a shell script has not. A word that - * does look like a path, and that the host cannot name, is refused instead: the floor cannot see through it. + * does look like a path, and that the host cannot name, is refused instead, unless [refuseUnnameable] is false: an + * opaque program runs code that the floor cannot see anyway, so its script text is no path to refuse. */ -private fun candidatePath(candidate: String, directory: Path): Path? { +private fun candidatePath(candidate: String, directory: Path, refuseUnnameable: Boolean = true): Path? { if (isAddress(candidate)) return null return try { directory.resolve(expandHome(candidate)).normalize() } catch (e: InvalidPathException) { - if (!looksLikePath(candidate)) return null + if (!refuseUnnameable || !looksLikePath(candidate)) return null throw FloorRefusal( "'$candidate' is no valid path, so the floor cannot check it: command.exec does not run this call.", ) diff --git a/libraries/tools/src/main/kotlin/org/unitedsoftware/alexandrite/tools/exec/Programs.kt b/libraries/tools/src/main/kotlin/org/unitedsoftware/alexandrite/tools/exec/Programs.kt index 9d8d5420..f83a3870 100644 --- a/libraries/tools/src/main/kotlin/org/unitedsoftware/alexandrite/tools/exec/Programs.kt +++ b/libraries/tools/src/main/kotlin/org/unitedsoftware/alexandrite/tools/exec/Programs.kt @@ -112,11 +112,17 @@ private fun programNames(name: String): List = if (POSIX_HOST) listOf(name) else listOf(name) + PROGRAM_EXTENSIONS.map { "$name$it" } /** Whether [program] (a base name) given [arguments] runs code that the floor cannot see. */ -internal fun isOpaque(program: String, arguments: List): Boolean = when { - program in LAUNCHERS || program in AWKS -> true - program == "find" -> arguments.any { it in FIND_EXEC } - program == "deno" -> arguments.firstOrNull { !it.startsWith("-") } == "eval" || arguments.any(::isDenoEval) - else -> INTERPRETERS.firstOrNull { it.names.matches(program) }?.givesCode(arguments) == true +internal fun isOpaque(program: String, arguments: List): Boolean { + val names = if (POSIX_HOST) listOf(program) else listOf(program, program.lowercase(Locale.ROOT)) + return names.any { name -> isOpaqueName(name, arguments) } +} + +/** Whether the program called [name] runs code that the floor cannot see. */ +private fun isOpaqueName(name: String, arguments: List): Boolean = when { + name in LAUNCHERS || name in WINDOWS_LAUNCHERS || name in AWKS -> true + name == "find" -> arguments.any { it in FIND_EXEC } + name == "deno" -> arguments.firstOrNull { !it.startsWith("-") } == "eval" || arguments.any(::isDenoEval) + else -> INTERPRETERS.firstOrNull { it.names.matches(name) }?.givesCode(arguments) == true } /** Whether [name] is a binary that acts as the program its argv[0] names. */ @@ -144,6 +150,11 @@ private val LAUNCHERS = setOf( private val AWKS = setOf("awk", "gawk", "mawk", "nawk") +/** The programs of a Windows host whose purpose is to run code given in their arguments. */ +private val WINDOWS_LAUNCHERS = setOf( + "cmd", "powershell", "pwsh", "cscript", "wscript", "mshta", "rundll32", "regsvr32", "wsl", +) + private val FIND_EXEC = setOf("-exec", "-execdir", "-ok", "-okdir") private val MULTI_CALL = setOf("busybox", "toybox", "coreutils") diff --git a/libraries/tools/src/test/kotlin/org/unitedsoftware/alexandrite/tools/exec/ExecWindowsTest.kt b/libraries/tools/src/test/kotlin/org/unitedsoftware/alexandrite/tools/exec/ExecWindowsTest.kt index 4f63a43c..a66e4e71 100644 --- a/libraries/tools/src/test/kotlin/org/unitedsoftware/alexandrite/tools/exec/ExecWindowsTest.kt +++ b/libraries/tools/src/test/kotlin/org/unitedsoftware/alexandrite/tools/exec/ExecWindowsTest.kt @@ -11,11 +11,13 @@ import org.unitedsoftware.alexandrite.sdk.chat.ConversationId import org.unitedsoftware.alexandrite.sdk.tool.CallReview import org.unitedsoftware.alexandrite.sdk.tool.FloorCheck import org.unitedsoftware.alexandrite.sdk.tool.HardFloor +import org.unitedsoftware.alexandrite.sdk.tool.ReviewPreview import org.unitedsoftware.alexandrite.sdk.tool.ToolResult import java.nio.file.Path import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -101,6 +103,20 @@ class ExecWindowsTest { ) } + @Test + fun `a script string passed to a Windows shell is no path to refuse, and the shell is opaque`() { + for (argv in listOf( + listOf("powershell", "-NoProfile", "-Command", "Get-Item C:\\work\\*.md"), + listOf("PowerShell", "-NoProfile", "-Command", "Get-Item C:\\work\\*.md"), + listOf("cmd", "/c", "dir C:\\work\\*.md"), + )) { + val review = review(*argv.toTypedArray()) + assertNull(review.refusal, "$argv: ${review.refusal}") + assertNull(review.problem, "$argv: ${review.problem}") + assertTrue(assertIs(review.preview).opaque, "$argv") + } + } + @Test fun `a glob that names a path is still a path, checked as the floor checks any other`() { val script = fs.write("bin/list.cmd", "@echo off\r\n") From 2e06d5919ee55731a8f8a822c5dc49c228b6c157 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Tue, 20 Oct 2026 15:33:33 +0800 Subject: [PATCH 31/37] qqbot: Publish fewer command items when the platform names no limit An answer of 40030013 need not carry the limit it mentions, so a refused menu or panel is retried with five items then, and a refusal that leaves one names the answer itself and how many panels the scope holds. Past the panels the platform keeps for one bot it says that the console has to give way, since no number of items helps there. --- .../channel/qqbot/channel/QqBotMenuSync.kt | 79 ++++++++++++------- .../channel/qqbot/protocol/QqBotPayloads.kt | 7 +- .../qqbot/channel/QqBotMenuSyncTest.kt | 57 +++++++++++++ 3 files changed, 112 insertions(+), 31 deletions(-) diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt index eaff6e71..16cef72f 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt @@ -47,19 +47,11 @@ internal class QqBotMenuSync( private suspend fun putMenu() { var items = commands.take(MENU_ITEMS) var answer = api.put("/v2/menu", menuBody(items)) - if (answer is QqBotAnswer.Failed) { - val limit = answer.failure.limit - if (limit != null && limit in 1 until items.size) { - logger.info( - "{}: the platform takes {} menu items, not {}: publishing {}", - PLUGIN, - limit, - items.size, - limit, - ) - items = items.take(limit) - answer = api.put("/v2/menu", menuBody(items)) - } + if (isOverLimit(answer) && items.size > 1) { + val count = fewerItems(answer, items.size, MENU_FALLBACK) + logger.info("{}: the platform takes {} menu items, not {}: publishing {}", PLUGIN, count, items.size, count) + items = items.take(count) + answer = api.put("/v2/menu", menuBody(items)) } when (answer) { is QqBotAnswer.Ok -> @@ -67,11 +59,12 @@ internal class QqBotMenuSync( is QqBotAnswer.Failed -> logger.warn( - "{}: could not publish the menu: {} {} (limit {})", + "{}: could not publish the menu: {} {} (limit {}, {})", PLUGIN, answer.failure.code, answer.failure.message, answer.failure.limit, + answer.failure.details, ) } } @@ -80,20 +73,18 @@ internal class QqBotMenuSync( val listing = listing(scope) var items = entries.take(PANEL_ITEMS) var answer = writePanel(scope, items, listing.id) - if (answer is QqBotAnswer.Failed) { - val limit = answer.failure.limit - if (limit != null && limit in 1 until items.size) { - logger.info( - "{}: the platform takes {} {} panel items, not {}: publishing {}", - PLUGIN, - limit, - scope, - items.size, - limit, - ) - items = items.take(limit) - answer = writePanel(scope, items, listing.id) - } + if (isOverLimit(answer) && items.size > 1) { + val count = fewerItems(answer, items.size, PANEL_FALLBACK) + logger.info( + "{}: the platform takes {} {} panel items, not {}: publishing {}", + PLUGIN, + count, + scope, + items.size, + count, + ) + items = items.take(count) + answer = writePanel(scope, items, listing.id) } when (answer) { is QqBotAnswer.Ok -> @@ -105,19 +96,37 @@ internal class QqBotMenuSync( entries.size, ) - is QqBotAnswer.Failed -> + is QqBotAnswer.Failed -> { logger.warn( - "{}: could not publish the {} command panel: {} {} (limit {}, {} panels in this scope)", + "{}: could not publish the {} command panel: {} {} (limit {}, {} panels in this scope, {})", PLUGIN, scope, answer.failure.code, answer.failure.message, answer.failure.limit, listing.count, + answer.failure.details, ) + if (listing.id == null && (listing.count ?: 0) >= MAX_PANELS) { + logger.warn( + "{}: the bot already holds the {} panels the platform allows: delete the ones it no longer " + + "needs in the open platform's console", + PLUGIN, + MAX_PANELS, + ) + } + } } } + /** Whether [answer] refuses the number of items that the call gave the platform. */ + private fun isOverLimit(answer: QqBotAnswer): Boolean = + answer is QqBotAnswer.Failed && (answer.failure.code == OVER_LIMIT || answer.failure.limit != null) + + /** How many items to give the platform after it refused [sent] of them: its `limit`, else [fallback]. */ + private fun fewerItems(answer: QqBotAnswer, sent: Int, fallback: Int): Int = + ((answer as? QqBotAnswer.Failed)?.failure?.limit ?: fallback).coerceIn(1, sent - 1) + /** Writes [items] as the [scope] panel of the bot, replacing the panel [existing], and creating one when null. */ private suspend fun writePanel( scope: String, @@ -159,6 +168,16 @@ internal class QqBotMenuSync( /** How many items the platform's command panel takes, as its documentation says. */ private const val PANEL_ITEMS = 20 + /** How many items to try when the platform refuses the number without naming its limit. */ + private const val MENU_FALLBACK = 5 + private const val PANEL_FALLBACK = 5 + + /** The code with which the platform refuses the number of items, naming in `limit` how many it takes. */ + private const val OVER_LIMIT = 40030013 + + /** How many command panels the platform keeps for one bot, as its documentation says. */ + private const val MAX_PANELS = 20 + /** The platform's menu of [commands], a C2C menu of send-message items. */ internal fun menuBody(commands: List): JsonObject = buildJsonObject { put( diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/protocol/QqBotPayloads.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/protocol/QqBotPayloads.kt index db11ce1c..843e4308 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/protocol/QqBotPayloads.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/protocol/QqBotPayloads.kt @@ -146,6 +146,8 @@ internal class QqBotFailure( val traceId: String?, /** How many items the platform takes where it refused their number, null when it named none. */ val limit: Int? = null, + /** What the platform answered, for a log that has to say why it refused, null when the answer said nothing. */ + val details: String? = null, ) /** What a failed answer says, as the fields both kinds of answer use. */ @@ -155,9 +157,12 @@ internal fun failureOf(body: JsonElement?): QqBotFailure { val message = objectBody?.get("message")?.asTextOrNull().orEmpty() val trace = objectBody?.get("trace_id")?.asTextOrNull() val limit = objectBody?.get("limit")?.asIntOrNull() - return QqBotFailure(code, message, trace, limit) + return QqBotFailure(code, message, trace, limit, body?.toString()?.take(DETAILS_LIMIT)) } +/** How much of a failed answer is kept for the log that has to say why the platform refused. */ +private const val DETAILS_LIMIT = 400 + /** The int value of the field, null when it is no number. */ internal fun JsonElement?.asIntOrNull(): Int? = (this as? JsonPrimitive)?.content?.toIntOrNull() diff --git a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt index 02536e8a..900adbe7 100644 --- a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt +++ b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt @@ -157,6 +157,63 @@ class QqBotMenuSyncTest { } } + @Test + fun `a menu the platform refuses without a limit is published with fewer items`() = runBlocking { + val platform = Platform() + try { + var menus = 0 + platform.server.on("/v2/menu") { _ -> + menus++ + if (menus == 1) { + PlatformAnswer(400, """{"code":40030013,"message":"超出数量限制"}""") + } else { + PlatformAnswer(200, """{"version":1}""") + } + } + platform.server.on("/v2/panels") { request -> + if (request.method == "GET") { + PlatformAnswer(200, """{"records":[],"next_cursor":"","is_end":true}""") + } else { + PlatformAnswer(200, """{"panel_id":"p1"}""") + } + } + QqBotMenuSync(platform.api, platform.config, listOf(ManyCommands)).onOpen() + + val calls = platform.server.calls("/v2/menu") + assertEquals(2, calls.size, calls.joinToString("\n") { it.body }) + assertEquals(5, "\"type\":\"send_message\"".toRegex().findAll(calls[1].body).count(), calls[1].body) + } finally { + platform.close() + } + } + + @Test + fun `a command panel the platform refuses without a limit is published with fewer items`() = runBlocking { + val platform = Platform() + try { + val posts = mutableListOf() + platform.server.on("/v2/panels") { request -> + if (request.method == "GET") { + PlatformAnswer(200, """{"records":[],"next_cursor":"","is_end":true}""") + } else { + posts += request.body + if (posts.size % 2 == 1) { + PlatformAnswer(400, """{"code":40030013,"message":"超出数量限制"}""") + } else { + PlatformAnswer(200, """{"panel_id":"p1"}""") + } + } + } + platform.server.on("/v2/menu", """{"version":1}""") + QqBotMenuSync(platform.api, platform.config, listOf(Commands)).onOpen() + + assertEquals(4, posts.size, posts.joinToString("\n")) + assertEquals(1, "\"type\":\"command\"".toRegex().findAll(posts[1]).count(), posts[1]) + } finally { + platform.close() + } + } + /** Twelve commands, so that the menu takes fewer than there are. */ private object ManyCommands : CommandHandler { override val commands: List = (1..12).map { CommandSpec.builder("c$it", "Command $it").build() } From fd77541bca3d44992debf8cff4103e8018e8d693 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Tue, 20 Oct 2026 18:34:48 +0800 Subject: [PATCH 32/37] qqbot: Cut a command panel's text to the units the platform counts The platform counts a character that is not ASCII as two units, and 40030013 is what it answers when a description is over its thirty units rather than when a panel holds too many items: a host whose language is Chinese therefore had every command panel refused, since thirty Chinese characters are sixty units. The item counts of a menu and a panel, and the item names, are cut to the units the platform documents, and a test checks a Chinese and an ASCII description. --- .../channel/qqbot/channel/QqBotMenuSync.kt | 30 ++++++++++++++-- .../qqbot/channel/QqBotMenuSyncTest.kt | 36 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt index 16cef72f..2d43e529 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt @@ -168,6 +168,30 @@ internal class QqBotMenuSync( /** How many items the platform's command panel takes, as its documentation says. */ private const val PANEL_ITEMS = 20 + /** How many units the platform takes in a menu item's name, in a panel item's name, and in its description. */ + private const val MENU_NAME_UNITS = 10 + private const val PANEL_NAME_UNITS = 14 + private const val DESCRIPTION_UNITS = 30 + + /** + * [text] cut to [units] of the platform, which counts a character that is not ASCII as two: a Chinese + * description of more than fifteen characters is refused as one over the limit, and refused as a number of + * items, which is why the count it names is never the fault. + */ + private fun cut(text: String, units: Int): String { + var counted = 0 + var index = 0 + while (index < text.length) { + val codePoint = text.codePointAt(index) + counted += if (codePoint < ASCII) 1 else 2 + if (counted > units) return text.substring(0, index) + index += Character.charCount(codePoint) + } + return text + } + + private const val ASCII = 0x80 + /** How many items to try when the platform refuses the number without naming its limit. */ private const val MENU_FALLBACK = 5 private const val PANEL_FALLBACK = 5 @@ -190,7 +214,7 @@ internal class QqBotMenuSync( add( buildJsonObject { put("type", JsonPrimitive("send_message")) - put("name", JsonPrimitive(command.name.take(10))) + put("name", JsonPrimitive(cut(command.name, MENU_NAME_UNITS))) put("send_message", JsonPrimitive("/${command.name}")) }, ) @@ -223,8 +247,8 @@ internal class QqBotMenuSync( add( buildJsonObject { put("type", JsonPrimitive("command")) - put("name", JsonPrimitive("/${command.name}".take(14))) - put("desc", JsonPrimitive(description.take(30))) + put("name", JsonPrimitive(cut("/${command.name}", PANEL_NAME_UNITS))) + put("desc", JsonPrimitive(cut(description, DESCRIPTION_UNITS))) }, ) } diff --git a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt index 900adbe7..f88ed878 100644 --- a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt +++ b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt @@ -1,6 +1,9 @@ package org.unitedsoftware.alexandrite.channel.qqbot.channel import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import org.unitedsoftware.alexandrite.channel.qqbot.auth.QqBotTokens import org.unitedsoftware.alexandrite.channel.qqbot.net.QqBotApi import org.unitedsoftware.alexandrite.channel.qqbot.net.QqBotHttp @@ -214,6 +217,39 @@ class QqBotMenuSyncTest { } } + @Test + fun `a description is cut to the units the platform counts`() { + val chinese = CommandSpec.builder("model", "x").build() + val english = CommandSpec.builder("whoami", "x").build() + + fun description(handler: CommandHandler, spec: CommandSpec): String { + val panel = QqBotMenuSync.panelBody("c2c", listOf(handler to spec), null)["panel"] as JsonObject + val item = (panel["items"] as JsonArray)[0] as JsonObject + return (item["desc"] as JsonPrimitive).content + } + + val chineseHandler = handler( + listOf(chinese), + "说明".repeat(20), + ) + assertEquals(15, description(chineseHandler, chinese).length, description(chineseHandler, chinese)) + + val englishHandler = handler(listOf(english), "x".repeat(40)) + assertEquals(30, description(englishHandler, english).length, description(englishHandler, english)) + } + + private fun handler(commands: List, text: String): CommandHandler = object : CommandHandler { + override val commands: List = commands + + override fun description( + command: String, + language: org.unitedsoftware.alexandrite.sdk.chat.LanguageTag?, + ): String = text + + override suspend fun handle(invocation: CommandInvocation, context: CommandContext) = + throw UnsupportedOperationException("not used") + } + /** Twelve commands, so that the menu takes fewer than there are. */ private object ManyCommands : CommandHandler { override val commands: List = (1..12).map { CommandSpec.builder("c$it", "Command $it").build() } From 708538b4ff1aeb1c30e38092fc5db010fc192064 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Wed, 21 Oct 2026 23:10:51 +0800 Subject: [PATCH 33/37] qqbot: Keep the gateway reconnecting when the platform is unreachable Buying the access token happened outside the try of QqBotApi.call, so an unreachable token endpoint threw out of every call that needed one. QqBotGateway named its address through such a call outside any catch, so that exception ended the reconnect loop and no further connection was tried until the runtime restarted. The token purchase is now part of the call's try and yields a failed answer. Naming the gateway is caught and retried, the startup tries it up to START_ATTEMPTS before giving up, and the gateway's scope logs an unexpected failure instead of ending without a line. Reading one frame of the platform is guarded as well, so a frame that fails to read does not end the inbox. Tests: a refused token answers Failed instead of throwing; a gateway whose token is refused twice still connects. :libraries:channels:qqbot:spotlessCheck and :test pass: 72 tests, 0 failures. --- .../channel/qqbot/channel/QqBotInbox.kt | 12 +++++- .../channel/qqbot/gateway/QqBotGateway.kt | 35 +++++++++++++--- .../alexandrite/channel/qqbot/net/QqBotApi.kt | 42 +++++++++++-------- .../channel/qqbot/gateway/QqBotGatewayTest.kt | 38 +++++++++++++++++ .../channel/qqbot/net/QqBotApiTest.kt | 40 ++++++++++++++++++ 5 files changed, 143 insertions(+), 24 deletions(-) create mode 100644 libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/net/QqBotApiTest.kt diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotInbox.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotInbox.kt index b16ed753..eb61858c 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotInbox.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotInbox.kt @@ -28,6 +28,7 @@ import org.unitedsoftware.alexandrite.sdk.turn.CommandInvocation import org.unitedsoftware.alexandrite.sdk.turn.Submission import org.unitedsoftware.alexandrite.sdk.turn.TurnSubmitter import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.cancellation.CancellationException /** * What the gateway pushes, taken into the agent. @@ -96,7 +97,16 @@ internal class QqBotInbox( } private suspend fun read() { - gateway.frames.collect { frame -> read(frame) } + gateway.frames.collect { frame -> + // One frame that fails to read must not end the intake: the next message may still reach the agent. + try { + read(frame) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn("{}: reading a frame of the platform failed: {}", PLUGIN, e.toString()) + } + } } /** Takes one frame of the gateway, and the message it dispatches when it dispatches one. */ diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/gateway/QqBotGateway.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/gateway/QqBotGateway.kt index b4a040e8..283a60a6 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/gateway/QqBotGateway.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/gateway/QqBotGateway.kt @@ -1,6 +1,7 @@ package org.unitedsoftware.alexandrite.channel.qqbot.gateway import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -36,6 +37,7 @@ import org.unitedsoftware.alexandrite.sdk.di.Lifecycle import java.net.URI import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.cancellation.CancellationException /** * The gateway of one bot: the WebSocket the open platform pushes its events on. @@ -80,7 +82,10 @@ internal class QqBotGateway( override suspend fun onStart() { check(scope == null) { "The gateway is already open." } val opened = CompletableDeferred() - val run = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val failed = CoroutineExceptionHandler { _, error -> + logger.error("{}: the gateway loop failed", PLUGIN, error) + } + val run = CoroutineScope(SupervisorJob() + Dispatchers.IO + failed) scope = run loop = run.launch { connectLoop(opened) } val failure = try { @@ -105,13 +110,33 @@ internal class QqBotGateway( } private suspend fun connectLoop(opened: CompletableDeferred) { + // How often naming the gateway failed before the runtime was ready, which the startup retries before it gives + // up: the platform may be briefly unreachable, and one blip must not fail the start. + var unnamed = 0 while (scope != null) { - val url = gatewayUrl() - if (url == null) { + // Naming the gateway asks the platform, which may be unreachable: that is a reconnect, not the end of the + // loop, so that a single failed call never leaves the bot without its gateway until the runtime restarts. + val url = try { + gatewayUrl() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { if (!opened.isCompleted) { - opened.complete(IllegalStateException("The platform named no gateway.")) + opened.complete(e) return } + logger.warn("{}: the gateway could not be named: {}; trying again", PLUGIN, e.toString()) + delay(config.reconnectIntervalMillis) + continue + } + if (url == null) { + if (!opened.isCompleted) { + unnamed += 1 + if (unnamed >= START_ATTEMPTS) { + opened.complete(IllegalStateException("The platform named no gateway in $unnamed tries.")) + return + } + } logger.warn("{}: the platform named no gateway; trying again", PLUGIN) delay(config.reconnectIntervalMillis) continue @@ -359,7 +384,7 @@ internal class QqBotGateway( const val EVENT_CAPACITY = 1024 - /** How many times a connect timeout the startup may take: the token, the gateway and the ready frame. */ + /** How often the startup may try a step before it gives up: the token, the gateway and the ready frame. */ const val START_ATTEMPTS = 3L /** The dispatches that carry a message, which are worth a line at the usual level. */ diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/net/QqBotApi.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/net/QqBotApi.kt index bf3a66b7..7ad80a5e 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/net/QqBotApi.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/net/QqBotApi.kt @@ -10,6 +10,7 @@ import org.unitedsoftware.alexandrite.channel.qqbot.protocol.failureOf import org.unitedsoftware.alexandrite.internal.http.HttpCall import org.unitedsoftware.alexandrite.internal.http.HttpException import org.unitedsoftware.alexandrite.sdk.di.ChannelInstanceScoped +import kotlin.coroutines.cancellation.CancellationException /** What a call to the open platform answered: the body, or why it gave none. */ internal sealed class QqBotAnswer { @@ -48,19 +49,21 @@ internal class QqBotApi(private val http: QqBotHttp, private val tokens: QqBotTo } /** Calls [method] on [path] with [body], buying a new token once when the platform says the old one expired. */ - private suspend fun call(method: String, path: String, body: JsonObject?, retried: Boolean = false): QqBotAnswer { - val authorization = tokens.authorization(refresh = retried) - val call = HttpCall( - method = method, - uri = http.uri(path), - headers = buildMap { - put("Authorization", authorization) - if (body != null) put("Content-Type", "application/json; charset=utf-8") - }, - body = body?.toString(), - secrets = listOf(authorization.removePrefix("QQBot ")), - ) - return try { + private suspend fun call(method: String, path: String, body: JsonObject?, retried: Boolean = false): QqBotAnswer = + try { + // Buying the token is part of the call: a platform that cannot be reached for the token must answer the + // caller like any other failed call rather than throw past it. + val authorization = tokens.authorization(refresh = retried) + val call = HttpCall( + method = method, + uri = http.uri(path), + headers = buildMap { + put("Authorization", authorization) + if (body != null) put("Content-Type", "application/json; charset=utf-8") + }, + body = body?.toString(), + secrets = listOf(authorization.removePrefix("QQBot ")), + ) val text = http.send(call) val answer = if (text.isBlank()) { null @@ -73,24 +76,27 @@ internal class QqBotApi(private val http: QqBotHttp, private val tokens: QqBotTo if (failure.code != null && failure.code != 0) { if (failure.code in EXPIRED_TOKEN && !retried) { logger.warn("{}: the token is not taken while calling {}; buying another", PLUGIN, path) - return call(method, path, body, retried = true) + call(method, path, body, retried = true) + } else { + QqBotAnswer.Failed(failure, null) } - QqBotAnswer.Failed(failure, null) } else { QqBotAnswer.Ok(answer) } + } catch (e: CancellationException) { + throw e } catch (e: HttpException) { val parsed = e.body?.let { runCatching { QqBotWire.json.parseToJsonElement(it) }.getOrNull() } if (e.status == UNAUTHORIZED && !retried) { logger.warn("{}: the token expired while calling {}; buying another", PLUGIN, path) - return call(method, path, body, retried = true) + call(method, path, body, retried = true) + } else { + QqBotAnswer.Failed(failureOf(parsed), e.status) } - QqBotAnswer.Failed(failureOf(parsed), e.status) } catch (e: Exception) { logger.warn("{}: the call to {} failed: {}", PLUGIN, path, e.toString()) QqBotAnswer.Failed(QqBotFailure(null, e.toString(), null), null) } - } private companion object { const val PLUGIN = "alexandrite-channel-qqbot" diff --git a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/gateway/QqBotGatewayTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/gateway/QqBotGatewayTest.kt index 6db3e30e..2fd7c715 100644 --- a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/gateway/QqBotGatewayTest.kt +++ b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/gateway/QqBotGatewayTest.kt @@ -9,8 +9,10 @@ import org.unitedsoftware.alexandrite.channel.qqbot.net.QqBotHttp import org.unitedsoftware.alexandrite.channel.qqbot.testing.FakeApiServer import org.unitedsoftware.alexandrite.channel.qqbot.testing.FakeGatewayServer import org.unitedsoftware.alexandrite.channel.qqbot.testing.MutableClock +import org.unitedsoftware.alexandrite.channel.qqbot.testing.PlatformAnswer import org.unitedsoftware.alexandrite.channel.qqbot.testing.testConfig import java.time.Instant +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -56,6 +58,40 @@ class QqBotGatewayTest { } } + @Test + fun `a token the platform cannot sell is retried rather than ending the gateway loop`() = runBlocking { + val api = FakeApiServer() + val bought = AtomicInteger() + api.on(TOKEN_PATH) { + if (bought.incrementAndGet() <= 2) { + PlatformAnswer(500, """{"message":"token endpoint unavailable"}""") + } else { + PlatformAnswer(200, """{"access_token":"access","expires_in":"7200"}""") + } + } + val platform = FakeGatewayServer().startAndAwait() + api.on(GATEWAY_PATH, """{"url":"${platform.url}"}""") + val config = testConfig(api.base) + val http = QqBotHttp(config) + try { + val tokens = QqBotTokens(config, http, clock) + val gateway = QqBotGateway(config, QqBotApi(http, tokens), tokens) + gateway.onStart() + + assertTrue(platform.awaitConnection(), "the gateway was not connected after the token was bought") + val identify = assertNotNull(platform.awaitFrame(predicate = { it.contains("\"op\":2") })) + assertTrue("QQBot access" in identify, identify) + assertTrue(bought.get() >= 3, "the token was bought ${bought.get()} times, so it was not retried") + assertEquals("bot1", gateway.botId) + + gateway.onStop() + } finally { + platform.stop() + http.close() + api.close() + } + } + @Test fun `a close code that ends the session is not resumed`() { assertTrue(dropsSession(4006)) @@ -70,6 +106,8 @@ class QqBotGatewayTest { private companion object { const val TOKEN_PATH = "/app/getAppAccessToken" + const val GATEWAY_PATH = "/gateway" + const val EVENT = """{"id":"event1","op":0,"s":2,"t":"C2C_MESSAGE_CREATE","d":""" + """{"id":"msg1","author":{"id":"u1","user_openid":"u1","username":"Ada"},"content":"hi",""" + """"message_type":0,"timestamp":"2026-07-21T10:00:00+08:00"}}""" diff --git a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/net/QqBotApiTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/net/QqBotApiTest.kt new file mode 100644 index 00000000..bddc0513 --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/net/QqBotApiTest.kt @@ -0,0 +1,40 @@ +package org.unitedsoftware.alexandrite.channel.qqbot.net + +import kotlinx.coroutines.runBlocking +import org.unitedsoftware.alexandrite.channel.qqbot.auth.QqBotTokens +import org.unitedsoftware.alexandrite.channel.qqbot.testing.FakeApiServer +import org.unitedsoftware.alexandrite.channel.qqbot.testing.MutableClock +import org.unitedsoftware.alexandrite.channel.qqbot.testing.PlatformAnswer +import org.unitedsoftware.alexandrite.channel.qqbot.testing.testConfig +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertIs + +/** + * The calls of a bot to the open platform. + * + * A call that cannot get a token must answer `Failed` like any other failed call: throwing from here used to end the + * gateway's reconnect loop, which left the bot without a gateway until the runtime was restarted. + */ +class QqBotApiTest { + private val clock = MutableClock(Instant.parse("2026-07-21T00:00:00Z")) + + @Test + fun `a token the platform cannot sell answers the call as failed instead of throwing`() = runBlocking { + val server = FakeApiServer() + server.on(TOKEN_PATH) { PlatformAnswer(500, """{"message":"token endpoint unavailable"}""") } + val config = testConfig(server.base) + val http = QqBotHttp(config) + try { + val api = QqBotApi(http, QqBotTokens(config, http, clock)) + assertIs(api.get("/users/@me")) + } finally { + http.close() + server.close() + } + } + + private companion object { + const val TOKEN_PATH = "/app/getAppAccessToken" + } +} From d844d3fb86b55bf58b2fe0cb2dd1a321d7704900 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Thu, 22 Oct 2026 00:38:28 +0800 Subject: [PATCH 34/37] onebot, qqbot: Follow the publishing conventions of the build The build publishes every module except the app and the examples, so the two channel modules need a description or their POM fails. Their plugin SDK dependency becomes an API dependency and their plugin entry classes become internal, as the Telegram module has them. --- libraries/channels/onebot/build.gradle.kts | 4 +++- .../unitedsoftware/alexandrite/channel/onebot/OneBotPlugin.kt | 2 +- libraries/channels/qqbot/build.gradle.kts | 4 +++- .../unitedsoftware/alexandrite/channel/qqbot/QqBotPlugin.kt | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/libraries/channels/onebot/build.gradle.kts b/libraries/channels/onebot/build.gradle.kts index d7e977e3..b2fbc69a 100644 --- a/libraries/channels/onebot/build.gradle.kts +++ b/libraries/channels/onebot/build.gradle.kts @@ -3,9 +3,11 @@ plugins { id("alexandrite.kotlin-serialization") } +description = "The OneBot v11 channel of Alexandrite" + dependencies { implementation(project(":libraries:channel-common")) - implementation(project(":libraries:plugin-sdk")) + api(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. diff --git a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotPlugin.kt b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotPlugin.kt index 77604d93..ad77bf0a 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotPlugin.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotPlugin.kt @@ -4,4 +4,4 @@ import org.unitedsoftware.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 +internal class OneBotPlugin diff --git a/libraries/channels/qqbot/build.gradle.kts b/libraries/channels/qqbot/build.gradle.kts index f0394314..02e71730 100644 --- a/libraries/channels/qqbot/build.gradle.kts +++ b/libraries/channels/qqbot/build.gradle.kts @@ -3,8 +3,10 @@ plugins { id("alexandrite.kotlin-serialization") } +description = "The QQ Bot channel of Alexandrite" + dependencies { - implementation(project(":libraries:plugin-sdk")) + api(project(":libraries:plugin-sdk")) implementation(project(":libraries:internal")) // The client of the gateway, of which the JDK serves none. diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotPlugin.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotPlugin.kt index de11a139..6ca12e6a 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotPlugin.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotPlugin.kt @@ -4,4 +4,4 @@ import org.unitedsoftware.alexandrite.sdk.plugin.Plugin /** The QQ official bot channel plugin, which talks to the open platform of the QQ bots. */ @Plugin(name = "QQ Bot", description = "QQ official bot channel") -public class QqBotPlugin +internal class QqBotPlugin From 7c5582c8156cfcf47f2f66a10c0033ae3b5fde07 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Sat, 24 Oct 2026 13:05:55 +0800 Subject: [PATCH 35/37] onebot, qqbot: Follow the merged master and make OneBot's operators its admins The channel modules take the SDK of the merged master: ChatSettings under sdk.turn, ApprovalRequest built by its builder with alwaysRules, FloorCheck read with an else branch, TurnEvent.Content.Preview, and ReplySink.abandon taking vouched. OneBot takes an operator only from the instance's admins, not from a group owner's or administrator's role. The config checks that admins are numbers and that allowedChats are private or group keys. An approval of a member's turn goes only to an administrator chat that paired, and the instance warns at start about administrator chats that did not pair and about an empty admins list. The provider's own image reading is dropped: the merged master normalizes an image before a wire API sends it. --- .../onebot/channel/OneBotAccessGate.kt | 16 +++++- .../channel/onebot/channel/OneBotApprovals.kt | 32 ++++++++--- .../channel/onebot/channel/OneBotChannel.kt | 3 +- .../channel/onebot/channel/OneBotProgress.kt | 5 +- .../channel/onebot/config/OneBotConfig.kt | 13 +++++ .../channel/onebot/mapping/OneBotMessages.kt | 5 +- .../channel/onebot/tools/OneBotActionTool.kt | 4 ++ .../onebot/OneBotApprovalIntegrationTest.kt | 56 ++++++++++++++++--- .../channel/onebot/config/OneBotConfigTest.kt | 45 +++++++++++++++ .../onebot/mapping/OneBotMessagesTest.kt | 13 ++++- .../channel/qqbot/channel/QqBotApprovals.kt | 6 +- .../channel/qqbot/channel/QqBotChannel.kt | 3 +- .../channel/qqbot/config/QqBotConfig.kt | 5 +- .../qqbot/QqBotChannelIntegrationTest.kt | 12 ++-- .../provider/common/chat/ChatRequest.kt | 39 +------------ .../provider/common/chat/ChatRequestTest.kt | 36 ------------ 16 files changed, 183 insertions(+), 110 deletions(-) create mode 100644 libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfigTest.kt diff --git a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotAccessGate.kt b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotAccessGate.kt index 1f7c3294..aaac8412 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotAccessGate.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotAccessGate.kt @@ -5,7 +5,9 @@ import org.unitedsoftware.alexandrite.channel.common.pairing.InstancePairing import org.unitedsoftware.alexandrite.channel.onebot.config.OneBotInstanceConfig import org.unitedsoftware.alexandrite.channel.onebot.config.configPath import org.unitedsoftware.alexandrite.channel.onebot.i18n.TextKeys +import org.unitedsoftware.alexandrite.channel.onebot.mapping.OneBotChats import org.unitedsoftware.alexandrite.channel.onebot.pairing.OneBotPairingDesk +import org.unitedsoftware.alexandrite.channel.onebot.protocol.UserId import org.unitedsoftware.alexandrite.sdk.channel.ChannelInstance import org.unitedsoftware.alexandrite.sdk.channel.MessageKind import org.unitedsoftware.alexandrite.sdk.channel.OutboundMessage @@ -91,10 +93,19 @@ internal class OneBotAccessGate( } } - /** Warns once of each home chat and linked chat of this instance that is not paired. */ + /** Warns once of each home chat, linked chat and administrator chat of this instance that is not paired. */ private fun warnUnpaired(pairing: InstancePairing) { - if (catalog == null && links == null) return val uses = LinkedHashMap>() + for (admin in config.admins) { + uses.getOrPut(OneBotChats.privateChat(instance.id, UserId(admin)), ::LinkedHashSet) += + "the chat of administrator $admin" + } + if (config.admins.isEmpty()) { + logger.warn( + "{} names no admins, so no administrator can answer an approval and nobody is an operator", + instance.id, + ) + } try { for (agent in catalog?.agents(instance.id).orEmpty()) { agent.home?.let { uses.getOrPut(it.parent, ::LinkedHashSet) += "the home chat of agent ${agent.id}" } @@ -108,7 +119,6 @@ internal class OneBotAccessGate( throw e } catch (e: Exception) { logger.warn("Cannot tell whether the home chats and linked chats of {} are paired: {}", instance.id, "$e") - return } for ((chat, use) in uses.filterKeys { !pairing.isPaired(it) }) { logger.warn( diff --git a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotApprovals.kt b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotApprovals.kt index 9ca10b42..8f307711 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotApprovals.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotApprovals.kt @@ -23,13 +23,13 @@ import org.unitedsoftware.alexandrite.sdk.channel.Delivery import org.unitedsoftware.alexandrite.sdk.channel.MessageKind import org.unitedsoftware.alexandrite.sdk.channel.OutboundMessage import org.unitedsoftware.alexandrite.sdk.chat.ChatAddress -import org.unitedsoftware.alexandrite.sdk.chat.ChatSettings import org.unitedsoftware.alexandrite.sdk.chat.ChatUser import org.unitedsoftware.alexandrite.sdk.chat.LanguageTag import org.unitedsoftware.alexandrite.sdk.di.ChannelInstanceScoped import org.unitedsoftware.alexandrite.sdk.di.Lifecycle import org.unitedsoftware.alexandrite.sdk.i18n.Texts import org.unitedsoftware.alexandrite.sdk.tool.ReviewPreview +import org.unitedsoftware.alexandrite.sdk.turn.ChatSettings import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.minutes @@ -52,6 +52,8 @@ internal class OneBotApprovals( private val config: OneBotInstanceConfig, private val sender: OneBotSender, private val texts: Texts, + /** The chats this instance serves, whose pairing a chat of no operator may need before it can answer. */ + private val gate: OneBotAccessGate, /** The language of each chat, null while the runtime has no agent to ask. */ private val settings: ChatSettings?, ) : Lifecycle { @@ -62,7 +64,16 @@ internal class OneBotApprovals( /** The answer of the first admin who answers [request], else how the request ended. */ suspend fun ask(request: ApprovalRequest): ApprovalAnswer { val chats = recipients(request) - if (chats.isEmpty()) return UNREACHABLE + if (chats.isEmpty()) { + // A chat that never paired is offered its code when somebody addresses the bot in it, never sent a + // request it cannot answer. + logger.warn( + "{} cannot ask for approval {}: no administrator chat of it is paired", + instance.id, + request.id, + ) + return UNREACHABLE + } val waiting = Pending(request) if (pending.putIfAbsent(request.id, waiting) != null) { return ApprovalAnswer.Unavailable("another request has the id ${request.id}") @@ -132,13 +143,20 @@ internal class OneBotApprovals( pending.values.forEach { it.ending.complete(Ending.Answered(ApprovalAnswer.Unavailable("the bot stopped"))) } } - /** The chats [request] is shown in: the chat of an admin's own turn, else the direct chats of the admins. */ + /** + * The chats [request] is shown in: the chat of an admin's own turn, else the paired direct chats of the admins. + * + * The pairing gate is not bypassed for an approval: a chat that never paired is offered its code when somebody + * addresses the bot in it, and a request is not sent where it could not be answered. + */ private fun recipients(request: ApprovalRequest): List { val turn = request.turn return if (turn.actorIsOperator) { listOf(turn.chat) } else { - config.admins.distinct().map { OneBotChats.privateChat(instance.id, UserId(it)) } + config.admins.distinct() + .map { OneBotChats.privateChat(instance.id, UserId(it)) } + .filter { gate.isPaired(it) } } } @@ -227,7 +245,7 @@ internal class OneBotApprovals( val waiting = pending[id] ?: return text(TextKeys.APPROVAL_NOT_PENDING, language) val request = waiting.request val answer = if (allowed) { - ApprovalAnswer.Allowed(by, always && request.always != null) + ApprovalAnswer.Allowed(by, always && request.alwaysRules.isNotEmpty()) } else { ApprovalAnswer.Denied(by) } @@ -298,7 +316,7 @@ internal class OneBotApprovals( language, "id" to request.id, "name" to named(answer.by), - "rule" to request.always?.display.orEmpty(), + "rule" to request.alwaysRules.firstOrNull()?.display.orEmpty(), ) } else { text( @@ -340,7 +358,7 @@ internal class OneBotApprovals( ).append("\n\n") append(body(request, language)) append("\n\n").append(text(TextKeys.APPROVAL_ANSWER, language)) - request.always?.let { + request.alwaysRules.firstOrNull()?.let { append(' ').append(text(TextKeys.APPROVAL_ALWAYS_RULE, language, "rule" to it.display)) } } diff --git a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotChannel.kt b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotChannel.kt index 24312748..06ad5174 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotChannel.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotChannel.kt @@ -270,7 +270,8 @@ internal class OneBotChannel( return message.rebuild { replyTo(trigger) } } - override suspend fun abandon(end: ReplyEnd) { + @Suppress("UNUSED_PARAMETER") + override suspend fun abandon(end: ReplyEnd, vouched: Boolean) { logger.info( "The turn of {} was abandoned: its {} waiting steps are told, and its reply closes", request.turn.chat, diff --git a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotProgress.kt b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotProgress.kt index f2da6bf8..40b3fb27 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotProgress.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotProgress.kt @@ -8,6 +8,7 @@ import org.unitedsoftware.alexandrite.sdk.chat.LanguageTag import org.unitedsoftware.alexandrite.sdk.chat.RunId import org.unitedsoftware.alexandrite.sdk.chat.ToolCallId import org.unitedsoftware.alexandrite.sdk.i18n.Texts +import org.unitedsoftware.alexandrite.sdk.tool.ReviewPreview import org.unitedsoftware.alexandrite.sdk.tool.ToolActivity import kotlin.jvm.Synchronized @@ -309,7 +310,9 @@ private fun suffix(call: ProgressCall, texts: Texts, language: LanguageTag?): St return " — " + texts.text(TextKeys.ACTIVITY_CALL_EXIT, language, "code" to it) } - is TurnEvent.Content.Diff -> lineCounts(content.unified)?.let { (added, removed) -> + is TurnEvent.Content.Preview -> (content.preview as? ReviewPreview.Diff)?.let { + lineCounts(it.unified) + }?.let { (added, removed) -> return " (" + texts.text(TextKeys.ACTIVITY_CALL_DIFF, language, "added" to added, "removed" to removed) + ")" } diff --git a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfig.kt b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfig.kt index a961c37b..6a7085a3 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfig.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfig.kt @@ -150,6 +150,16 @@ public class OneBotInstanceConfig( "selfId is the number the implementation reports, was '$selfId'" } } + for (admin in admins) { + require(admin.isNotEmpty() && admin.all(Char::isDigit)) { + "admins holds the numbers of the accounts that operate this instance, was '$admin'" + } + } + for (chat in allowedChats) { + require(ALLOWED_CHAT.matches(chat)) { + "allowedChats holds chats as private: or group:, was '$chat'" + } + } } public companion object { @@ -166,6 +176,9 @@ public class OneBotInstanceConfig( public const val DEFAULT_APPROVAL_MINUTES: Int = 10 public const val MAX_PORT: Int = 65_535 + + /** The shape of a chat key in [OneBotInstanceConfig.allowedChats]. */ + private val ALLOWED_CHAT: Regex = Regex("(private|group):[0-9]+") } } diff --git a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/mapping/OneBotMessages.kt b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/mapping/OneBotMessages.kt index abba25b4..e74cdb14 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/mapping/OneBotMessages.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/mapping/OneBotMessages.kt @@ -235,7 +235,7 @@ public object OneBotMessages { else -> DeliveryFailure.UNKNOWN } - /** Who sent [event], as an operator of the instance when the report or the list of [admins] says so. */ + /** Who sent [event], an operator of the instance when the configured [admins] name them and nobody else. */ public fun senderOf(instance: ChannelInstanceId, event: OneBotEvent.Message, admins: Set): ChatUser = sender(instance, event, admins) @@ -248,7 +248,8 @@ public object OneBotMessages { displayName = card ?: reported.nickname ?: id, username = null, isBot = reported.userId == event.selfId.value, - isOperator = id in admins || (event is OneBotEvent.Message.Group && reported.isOperator), + // A platform role is not an operator of this bot: only the accounts the instance names in `admins` are. + isOperator = id in admins, ) } diff --git a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/tools/OneBotActionTool.kt b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/tools/OneBotActionTool.kt index b69fe30c..8a358e26 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/tools/OneBotActionTool.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/tools/OneBotActionTool.kt @@ -308,6 +308,10 @@ internal suspend fun withFiles( ) is FloorCheck.Allowed -> check.canonical + + else -> return Prepared.Refused( + "The action '${row.name}' may not read '$given': the floor gave no answer this version knows.", + ) } val size = try { withContext(Dispatchers.IO) { Files.size(allowed) } diff --git a/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt b/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt index 549e117c..1cfbfa24 100644 --- a/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt +++ b/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt @@ -182,6 +182,26 @@ class OneBotApprovalIntegrationTest { } } + @Test + fun `an approval of a member is not sent to a chat that never paired`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port, allowedChats = listOf("group:987654")) { + val answer = front().askApproval(requestOfMember("m1x2")) + assertIs(answer) + assertTrue( + waiting { peer.awaitQuiet() }.isEmpty(), + "an approval reached a chat that never paired", + ) + } + } + } finally { + peer.stop() + } + } + @Test fun `an answer that is taken is said once`() { val peer = ApprovalPeer() @@ -208,10 +228,15 @@ class OneBotApprovalIntegrationTest { } } - /** Runs [block] with the plugin built over the peer on [port]. */ - private suspend fun harness(peer: ApprovalPeer, port: Int, block: suspend Running.() -> Unit) { + /** Runs [block] with the plugin built over the peer on [port], serving [allowedChats]. */ + private suspend fun harness( + peer: ApprovalPeer, + port: Int, + allowedChats: List = listOf("group:987654", "private:12345678"), + block: suspend Running.() -> Unit, + ) { PluginHarness.builder(AlexandriteChannelOnebotIndex()) - .config(config(port)) + .config(config(port, allowedChats)) .turnSubmitter(RecordingTurnSubmitter()) .build() .run { @@ -228,16 +253,24 @@ class OneBotApprovalIntegrationTest { get(directory).channel(instance) ?: error("the instance has no channel") /** An approval request of [id] that the user of the group asks for. */ - private fun request(id: String): ApprovalRequest = ApprovalRequest( + private fun request(id: String): ApprovalRequest = ApprovalRequest.builder( id = id, turn = testTurn(chat = group) { actor(admin) }, tool = "fs.write", risk = ToolRisk.WORKSPACE_WRITE, arguments = """{"path":"notes.md"}""", - preview = null, reason = "the chat is in ask mode", - alwaysRules = emptyList(), - ) + ).build() + + /** An approval request of [id] that a member of the group asks for, which no operator's chat can answer. */ + private fun requestOfMember(id: String): ApprovalRequest = ApprovalRequest.builder( + id = id, + turn = testTurn(chat = group) { actor(member) }, + tool = "fs.write", + risk = ToolRisk.WORKSPACE_WRITE, + arguments = """{"path":"notes.md"}""", + reason = "the chat is in ask mode", + ).build() /** A report of the group that answers the message [replyTo] with [text], sent by [from]. */ private fun command(replyTo: String? = null, text: String, from: String = USER, role: String = "admin"): String = @@ -257,7 +290,10 @@ class OneBotApprovalIntegrationTest { private val admin: ChatUser = ChatUser(UserAddress(instance, USER), "someone", null, isBot = false, isOperator = true) - private fun config(port: Int): String = """ + private val member: ChatUser = + ChatUser(UserAddress(instance, MEMBER), "someone", null, isBot = false, isOperator = false) + + private fun config(port: Int, allowedChats: List): String = """ { "settings": {"eventQueueCapacity": 64}, "instances": { @@ -268,7 +304,7 @@ class OneBotApprovalIntegrationTest { "firstByteTimeoutMillis": 5000, "idleTimeoutMillis": 5000, "reconnectIntervalMillis": 200, - "allowedChats": ["group:987654", "private:12345678"], + "allowedChats": [${allowedChats.joinToString(",") { "\"$it\"" }}], "admins": ["$USER"], "approvalMinutes": 1 } @@ -278,6 +314,8 @@ class OneBotApprovalIntegrationTest { private companion object { const val USER = "12345678" + + const val MEMBER = "99999999" } /** An implementation that serves the API, numbering the messages it is told to send, and reports what the test says. */ diff --git a/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfigTest.kt b/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfigTest.kt new file mode 100644 index 00000000..18c23384 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfigTest.kt @@ -0,0 +1,45 @@ +package org.unitedsoftware.alexandrite.channel.onebot.config + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class OneBotConfigTest { + @Test + fun `an admin is the number of an account`() { + assertFailsWith { + instance(admins = setOf("someone")) + } + assertFailsWith { + instance(admins = setOf("")) + } + assertEquals(setOf("12345678"), instance(admins = setOf("12345678")).admins) + } + + @Test + fun `an allowed chat is a private or group key with a number`() { + assertFailsWith { + instance(allowedChats = listOf("qq:12345678")) + } + assertFailsWith { + instance(allowedChats = listOf("private:someone")) + } + val chats = listOf("private:12345678", "group:987654") + assertEquals(chats, instance(allowedChats = chats).allowedChats) + } + + @Test + fun `an instance without admins starts, with nobody an operator`() { + assertEquals(emptySet(), instance(admins = emptySet()).admins) + } + + private fun instance( + admins: Set = emptySet(), + allowedChats: List = emptyList(), + ): OneBotInstanceConfig = OneBotInstanceConfig( + transport = "ws", + endpoint = "ws://127.0.0.1:3001", + admins = admins, + allowedChats = allowedChats, + ) +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/mapping/OneBotMessagesTest.kt b/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/mapping/OneBotMessagesTest.kt index 0c3297de..5c231b5d 100644 --- a/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/mapping/OneBotMessagesTest.kt +++ b/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/mapping/OneBotMessagesTest.kt @@ -24,6 +24,7 @@ import org.unitedsoftware.alexandrite.sdk.chat.ChatAddress import org.unitedsoftware.alexandrite.sdk.chat.ChatKind import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -44,11 +45,11 @@ class OneBotMessagesTest { } @Test - fun `a group message keeps its group and marks an administrator`() { + fun `a group message keeps its group and takes no operator from a platform role`() { val message = OneBotMessages.incoming(instance, groupEvent(role = "admin")) assertEquals("group:100100", message.chat.chat) assertEquals(ChatKind.GROUP, message.chatInfo.kind) - assertTrue(message.sender.isOperator) + assertFalse(message.sender.isOperator, "a platform administrator was treated as an operator") assertEquals("the card", message.sender.displayName) } @@ -58,6 +59,14 @@ class OneBotMessagesTest { assertTrue(message.sender.isOperator) } + @Test + fun `an operator is named by the instance and not by the group owner role`() { + val owner = OneBotMessages.incoming(instance, groupEvent(role = "owner")) + assertFalse(owner.sender.isOperator, "a group owner was treated as an operator") + val named = OneBotMessages.incoming(instance, groupEvent(role = "owner"), admins = setOf("12345678")) + assertTrue(named.sender.isOperator) + } + @Test fun `an anonymous group message is a fact of the message`() { val message = OneBotMessages.incoming(instance, groupEvent(anonymous = true)) diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotApprovals.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotApprovals.kt index 6845f644..fd9a3111 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotApprovals.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotApprovals.kt @@ -89,7 +89,7 @@ internal class QqBotApprovals( val answer = if (name == QqBotApprovalCommands.DENY) { ApprovalAnswer.Denied(by) } else { - ApprovalAnswer.Allowed(by, always && waiting.request.always != null) + ApprovalAnswer.Allowed(by, always && waiting.request.alwaysRules.isNotEmpty()) } if (!waiting.ending.complete(answer)) return null logger.info("{}: approval {} of {} answered by {}", instance.id, id, waiting.request.tool, by.address) @@ -114,7 +114,9 @@ internal class QqBotApprovals( request.id, text(TextKeys.APPROVAL_ALLOW, request.turn.language), text(TextKeys.APPROVAL_DENY, request.turn.language), - request.always?.let { text(TextKeys.APPROVAL_ALWAYS, request.turn.language, "rule" to it.display) }, + request.alwaysRules.firstOrNull()?.let { + text(TextKeys.APPROVAL_ALWAYS, request.turn.language, "rule" to it.display) + }, ) if (delivery is Delivery.Delivered) { shown += chat diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotChannel.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotChannel.kt index de67d488..5e06b4e6 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotChannel.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotChannel.kt @@ -234,7 +234,8 @@ internal class QqBotChannel( } } - override suspend fun abandon(end: ReplyEnd) = lock.withLock { + @Suppress("UNUSED_PARAMETER") + override suspend fun abandon(end: ReplyEnd, vouched: Boolean) = lock.withLock { logger.info("The reply of {} was abandoned: {}", request.turn.id, end) replies.close(request.turn.id) progress.abandon(request.turn.id) diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/config/QqBotConfig.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/config/QqBotConfig.kt index 8087353e..0f781695 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/config/QqBotConfig.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/config/QqBotConfig.kt @@ -33,7 +33,10 @@ public class QqBotInstanceConfig( public val progress: Boolean = true, /** Whether a single chat is answered with the platform's streaming message, false to send one message instead. */ public val streaming: Boolean = true, - /** The accounts that may operate this instance, by their open id in the chat they write in. */ + /** + * The accounts that may operate this instance, by the open id a single chat names them by, which is the chat an + * approval of theirs is shown in. A group's administrators are its platform `member_role`, not this list. + */ public val admins: Set = emptySet(), /** Whether the bot publishes its slash commands as the single-chat menu and command panels when it opens. */ @SerialName("syncCommands") public val syncCommands: Boolean = true, diff --git a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt index b1d6d0e0..b7dab05d 100644 --- a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt +++ b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt @@ -376,16 +376,14 @@ class QqBotChannelIntegrationTest { assertTrue(platform.awaitConnection(), "the gateway was not connected") val channel = get(directory).channel(instance) ?: error("the instance has no channel") val chat = ChatAddress(instance, "private:u1") - val request = ApprovalRequest( + val request = ApprovalRequest.builder( "req1", testTurn(chat = chat), "command.exec", ToolRisk.EXEC, "{}", - null, "it runs a command", - emptyList(), - ) + ).build() val waiting = async(start = CoroutineStart.UNDISPATCHED) { channel.askApproval(request) } assertTrue(sendReached.await(5, TimeUnit.SECONDS), "the approval message was not sent") val shown = assertNotNull(api.awaitRequest(SEND_DIRECT)) @@ -429,16 +427,14 @@ class QqBotChannelIntegrationTest { assertTrue(platform.awaitConnection(), "the gateway was not connected") val channel = get(directory).channel(instance) ?: error("the instance has no channel") val chat = ChatAddress(instance, "private:u1") - val request = ApprovalRequest( + val request = ApprovalRequest.builder( "req1", testTurn(chat = chat), "command.exec", ToolRisk.EXEC, "{}", - null, "it runs a command", - emptyList(), - ) + ).build() val waiting = async(start = CoroutineStart.UNDISPATCHED) { channel.askApproval(request) } assertNotNull(api.awaitRequest(SEND_DIRECT)) platform.push(APPROVAL_DENY) diff --git a/libraries/provider-common/src/main/kotlin/org/unitedsoftware/alexandrite/provider/common/chat/ChatRequest.kt b/libraries/provider-common/src/main/kotlin/org/unitedsoftware/alexandrite/provider/common/chat/ChatRequest.kt index e61ac503..d36548c4 100644 --- a/libraries/provider-common/src/main/kotlin/org/unitedsoftware/alexandrite/provider/common/chat/ChatRequest.kt +++ b/libraries/provider-common/src/main/kotlin/org/unitedsoftware/alexandrite/provider/common/chat/ChatRequest.kt @@ -151,42 +151,14 @@ internal class ChatRequest( private fun media(part: MediaPart): Content { val source = part.source if (part.kind == MediaKind.IMAGE && MediaKind.IMAGE in info.inputMedia && source is InlineMedia) { - val bytes = source.bytes() - val type = imageType(part.mediaType, bytes) - if (type != null) { - return Content.Image("data:$type;base64," + Base64.getEncoder().encodeToString(bytes)) - } - // A channel that can only name the kind of what it holds leaves the format to the bytes. Sending the kind - // as the type is refused for the whole request rather than for this part, which fails every turn that - // carries it, so what no format is read as is left out and said to be. - warnings += Warning( - "unsupported_media", - "Model '${info.id}' cannot take ${part.kind} ${part.mediaType}: no image of a known format.", - ) - return Content.Text(placeholder(part, "it is in no format the model takes")) + val encoded = Base64.getEncoder().encodeToString(source.bytes()) + return Content.Image("data:${part.mediaType};base64,$encoded") } warnings += Warning(WarningKind.UNSUPPORTED_MEDIA, "Model '${info.id}' cannot take ${part.kind} ${part.mediaType}.") return Content.Text(placeholder(part, "the model cannot take it")) } - // The type to send the bytes as: what the channel declared where it names a format, and where it names only the - // kind, the format the bytes themselves are, or null when they are no image the models take. - private fun imageType(declared: String, bytes: ByteArray): String? { - if (!declared.endsWith("/*")) return declared - return when { - bytes.startsWith(PNG) -> "image/png" - bytes.startsWith(JPEG) -> "image/jpeg" - bytes.startsWith(GIF) -> "image/gif" - bytes.startsWith(RIFF) && bytes.size > 12 && bytes.copyOfRange(8, 12).contentEquals(WEBP) -> "image/webp" - else -> null - } - } - - // Whether these bytes begin with those, which is how a format is read from an image. - private fun ByteArray.startsWith(prefix: ByteArray): Boolean = - size >= prefix.size && prefix.indices.all { this[it] == prefix[it] } - private fun assistant(entry: AssistantEntry): JsonObject = buildJsonObject { put("role", "assistant") val parts = entry.parts @@ -281,12 +253,5 @@ internal class ChatRequest( private companion object { const val SEPARATOR = "\n\n" - - // The bytes an image of each format begins with, which say what a channel that named only the kind holds. - val PNG: ByteArray = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) - val JPEG: ByteArray = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte()) - val GIF: ByteArray = "GIF8".toByteArray() - val RIFF: ByteArray = "RIFF".toByteArray() - val WEBP: ByteArray = "WEBP".toByteArray() } } diff --git a/libraries/provider-common/src/test/kotlin/org/unitedsoftware/alexandrite/provider/common/chat/ChatRequestTest.kt b/libraries/provider-common/src/test/kotlin/org/unitedsoftware/alexandrite/provider/common/chat/ChatRequestTest.kt index 6048d3de..775f72db 100644 --- a/libraries/provider-common/src/test/kotlin/org/unitedsoftware/alexandrite/provider/common/chat/ChatRequestTest.kt +++ b/libraries/provider-common/src/test/kotlin/org/unitedsoftware/alexandrite/provider/common/chat/ChatRequestTest.kt @@ -298,42 +298,6 @@ class ChatRequestTest { assertEquals(listOf("unsupported_media"), blind.warnings.map { it.kind.id }) } - @Test - fun `an image whose channel names only the kind goes out as the format its bytes are`() { - val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) - val jpeg = MediaPart(MediaKind.IMAGE, "image/jpeg", InlineMedia(byteArrayOf(1, 2, 3)), "dot.jpg") - val sniffedPng = MediaPart(MediaKind.IMAGE, "image/*", InlineMedia(png), "dot") - val gif = MediaPart(MediaKind.IMAGE, "image/*", InlineMedia("GIF89a".toByteArray()), "dot") - val webp = MediaPart(MediaKind.IMAGE, "image/*", InlineMedia("RIFF____WEBPVP8 ".toByteArray()), "dot") - - // A channel that names the format is taken at its word, whatever the bytes are. - assertEquals("data:image/jpeg", sent(jpeg).substring(0, 15)) - // One that names only the kind leaves the format to the bytes, which is what the model is sent. - assertEquals("data:image/png", sent(sniffedPng).substring(0, 14)) - assertEquals("data:image/gif", sent(gif).substring(0, 14)) - assertEquals("data:image/webp", sent(webp).substring(0, 15)) - } - - @Test - fun `an image of no known format is left out and said to be, rather than sent as a type the model refuses`() { - val not = MediaPart(MediaKind.IMAGE, "image/*", InlineMedia("no picture".toByteArray()), "dot") - - val refused = chat(request(listOf(UserEntry(null, listOf(not), user("x").origin)))) - - assertEquals( - "[image dot not shown: it is in no format the model takes]", - refused.body.messages().single().text("content"), - ) - assertEquals(listOf("unsupported_media"), refused.warnings.map { it.kind }) - } - - /** The data URL the model is sent for [part]. */ - private fun sent(part: MediaPart): String { - val body = body(request(listOf(UserEntry(null, listOf(part), user("x").origin)))) - return body.messages().single()["content"]!!.jsonArray.single().jsonObject["image_url"]!! - .jsonObject.text("url")!! - } - @Test fun `tools, tool choice and options map onto their fields`() { val options = ModelOptions.builder().maxOutputTokens(512).temperature(0.2).topP(0.9) From 5e56fb50214f1b4bd143d2201ef61fb2afb6dacf Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 26 Oct 2026 15:23:50 +0800 Subject: [PATCH 36/37] onebot, qqbot: Show every rule an approval grants and run the QQ tests that never ran OneBot and QQ list every rule that allowing a call for good grants, and QQ's always button names no rule so the body can name each one. OneBot answers /approve and /deny in any case, and keeps approvalMinutes between one and 1440 minutes. Two QQ integration tests returned Termination, which JUnit does not discover; they return Unit now and run. --- .../channel/onebot/channel/OneBotApprovals.kt | 31 ++++++++-- .../channel/onebot/config/OneBotConfig.kt | 6 +- .../channel/onebot/i18n/TextKeys.kt | 3 + .../alexandrite-channel-onebot/en.properties | 1 + .../onebot/OneBotApprovalIntegrationTest.kt | 57 +++++++++++++++++++ .../channel/onebot/config/OneBotConfigTest.kt | 9 +++ .../channel/qqbot/channel/QqBotApprovals.kt | 5 +- .../channel/qqbot/channel/QqBotChannel.kt | 2 +- .../channel/qqbot/config/QqBotConfig.kt | 5 +- .../channel/qqbot/i18n/TextKeys.kt | 1 + .../alexandrite-channel-qqbot/en.properties | 1 + .../qqbot/QqBotChannelIntegrationTest.kt | 18 +++++- 12 files changed, 126 insertions(+), 13 deletions(-) diff --git a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotApprovals.kt b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotApprovals.kt index 8f307711..5290575b 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotApprovals.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/channel/OneBotApprovals.kt @@ -114,8 +114,8 @@ internal class OneBotApprovals( } } - /** Whether [name] is a command this desk answers. */ - fun answers(name: String): Boolean = name == APPROVE || name == DENY + /** Whether [name] is a command this desk answers, in any case. */ + fun answers(name: String): Boolean = name.equals(APPROVE, ignoreCase = true) || name.equals(DENY, ignoreCase = true) /** * Takes the approval command [command] that [by] wrote in [event], and the text to reply with, null when the reply @@ -129,9 +129,18 @@ internal class OneBotApprovals( val code = words.firstOrNull { !it.equals(ALWAYS, ignoreCase = true) } return when (val resolution = resolve(chat, event, code)) { Resolution.None -> text(TextKeys.APPROVAL_NOTHING_WAITS, language(chat)) + is Resolution.Listing -> listing(resolution.requests, language(chat)) + is Resolution.Ambiguous -> several(resolution.ids, language(chat)) - is Resolution.One -> decide(resolution.id, by, command.name == APPROVE, always, language(chat)) + + is Resolution.One -> decide( + resolution.id, + by, + command.name.equals(APPROVE, ignoreCase = true), + always, + language(chat), + ) } } @@ -316,7 +325,7 @@ internal class OneBotApprovals( language, "id" to request.id, "name" to named(answer.by), - "rule" to request.alwaysRules.firstOrNull()?.display.orEmpty(), + "rule" to request.alwaysRules.joinToString(", ") { it.display }, ) } else { text( @@ -358,8 +367,18 @@ internal class OneBotApprovals( ).append("\n\n") append(body(request, language)) append("\n\n").append(text(TextKeys.APPROVAL_ANSWER, language)) - request.alwaysRules.firstOrNull()?.let { - append(' ').append(text(TextKeys.APPROVAL_ALWAYS_RULE, language, "rule" to it.display)) + if (request.alwaysRules.size == 1) { + append(' ').append( + text(TextKeys.APPROVAL_ALWAYS_RULE, language, "rule" to request.alwaysRules.first().display), + ) + } else if (request.alwaysRules.size > 1) { + append(' ').append( + text( + TextKeys.APPROVAL_ALWAYS_RULES, + language, + "rules" to request.alwaysRules.joinToString(", ") { it.display }, + ), + ) } } diff --git a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfig.kt b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfig.kt index 6a7085a3..2cade0c9 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfig.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfig.kt @@ -121,7 +121,9 @@ public class OneBotInstanceConfig( 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." } - require(approvalMinutes > 0) { "An approval waits at least a minute, was $approvalMinutes." } + require(approvalMinutes in 1..MAX_APPROVAL_MINUTES) { + "An approval waits between 1 and $MAX_APPROVAL_MINUTES minutes, was $approvalMinutes." + } 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. @@ -175,6 +177,8 @@ public class OneBotInstanceConfig( public const val DEFAULT_APPROVAL_MINUTES: Int = 10 + public const val MAX_APPROVAL_MINUTES: Int = 1_440 + public const val MAX_PORT: Int = 65_535 /** The shape of a chat key in [OneBotInstanceConfig.allowedChats]. */ diff --git a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/i18n/TextKeys.kt b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/i18n/TextKeys.kt index c4d60251..2dd7fa0c 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/i18n/TextKeys.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/onebot/i18n/TextKeys.kt @@ -39,6 +39,9 @@ internal object TextKeys { /** Takes `{rule}`. */ const val APPROVAL_ALWAYS_RULE: String = "approval.always_rule" + /** Takes `{rules}`, the rules joined for a request that may grant several. */ + const val APPROVAL_ALWAYS_RULES: String = "approval.always_rules" + /** Takes `{id}` and `{name}`. */ const val APPROVAL_OUTCOME_ALLOWED: String = "approval.outcome_allowed" diff --git a/libraries/channels/onebot/src/main/resources/alexandrite/i18n/alexandrite-channel-onebot/en.properties b/libraries/channels/onebot/src/main/resources/alexandrite/i18n/alexandrite-channel-onebot/en.properties index e8520ab7..f87c7f03 100644 --- a/libraries/channels/onebot/src/main/resources/alexandrite/i18n/alexandrite-channel-onebot/en.properties +++ b/libraries/channels/onebot/src/main/resources/alexandrite/i18n/alexandrite-channel-onebot/en.properties @@ -12,6 +12,7 @@ approval.opaque=Opaque: Alexandrite cannot see what this program runs. approval.arguments=Arguments: approval.answer=Reply to this message with /approve or /deny. approval.always_rule=Allowing it for good grants: {rule} +approval.always_rules=Allowing it for good grants: {rules} approval.outcome_allowed=Approval {id} is allowed by {name}. approval.outcome_allowed_always=Approval {id} is allowed for good by {name}, which also grants {rule}. approval.outcome_denied=Approval {id} is denied by {name}. diff --git a/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt b/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt index 1cfbfa24..6229e71b 100644 --- a/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt +++ b/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt @@ -19,6 +19,7 @@ import org.unitedsoftware.alexandrite.sdk.chat.ChatUser import org.unitedsoftware.alexandrite.sdk.chat.TurnInfo import org.unitedsoftware.alexandrite.sdk.chat.UserAddress import org.unitedsoftware.alexandrite.sdk.di.key +import org.unitedsoftware.alexandrite.sdk.tool.RuleSubject import org.unitedsoftware.alexandrite.sdk.tool.ToolRisk import org.unitedsoftware.alexandrite.testkit.PluginHarness import org.unitedsoftware.alexandrite.testkit.PluginHarness.Running @@ -228,6 +229,62 @@ class OneBotApprovalIntegrationTest { } } + @Test + fun `an approval that grants several rules shows all of them`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + val request = request("many1").toBuilder().alwaysRules( + listOf( + RuleSubject("command.exec", listOf("a"), "host a.example"), + RuleSubject("command.exec", listOf("b"), "host b.example"), + ), + ).build() + val asking = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request) } + val carried = waiting { peer.awaitSent(1) }.last() + assertTrue( + carried.params.contains("host a.example"), + "the first rule was not shown: ${carried.params}", + ) + assertTrue( + carried.params.contains("host b.example"), + "the second rule was not shown: ${carried.params}", + ) + peer.report(command(replyTo = carried.id, text = "/approve always")) + assertIs(asking.await()) + val sent = waiting { peer.awaitQuiet() } + assertTrue( + sent.last().params.contains("host a.example") && + sent.last().params.contains("host b.example"), + "the outcome named only some of the rules: ${sent.last().params}", + ) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `an approval command is answered in any case`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + val asking = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("case1")) } + val carried = waiting { peer.awaitSent(1) }.last().id + peer.report(command(replyTo = carried, text = "/Approve")) + assertIs(asking.await()) + } + } + } finally { + peer.stop() + } + } + /** Runs [block] with the plugin built over the peer on [port], serving [allowedChats]. */ private suspend fun harness( peer: ApprovalPeer, diff --git a/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfigTest.kt b/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfigTest.kt index 18c23384..8ead8773 100644 --- a/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfigTest.kt +++ b/libraries/channels/onebot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/onebot/config/OneBotConfigTest.kt @@ -33,13 +33,22 @@ class OneBotConfigTest { assertEquals(emptySet(), instance(admins = emptySet()).admins) } + @Test + fun `an approval timeout is between one and 1440 minutes`() { + assertFailsWith { instance(approvalMinutes = 0) } + assertFailsWith { instance(approvalMinutes = 1_441) } + assertEquals(1_440, instance(approvalMinutes = 1_440).approvalMinutes) + } + private fun instance( admins: Set = emptySet(), allowedChats: List = emptyList(), + approvalMinutes: Int = 10, ): OneBotInstanceConfig = OneBotInstanceConfig( transport = "ws", endpoint = "ws://127.0.0.1:3001", admins = admins, allowedChats = allowedChats, + approvalMinutes = approvalMinutes, ) } diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotApprovals.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotApprovals.kt index fd9a3111..b67b2cee 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotApprovals.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotApprovals.kt @@ -115,7 +115,7 @@ internal class QqBotApprovals( text(TextKeys.APPROVAL_ALLOW, request.turn.language), text(TextKeys.APPROVAL_DENY, request.turn.language), request.alwaysRules.firstOrNull()?.let { - text(TextKeys.APPROVAL_ALWAYS, request.turn.language, "rule" to it.display) + text(TextKeys.APPROVAL_ALWAYS_SHORT, request.turn.language) }, ) if (delivery is Delivery.Delivered) { @@ -175,6 +175,9 @@ internal class QqBotApprovals( ), ) appendLine(text(TextKeys.APPROVAL_REASON, request.turn.language, "reason" to request.reason)) + request.alwaysRules.forEach { rule -> + appendLine(text(TextKeys.APPROVAL_ALWAYS, request.turn.language, "rule" to rule.display)) + } appendLine(text(TextKeys.APPROVAL_ARGUMENTS, request.turn.language)) append(request.arguments.take(MAX_ARGUMENTS)) } diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotChannel.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotChannel.kt index 5e06b4e6..b35e3389 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotChannel.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/channel/QqBotChannel.kt @@ -299,7 +299,7 @@ internal class QqBotChannel( /** How long tool lines of a non-streaming chat wait before they are told again; the first is told at once. */ const val NOTICE_INTERVAL_MS: Long = 8_000L - /** What this channel can do in a group: text and markdown, no streaming, no files and no live events. */ + /** What this channel can do in a group: text and markdown, no streaming, but files and tool notices. */ val CAPABILITIES: ChannelCapabilities = ChannelCapabilities.builder() .markups(setOf(Markup.PLAIN, Markup.MARKDOWN)) .streaming(false) diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/config/QqBotConfig.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/config/QqBotConfig.kt index 0f781695..5a95648a 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/config/QqBotConfig.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/config/QqBotConfig.kt @@ -34,8 +34,9 @@ public class QqBotInstanceConfig( /** Whether a single chat is answered with the platform's streaming message, false to send one message instead. */ public val streaming: Boolean = true, /** - * The accounts that may operate this instance, by the open id a single chat names them by, which is the chat an - * approval of theirs is shown in. A group's administrators are its platform `member_role`, not this list. + * The accounts that may operate this instance, by the open id the chat they write in names them by: the + * `user_openid` of a single chat, the `member_openid` of a group. A group's administrators are also its platform + * `member_role`; this list is matched against the open id of the chat, not against a global account id. */ public val admins: Set = emptySet(), /** Whether the bot publishes its slash commands as the single-chat menu and command panels when it opens. */ diff --git a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/i18n/TextKeys.kt b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/i18n/TextKeys.kt index 7c7662c9..1225aebe 100644 --- a/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/i18n/TextKeys.kt +++ b/libraries/channels/qqbot/src/main/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/i18n/TextKeys.kt @@ -9,6 +9,7 @@ internal object TextKeys { const val APPROVAL_ALLOW: String = "approval.allow" const val APPROVAL_DENY: String = "approval.deny" const val APPROVAL_ALWAYS: String = "approval.always" + const val APPROVAL_ALWAYS_SHORT: String = "approval.always_short" const val APPROVAL_ALLOWED: String = "approval.allowed" const val APPROVAL_DENIED: String = "approval.denied" const val APPROVAL_EXPIRED: String = "approval.expired" diff --git a/libraries/channels/qqbot/src/main/resources/alexandrite/i18n/alexandrite-channel-qqbot/en.properties b/libraries/channels/qqbot/src/main/resources/alexandrite/i18n/alexandrite-channel-qqbot/en.properties index aac31c8a..eb1b031f 100644 --- a/libraries/channels/qqbot/src/main/resources/alexandrite/i18n/alexandrite-channel-qqbot/en.properties +++ b/libraries/channels/qqbot/src/main/resources/alexandrite/i18n/alexandrite-channel-qqbot/en.properties @@ -5,6 +5,7 @@ approval.arguments=Arguments: approval.allow=Allow approval.deny=Deny approval.always=Always allow: {rule} +approval.always_short=Always approval.allowed=Allowed (#{id}) approval.denied=Denied (#{id}) approval.expired=The approval #{id} expired diff --git a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt index b7dab05d..7be64369 100644 --- a/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt +++ b/libraries/channels/qqbot/src/test/kotlin/org/unitedsoftware/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt @@ -30,6 +30,7 @@ import org.unitedsoftware.alexandrite.sdk.chat.ChatAddress import org.unitedsoftware.alexandrite.sdk.chat.RunId import org.unitedsoftware.alexandrite.sdk.chat.ToolCallId import org.unitedsoftware.alexandrite.sdk.di.key +import org.unitedsoftware.alexandrite.sdk.tool.RuleSubject import org.unitedsoftware.alexandrite.sdk.tool.ToolActivity import org.unitedsoftware.alexandrite.sdk.tool.ToolRisk import org.unitedsoftware.alexandrite.sdk.turn.Submission @@ -317,7 +318,7 @@ class QqBotChannelIntegrationTest { } @Test - fun `a streamed answer with a file sends the file and closes the stream`() = runBlocking { + fun `a streamed answer with a file sends the file and closes the stream`() = runBlocking { val api = FakeApiServer() api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") api.on(STREAM_DIRECT, """{"id":"stream1","timestamp":"2026-07-21T10:00:00+08:00"}""") @@ -383,6 +384,11 @@ class QqBotChannelIntegrationTest { ToolRisk.EXEC, "{}", "it runs a command", + ).alwaysRules( + listOf( + RuleSubject("command.exec", listOf("a"), "host a.example"), + RuleSubject("command.exec", listOf("b"), "host b.example"), + ), ).build() val waiting = async(start = CoroutineStart.UNDISPATCHED) { channel.askApproval(request) } assertTrue(sendReached.await(5, TimeUnit.SECONDS), "the approval message was not sent") @@ -392,6 +398,9 @@ class QqBotChannelIntegrationTest { assertTrue("\"keyboard\"" in shown.body, shown.body) assertTrue("\"data\":\"/approve req1\"" in shown.body, shown.body) assertTrue("\"data\":\"/deny req1\"" in shown.body, shown.body) + assertTrue("\"data\":\"/approve req1 always\"" in shown.body, shown.body) + assertTrue("host a.example" in shown.body, shown.body) + assertTrue("host b.example" in shown.body, shown.body) assertTrue("\"enter\":true" in shown.body, shown.body) platform.push(APPROVAL_PRESS) @@ -434,6 +443,11 @@ class QqBotChannelIntegrationTest { ToolRisk.EXEC, "{}", "it runs a command", + ).alwaysRules( + listOf( + RuleSubject("command.exec", listOf("a"), "host a.example"), + RuleSubject("command.exec", listOf("b"), "host b.example"), + ), ).build() val waiting = async(start = CoroutineStart.UNDISPATCHED) { channel.askApproval(request) } assertNotNull(api.awaitRequest(SEND_DIRECT)) @@ -483,7 +497,7 @@ class QqBotChannelIntegrationTest { } @Test - fun `a detached background run keeps writing to the reply's stream until it closes`() = runBlocking { + fun `a detached background run keeps writing to the reply's stream until it closes`() = runBlocking { val api = FakeApiServer() api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") api.on(STREAM_DIRECT, """{"id":"stream1","timestamp":"2026-07-21T10:00:00+08:00"}""") From f2ec58154e2b6fd2a91f78f47c19e7fe33cfaaf1 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 26 Oct 2026 15:44:17 +0800 Subject: [PATCH 37/37] app: Add the new approval texts to the Chinese packs The OneBot and QQ approval texts gained an always-rules line and a short always label; the app's Chinese packs carry them so every language pack keeps the keys of en.properties. --- .../app-i18n/alexandrite-channel-onebot/zh-CN.properties | 3 ++- .../app-i18n/alexandrite-channel-qqbot/zh-CN.properties | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-onebot/zh-CN.properties b/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-onebot/zh-CN.properties index 5e6ae092..edde7147 100644 --- a/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-onebot/zh-CN.properties +++ b/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-onebot/zh-CN.properties @@ -82,4 +82,5 @@ activity.call.failed=失败 activity.call.diff=+{added} -{removed} activity.delegate.present=正在委派给 {agent} activity.delegate.past=已委派给 {agent} -activity.call.by_agent={agent} · {line} \ No newline at end of file +activity.call.by_agent={agent} · {line} +approval.always_rules=永久允许将授予:{rules} diff --git a/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-qqbot/zh-CN.properties b/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-qqbot/zh-CN.properties index 551d83b4..80d02de0 100644 --- a/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-qqbot/zh-CN.properties +++ b/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-qqbot/zh-CN.properties @@ -13,3 +13,4 @@ approval.nothing_waiting=没有等待中的这条审批。 approval.unreachable=没能把这条审批发给任何管理员。 refused.admin=只有本实例的管理员可以使用 /{command}。 refused.admin_here=只有本实例的管理员可以在本聊天使用 /{command}。 +approval.always_short=始终