From 6d10e1086e6545c610493832d63b38e1f90eef4a Mon Sep 17 00:00:00 2001 From: Yos-X Date: Sat, 10 Oct 2026 19:31:26 +0800 Subject: [PATCH 01/36] onebot: Add the protocol domain and the action registry The OneBot v11 standard, in Kotlin types and nothing else: the ids and statuses, the twenty message segment types with the unknown ones kept whole, a message in each of the three shapes the API takes, the CQ code format, the events of the four kinds plus an unknown one that keeps its JSON, the 38 public actions with the hidden one and the derived async and rate limited calls, and the layered result model. No transport and no library is involved, so the domain is testable on its own. The module is discovered by AlexandriteLayout as channels.onebot, which gives it the built-in KSP index, the config root channels.onebot and the plugin id alexandrite-channel-onebot. --- libraries/channels/onebot/build.gradle.kts | 15 + .../channel/onebot/OneBotPlugin.kt | 7 + .../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 | 430 +++++++++++++++ .../onebot/protocol/event/OneBotEvent.kt | 490 ++++++++++++++++++ .../onebot/protocol/event/OneBotEventCodec.kt | 266 ++++++++++ .../channel/onebot/protocol/message/CqCode.kt | 337 ++++++++++++ .../onebot/protocol/message/OneBotMessage.kt | 49 ++ .../onebot/protocol/message/OneBotSegment.kt | 242 +++++++++ .../protocol/message/OneBotSegmentCodec.kt | 269 ++++++++++ .../onebot/protocol/result/OneBotResult.kt | 137 +++++ .../onebot/protocol/api/OneBotRegistryTest.kt | 125 +++++ .../protocol/event/OneBotEventCodecTest.kt | 391 ++++++++++++++ .../onebot/protocol/message/CqCodeTest.kt | 258 +++++++++ 18 files changed, 3576 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/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/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 diff --git a/libraries/channels/onebot/build.gradle.kts b/libraries/channels/onebot/build.gradle.kts new file mode 100644 index 00000000..b059679b --- /dev/null +++ b/libraries/channels/onebot/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("alexandrite.kotlin-library") + id("alexandrite.kotlin-serialization") +} + +dependencies { + implementation(project(":libraries:plugin-sdk")) + implementation(project(":libraries:internal")) + + // The client and the server of the Websocket transports, of which the JDK offers a client only. + implementation(libs.java.websocket) + + testImplementation(project(":libraries:testkit")) + testRuntimeOnly(libs.logback.classic) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotPlugin.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotPlugin.kt new file mode 100644 index 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/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..17cf9028 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRequests.kt @@ -0,0 +1,430 @@ +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", userId.value) + put("message", OneBotSegmentCodec.encodeMessageArray(message)) + if (autoEscape) 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", groupId.value) + put("message", OneBotSegmentCodec.encodeMessageArray(message)) + if (autoEscape) 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", it.value) } + groupId?.let { put("group_id", it.value) } + put("message", OneBotSegmentCodec.encodeMessageArray(message)) + if (autoEscape) 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", messageId.value) } +} + +/** `get_msg`: reads one message. */ +public class GetMessage(public val messageId: MessageId) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { put("message_id", 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", 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", groupId.value) + put("user_id", 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", groupId.value) + put("user_id", 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", 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", 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", groupId.value) + put("user_id", 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", 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", groupId.value) + put("user_id", 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", 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", 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", groupId.value) + put("user_id", 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", 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", 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", groupId.value) + put("user_id", 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", 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", 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..cfbcd583 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodec.kt @@ -0,0 +1,266 @@ +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]. */ + public fun decode(element: JsonElement): OneBotEvent { + val raw = element as? JsonObject ?: return OneBotEvent.Unknown(0L, SelfId("0"), "", JsonObject(emptyMap())) + 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, messageType.orEmpty(), 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"), + 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..4293a695 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCode.kt @@ -0,0 +1,337 @@ +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 = ']' + private const val SEPARATOR = ',' + private const val ASSIGNMENT = '=' + + /** + * The segments of [text], with the text between the codes as [OneBotSegment.Text]. + * + * The entities of the format stand for the characters that would otherwise end a code, so reading turns them + * back into those characters everywhere, inside a code and in the text between the codes alike. + */ + public fun decode(text: String): List { + if (PREFIX !in text) return if (text.isEmpty()) emptyList() else listOf(OneBotSegment.Text(unescape(text))) + val segments = mutableListOf() + val plain = StringBuilder() + var at = 0 + while (at < text.length) { + val start = text.indexOf(PREFIX, at) + if (start < 0) { + plain.append(text, at, text.length) + break + } + plain.append(text, at, start) + val end = text.indexOf(SUFFIX, start + PREFIX.length) + if (end < 0) { + plain.append(text, start, text.length) + break + } + val segment = parse(text.substring(start + PREFIX.length, end)) + if (segment == null) { + plain.append(text, start, end + 1) + } else { + if (plain.isNotEmpty()) { + segments += OneBotSegment.Text(unescape(plain.toString())) + plain.setLength(0) + } + segments += segment + } + at = end + 1 + } + if (plain.isNotEmpty()) segments += OneBotSegment.Text(unescape(plain.toString())) + return segments + } + + /** The string that spells [segments] as CQ codes. */ + public fun encode(segments: List): String = buildString { + for (segment in segments) append(encode(segment)) + } + + /** The string that spells [segment] as a CQ code, or as plain text for a [OneBotSegment.Text]. */ + public fun encode(segment: OneBotSegment): String = when (segment) { + is OneBotSegment.Text -> escape(segment.text) + + is OneBotSegment.Unknown -> encodeUnknown(segment) + + is OneBotSegment.Rps -> "[CQ:rps]" + + is OneBotSegment.Dice -> "[CQ:dice]" + + is OneBotSegment.Shake -> "[CQ:shake]" + + is OneBotSegment.At -> parameters("at", "qq" to segment.userId) + + is OneBotSegment.Face -> parameters("face", "id" to segment.id) + + is OneBotSegment.Image -> parameters( + "image", + "file" to segment.file, + "type" to segment.subtype, + "url" to segment.url, + "cache" to segment.cache?.let(::flag), + "proxy" to segment.proxy?.let(::flag), + "timeout" to segment.timeout?.toString(), + ) + + is OneBotSegment.Record -> parameters( + "record", + "file" to segment.file, + "magic" to segment.magic?.let(::flag), + "url" to segment.url, + "cache" to segment.cache?.let(::flag), + "proxy" to segment.proxy?.let(::flag), + "timeout" to segment.timeout?.toString(), + ) + + is OneBotSegment.Video -> parameters( + "video", + "file" to segment.file, + "url" to segment.url, + "cache" to segment.cache?.let(::flag), + "proxy" to segment.proxy?.let(::flag), + "timeout" to segment.timeout?.toString(), + ) + + is OneBotSegment.Poke -> parameters( + "poke", + "type" to segment.pokeType, + "id" to segment.id, + "name" to segment.name, + ) + + is OneBotSegment.Anonymous -> parameters("anonymous", "ignore" to segment.ignore?.let(::flag)) + + is OneBotSegment.Share -> parameters( + "share", + "url" to segment.url, + "title" to segment.title, + "content" to segment.content, + "image" to segment.image, + ) + + is OneBotSegment.Contact -> + parameters("contact", "type" to segment.contactType, "id" to segment.id) + + is OneBotSegment.Location -> parameters( + "location", + "lat" to segment.lat, + "lon" to segment.lon, + "title" to segment.title, + "content" to segment.content, + ) + + is OneBotSegment.Music -> parameters( + "music", + "type" to segment.musicType, + "id" to segment.id, + "url" to segment.url, + "audio" to segment.audio, + "title" to segment.title, + "content" to segment.content, + "image" to segment.image, + ) + + is OneBotSegment.Reply -> parameters("reply", "id" to segment.id.value) + + is OneBotSegment.Forward -> parameters("forward", "id" to segment.id) + + is OneBotSegment.Node -> parameters( + "node", + "id" to segment.id?.value, + "user_id" to segment.userId?.value, + "nickname" to segment.nickname, + "content" to segment.content?.let { encode(it.segments) }, + ) + + is OneBotSegment.Xml -> parameters("xml", "data" to segment.data) + + is OneBotSegment.Json -> parameters("json", "data" to segment.data) + } + + /** The value of [text] with the characters that end a code escaped. */ + public fun escape(text: String): String = buildString(text.length) { + for (char in text) { + when (char) { + '&' -> append("&") + '[' -> append("[") + ']' -> append("]") + ',' -> 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 = 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) } + 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(escape(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()] = unescape(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..add499cf --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegment.kt @@ -0,0 +1,242 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.message + +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId + +/** + * One segment of an OneBot message. + * + * A segment names what it is in `type` and carries its own fields in `data`. A type this version does not know is + * kept as [Unknown] with its `data` untouched, so a message never loses content while it crosses the plugin. + * + * [OneBotSegmentCodec] encodes and decodes the shapes the standard describes, so a `type` field that is also a + * parameter of a segment, as in an image, never collides with the discriminator. + */ +public sealed interface OneBotSegment { + /** The `type` of the segment. */ + public val type: String + + /** Plain text. */ + public data class Text(public val text: String) : OneBotSegment { + override val type: String get() = OneBotSegment.TEXT + } + + /** A QQ emoji. */ + public data class Face(public val id: String) : OneBotSegment { + override val type: String get() = OneBotSegment.FACE + } + + /** An image. */ + public data class Image( + public val file: String? = null, + /** `flash` for a self-destructing image, null for a plain one. */ + public val subtype: String? = null, + public val url: String? = null, + public val cache: Boolean? = null, + public val proxy: Boolean? = null, + public val timeout: Int? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.IMAGE + } + + /** A voice message. */ + public data class Record( + public val file: String? = null, + /** Whether the record is sent with a changed voice. */ + public val magic: Boolean? = null, + public val url: String? = null, + public val cache: Boolean? = null, + public val proxy: Boolean? = null, + public val timeout: Int? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.RECORD + } + + /** A short video. */ + public data class Video( + public val file: String? = null, + public val url: String? = null, + public val cache: Boolean? = null, + public val proxy: Boolean? = null, + public val timeout: Int? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.VIDEO + } + + /** A mention of one member, or of everyone when [userId] is [At.ALL]. */ + public data class At(public val userId: String) : OneBotSegment { + override val type: String get() = OneBotSegment.AT + + /** Whether the mention names everyone in the group. */ + public val isAll: Boolean get() = userId == ALL + + public companion object { + /** The `qq` value that mentions everyone. */ + public const val ALL: String = "all" + } + } + + /** The rock-paper-scissors magic emoji. */ + public data object Rps : OneBotSegment { + override val type: String get() = OneBotSegment.RPS + } + + /** The dice magic emoji. */ + public data object Dice : OneBotSegment { + override val type: String get() = OneBotSegment.DICE + } + + /** The window shake, the simplest kind of a poke. */ + public data object Shake : OneBotSegment { + override val type: String get() = OneBotSegment.SHAKE + } + + /** A poke. */ + public data class Poke( + /** The poke kind. */ + public val pokeType: String? = null, + public val id: String? = null, + /** The poke's name, which an implementation reports but does not take. */ + public val name: String? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.POKE + } + + /** A message sent without a name. */ + public data class Anonymous( + /** Whether the message is sent anyway when the platform refuses anonymity. */ + public val ignore: Boolean? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.ANONYMOUS + } + + /** A shared link. */ + public data class Share( + public val url: String, + public val title: String, + public val content: String? = null, + public val image: String? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.SHARE + } + + /** A recommended friend or group. */ + public data class Contact( + /** `qq` for a friend, `group` for a group. */ + public val contactType: String, + public val id: String, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.CONTACT + } + + /** A location. */ + public data class Location( + public val lat: String, + public val lon: String, + public val title: String? = null, + public val content: String? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.LOCATION + } + + /** A shared piece of music, either from a provider named by [musicType] or described by [url] and [audio]. */ + public data class Music( + /** `qq`, `163`, `xm` for a provider, or `custom` for a share the sender described itself. */ + public val musicType: String, + public val id: String? = null, + public val url: String? = null, + public val audio: String? = null, + public val title: String? = null, + public val content: String? = null, + public val image: String? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.MUSIC + } + + /** A reply to an earlier message. */ + public data class Reply(public val id: MessageId) : OneBotSegment { + override val type: String get() = OneBotSegment.REPLY + } + + /** A forwarded message, whose content only `get_forward_msg` returns. */ + public data class Forward(public val id: String) : OneBotSegment { + override val type: String get() = OneBotSegment.FORWARD + } + + /** A node of a forwarded message, either an existing message named by [id] or one the sender described. */ + public data class Node( + public val id: MessageId? = null, + public val userId: UserId? = null, + public val nickname: String? = null, + /** A message in any of the three shapes the API accepts, null when the node names a message by [id]. */ + public val content: OneBotMessage? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.NODE + } + + /** An XML message. */ + public data class Xml(public val data: String) : OneBotSegment { + override val type: String get() = OneBotSegment.XML + } + + /** A JSON message. */ + public data class Json(public val data: String) : OneBotSegment { + override val type: String get() = OneBotSegment.JSON + } + + /** A segment of a type this version does not know, with the value of its `data` object untouched. */ + public data class Unknown( + override val type: String, + /** The `data` object as the implementation sent it, empty when the segment carried none. */ + public val data: JsonObject = JsonObject(emptyMap()), + ) : OneBotSegment + + /** The names of the segment types OneBot v11 defines. */ + public companion object { + public const val TEXT: String = "text" + public const val FACE: String = "face" + public const val IMAGE: String = "image" + public const val RECORD: String = "record" + public const val VIDEO: String = "video" + public const val AT: String = "at" + public const val RPS: String = "rps" + public const val DICE: String = "dice" + public const val SHAKE: String = "shake" + public const val POKE: String = "poke" + public const val ANONYMOUS: String = "anonymous" + public const val SHARE: String = "share" + public const val CONTACT: String = "contact" + public const val LOCATION: String = "location" + public const val MUSIC: String = "music" + public const val REPLY: String = "reply" + public const val FORWARD: String = "forward" + public const val NODE: String = "node" + public const val XML: String = "xml" + public const val JSON: String = "json" + + /** The names of every segment type, in the order the standard lists them. */ + public val KNOWN_TYPES: List = listOf( + TEXT, + FACE, + IMAGE, + RECORD, + VIDEO, + AT, + RPS, + DICE, + SHAKE, + POKE, + ANONYMOUS, + SHARE, + CONTACT, + LOCATION, + MUSIC, + REPLY, + FORWARD, + NODE, + XML, + JSON, + ) + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegmentCodec.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegmentCodec.kt new file mode 100644 index 00000000..22e7a4ec --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegmentCodec.kt @@ -0,0 +1,269 @@ +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.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) + put("type", segment.subtype) + put("url", segment.url) + put("cache", segment.cache?.let(::flag)) + put("proxy", segment.proxy?.let(::flag)) + put("timeout", segment.timeout) + } + + is OneBotSegment.Record -> putJsonObject("data") { + put("file", segment.file) + put("magic", segment.magic?.let(::flag)) + put("url", segment.url) + put("cache", segment.cache?.let(::flag)) + put("proxy", segment.proxy?.let(::flag)) + put("timeout", segment.timeout) + } + + is OneBotSegment.Video -> putJsonObject("data") { + put("file", segment.file) + put("url", segment.url) + put("cache", segment.cache?.let(::flag)) + put("proxy", segment.proxy?.let(::flag)) + put("timeout", segment.timeout) + } + + is OneBotSegment.At -> putJsonObject("data") { put("qq", user(segment.userId)) } + + is OneBotSegment.Rps, is OneBotSegment.Dice, is OneBotSegment.Shake -> putJsonObject("data") {} + + is OneBotSegment.Poke -> putJsonObject("data") { + put("type", segment.pokeType) + put("id", segment.id) + put("name", segment.name) + } + + is OneBotSegment.Anonymous -> putJsonObject("data") { put("ignore", segment.ignore?.let(::flag)) } + + is OneBotSegment.Share -> putJsonObject("data") { + put("url", segment.url) + put("title", segment.title) + put("content", segment.content) + put("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) + put("title", segment.title) + put("content", segment.content) + } + + is OneBotSegment.Music -> putJsonObject("data") { + put("type", segment.musicType) + put("id", segment.id) + put("url", segment.url) + put("audio", segment.audio) + put("title", segment.title) + put("content", segment.content) + put("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" + + /** [id] as a JSON number when it is one, as a string when it is too large for a [Long]. */ + private fun number(id: String): JsonElement = id.toLongOrNull()?.let(::JsonPrimitive) ?: JsonPrimitive(id) + + /** [qq] as a JSON number, or the string `all`. */ + private fun user(qq: String): JsonElement = qq.toLongOrNull()?.let(::JsonPrimitive) ?: JsonPrimitive(qq) + + private fun segment(type: String, data: JsonObject): OneBotSegment = when (type) { + OneBotSegment.TEXT -> OneBotSegment.Text(data.string("text").orEmpty()) + + OneBotSegment.FACE -> OneBotSegment.Face(data.string("id").orEmpty()) + + OneBotSegment.IMAGE -> OneBotSegment.Image( + file = data.string("file"), + subtype = data.string("type"), + url = data.string("url"), + cache = data.flag("cache"), + proxy = data.flag("proxy"), + timeout = data.number("timeout"), + ) + + OneBotSegment.RECORD -> OneBotSegment.Record( + file = data.string("file"), + magic = data.flag("magic"), + url = data.string("url"), + cache = data.flag("cache"), + proxy = data.flag("proxy"), + timeout = data.number("timeout"), + ) + + OneBotSegment.VIDEO -> OneBotSegment.Video( + file = data.string("file"), + url = data.string("url"), + cache = data.flag("cache"), + proxy = data.flag("proxy"), + timeout = data.number("timeout"), + ) + + OneBotSegment.AT -> OneBotSegment.At(data.string("qq").orEmpty()) + + OneBotSegment.RPS -> OneBotSegment.Rps + + OneBotSegment.DICE -> OneBotSegment.Dice + + OneBotSegment.SHAKE -> OneBotSegment.Shake + + OneBotSegment.POKE -> OneBotSegment.Poke(data.string("type"), data.string("id"), data.string("name")) + + OneBotSegment.ANONYMOUS -> OneBotSegment.Anonymous(data.flag("ignore")) + + OneBotSegment.SHARE -> OneBotSegment.Share( + url = data.string("url").orEmpty(), + title = data.string("title").orEmpty(), + content = data.string("content"), + image = data.string("image"), + ) + + OneBotSegment.CONTACT -> OneBotSegment.Contact(data.string("type").orEmpty(), data.string("id").orEmpty()) + + OneBotSegment.LOCATION -> OneBotSegment.Location( + lat = data.string("lat").orEmpty(), + lon = data.string("lon").orEmpty(), + title = data.string("title"), + content = data.string("content"), + ) + + OneBotSegment.MUSIC -> OneBotSegment.Music( + musicType = data.string("type").orEmpty(), + id = data.string("id"), + url = data.string("url"), + audio = data.string("audio"), + title = data.string("title"), + content = data.string("content"), + image = data.string("image"), + ) + + OneBotSegment.REPLY -> OneBotSegment.Reply(MessageId(data.numberText("id") ?: "0")) + + OneBotSegment.FORWARD -> OneBotSegment.Forward(data.string("id").orEmpty()) + + OneBotSegment.NODE -> OneBotSegment.Node( + id = data.numberText("id")?.let(::MessageId), + userId = data.numberText("user_id")?.let(::UserId), + nickname = data.string("nickname"), + content = data["content"]?.let(::decodeMessage), + ) + + OneBotSegment.XML -> OneBotSegment.Xml(data.string("data").orEmpty()) + + OneBotSegment.JSON -> OneBotSegment.Json(data.string("data").orEmpty()) + + else -> OneBotSegment.Unknown(type, data) + } + + private fun JsonObject.string(key: String): String? = + (this[key] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull + + private fun JsonObject.number(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull + + private fun JsonObject.numberText(key: String): String? = + (this[key] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull?.takeIf { it.isNotEmpty() } + + private fun JsonObject.flag(key: String): Boolean? = (this[key] as? JsonPrimitive)?.let { value -> + when (value.contentOrNull) { + "1", "yes", "true" -> true + "0", "no", "false" -> false + else -> value.booleanOrNull + } + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/result/OneBotResult.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/result/OneBotResult.kt new file mode 100644 index 00000000..0f945366 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/result/OneBotResult.kt @@ -0,0 +1,137 @@ +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) : + OneBotResult { + override val status: OneBotStatus get() = OneBotStatus.FAILED + + override val retcode: Int get() = OneBotRetcode.BAD_REQUEST + + override val raw: JsonObject get() = JsonObject(emptyMap()) + } +} + +/** 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/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..7817f319 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodecTest.kt @@ -0,0 +1,391 @@ +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)) + assertEquals("channel", event.postType) + 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..efc0413b --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCodeTest.kt @@ -0,0 +1,258 @@ +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")) + 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) + assertEquals(text, 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 `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) + } +} From 94b662936bd51d2449c75c407aec801ad8240fb4 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Sat, 10 Oct 2026 20:08:09 +0800 Subject: [PATCH 02/36] onebot: Carry the token both ways and bind reports to their account Four things a review of the standard found missing, each with the test that would have said so: - The HTTP transport sent its token only in the `Authorization` header, while the standard also allows `?access_token=`, and an implementation that reads only the query refused every call. It sends both now. - An instance that serves one account accepted a reverse connection and an HTTP report that named no account at all, because only a value that was both present and different was refused. The account is what binds a connection to an instance, so a missing one is refused like a wrong one. The body of an HTTP report is read before that, so a report that is no event is still told apart from one of another account. - The reverse listener read no `X-Client-Role`, so an `API` client, which sends no events, and an `Event` client, which answers no call, were taken as if they carried both. The roles that carry both are served, the two that do not are closed with their reason, and an implementation that names no role keeps working, since older ones report none. - A port of 0 was documented as the operating system's choice and refused by the configuration, so the two now agree. Verified: :libraries:channels:onebot:test passes, 74 tests. --- gradle/libs.versions.toml | 2 + .../channel/onebot/auth/OneBotAuth.kt | 114 ++++++++ .../channel/onebot/config/OneBotConfig.kt | 168 ++++++++++++ .../channel/onebot/transport/Connection.kt | 115 ++++++++ .../transport/OneBotForwardWebSocket.kt | 170 ++++++++++++ .../channel/onebot/transport/OneBotHttpApi.kt | 148 +++++++++++ .../transport/OneBotHttpPostReceiver.kt | 248 ++++++++++++++++++ .../transport/OneBotReverseWebSocket.kt | 195 ++++++++++++++ .../onebot/transport/OneBotSettings.kt | 50 ++++ .../channel/onebot/transport/OneBotWire.kt | 33 +++ .../channel/onebot/auth/OneBotAuthTest.kt | 74 ++++++ .../onebot/channel/OneBotHttpTransportTest.kt | 181 +++++++++++++ .../transport/OneBotHttpPostReceiverTest.kt | 175 ++++++++++++ .../onebot/transport/WsTransportTest.kt | 207 +++++++++++++++ 14 files changed, 1880 insertions(+) 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/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 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/OneBotHttpTransportTest.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/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/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..9873b9d9 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/auth/OneBotAuth.kt @@ -0,0 +1,114 @@ +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. */ + public fun redact(text: String, values: Collection): String = values.asSequence() + .map { it.reveal() } + .filter { it.length >= MIN_REDACTED } + .sortedByDescending { it.length } + .fold(text) { masked, secret -> masked.replace(secret, MASKED) } + + private const val MASKED = "***" + + /** The shortest value [redact] replaces, so that a short token does not hide unrelated text. */ + private const val MIN_REDACTED = 4 + + 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..bfd189ea --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/config/OneBotConfig.kt @@ -0,0 +1,168 @@ +package org.foedusprogramme.alexandrite.channel.onebot.config + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.foedusprogramme.alexandrite.sdk.config.ConfigSection +import org.foedusprogramme.alexandrite.sdk.config.Secret +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped + +/** How an instance talks to its OneBot implementation. */ +public enum class OneBotTransport { + /** The implementation serves the API on the endpoint, and the client calls it. */ + HTTP, + + /** The implementation serves the API on the endpoint and reports events to the receiver. */ + HTTP_POST, + + /** The implementation serves the API and pushes events on the endpoint. */ + WS, + + /** The implementation connects to the receiver for the API and the events. */ + WS_REVERSE, + ; + + /** Whether the client listens for the implementation instead of calling it. */ + public val isReceiver: Boolean get() = this == HTTP_POST || this == WS_REVERSE + + public companion object { + /** The transport [name] spells, null when it names none. */ + public fun of(name: String): OneBotTransport? = when (name.lowercase()) { + "http" -> HTTP + "http_post" -> HTTP_POST + "ws", "websocket" -> WS + "ws_reverse", "ws-reverse" -> WS_REVERSE + else -> null + } + } +} + +/** + * Where the settings of the plugin sit below its config root, apart from the root its channel instances use. + * + * The paths of the two sections of this plugin have to differ, because a channel plugin's instances read the root of + * their own config under `instances` and a section at the root would collide with them. + */ +internal const val ONEBOT_SETTINGS_PATH: String = "settings" + +/** + * The settings of the OneBot plugin itself, below its config root. + * + * What describes one connection lives in [OneBotInstanceConfig] instead, because a connection is an instance of the + * channel and each instance has its own. + */ +@ConfigSection(path = ONEBOT_SETTINGS_PATH) +@Serializable +public class OneBotConfig( + /** How many events wait for the plugin to read them before the oldest are dropped. */ + @SerialName("eventQueueCapacity") public val eventQueueCapacity: Int = DEFAULT_EVENT_QUEUE, + /** The interval between two calls the implementation rate limits, in milliseconds. */ + @SerialName("rateLimitIntervalMillis") public val rateLimitIntervalMillis: Long = DEFAULT_RATE_LIMIT, + /** Whether the transport logs the raw JSON of an answer this version cannot read. */ + @SerialName("logUnreadableAnswers") public val logUnreadableAnswers: Boolean = false, +) { + init { + require(eventQueueCapacity > 0) { "An event queue holds at least one event, was $eventQueueCapacity." } + require(rateLimitIntervalMillis >= 0) { + "A rate limit interval is at least 0, was $rateLimitIntervalMillis." + } + } + + public companion object { + public const val DEFAULT_EVENT_QUEUE: Int = 1024 + + /** The interval the standard gives `api.rate_limit_interval`: half a second. */ + public const val DEFAULT_RATE_LIMIT: Long = 500L + } +} + +/** + * One connection to an OneBot implementation, below `instances` of the plugin's config root. + * + * A transport is either what the client calls or what it listens on. [HTTP] and [WS] use [endpoint]; [HTTP_POST] + * and [WS_REVERSE] use [listenHost] and [listenPort]. + */ +@ConfigSection +@ChannelInstanceScoped +@Serializable +public class OneBotInstanceConfig( + /** How this instance reaches its implementation. */ + public val transport: String, + /** The URL of [OneBotTransport.HTTP] and [OneBotTransport.WS], such as `http://127.0.0.1:3000`. */ + public val endpoint: String? = null, + /** The address [OneBotTransport.HTTP_POST] and [OneBotTransport.WS_REVERSE] listen on. */ + @SerialName("listenHost") public val listenHost: String = DEFAULT_HOST, + /** The port [OneBotTransport.HTTP_POST] and [OneBotTransport.WS_REVERSE] listen on. */ + @SerialName("listenPort") public val listenPort: Int = 0, + /** The path [OneBotTransport.HTTP_POST] accepts reports on. */ + public val path: String = "/", + /** The token the implementation checks, null when it checks none. */ + @SerialName("accessToken") public val accessToken: Secret? = null, + /** The key the implementation signs its reports with, null when it signs none. */ + public val secret: Secret? = null, + /** The account the implementation reports for, null when the plugin does not check it. */ + @SerialName("selfId") public val selfId: String? = null, + /** How long reaching the implementation may take. */ + @SerialName("connectTimeoutMillis") public val connectTimeoutMillis: Long = DEFAULT_CONNECT_TIMEOUT, + /** How long the first byte of an answer may take. */ + @SerialName("firstByteTimeoutMillis") public val firstByteTimeoutMillis: Long = DEFAULT_FIRST_BYTE_TIMEOUT, + /** How long a call may stay silent before it is cut off. */ + @SerialName("idleTimeoutMillis") public val idleTimeoutMillis: Long = DEFAULT_IDLE_TIMEOUT, + /** How long to wait before connecting again, in milliseconds. */ + @SerialName("reconnectIntervalMillis") public val reconnectIntervalMillis: Long = DEFAULT_RECONNECT, + /** The accounts that may operate this instance, which the channel marks as admins. */ + public val admins: Set = emptySet(), +) { + /** How this instance reaches its implementation. */ + public val kind: OneBotTransport = OneBotTransport.of(transport) ?: throw IllegalArgumentException( + "transport must be one of http, http_post, ws, ws_reverse, was '$transport'", + ) + + init { + require(connectTimeoutMillis > 0) { "A connect timeout is positive, was $connectTimeoutMillis." } + require(firstByteTimeoutMillis > 0) { "A first byte timeout is positive, was $firstByteTimeoutMillis." } + require(idleTimeoutMillis > 0) { "An idle timeout is positive, was $idleTimeoutMillis." } + require(reconnectIntervalMillis > 0) { "A reconnect interval is positive, was $reconnectIntervalMillis." } + if (kind.isReceiver) { + // Port 0 lets the operating system pick one, which a test and a host that only needs to bind use; the + // caller reads the port it took from the transport. + require(listenPort in 0..MAX_PORT) { + "transport '$transport' listens on a port between 0 and $MAX_PORT, was $listenPort" + } + require(path.startsWith("/")) { "A path starts with '/', was '$path'." } + require(endpoint == null) { + "transport '$transport' listens for its implementation and takes no endpoint, was '$endpoint'" + } + } else { + require(endpoint != null) { "transport '$transport' needs the endpoint of its implementation" } + val uri = try { + java.net.URI(endpoint) + } catch (e: java.net.URISyntaxException) { + throw IllegalArgumentException("endpoint '$endpoint' is no URL: ${e.message}", e) + } + val schemes = if (kind == OneBotTransport.WS) setOf("ws", "wss") else setOf("http", "https") + require(uri.scheme in schemes) { + "transport '$transport' takes an endpoint of ${schemes.joinToString(" or ")}, was '$endpoint'" + } + require(uri.host != null) { "endpoint '$endpoint' names no host" } + } + if (selfId != null) { + require(selfId.isNotEmpty() && selfId.all(Char::isDigit)) { + "selfId is the number the implementation reports, was '$selfId'" + } + } + } + + public companion object { + public const val DEFAULT_HOST: String = "127.0.0.1" + + public const val DEFAULT_CONNECT_TIMEOUT: Long = 10_000L + + public const val DEFAULT_FIRST_BYTE_TIMEOUT: Long = 30_000L + + public const val DEFAULT_IDLE_TIMEOUT: Long = 60_000L + + public const val DEFAULT_RECONNECT: Long = 3_000L + + public const val MAX_PORT: Int = 65_535 + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/Connection.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/Connection.kt new file mode 100644 index 00000000..4673b2d3 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/Connection.kt @@ -0,0 +1,115 @@ +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.receiveAsFlow +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent + +/** 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 counts what + * it dropped, so that a connection that cannot keep up is visible rather than silent. + */ +internal abstract class AbstractOneBotConnection(eventCapacity: Int) : OneBotConnection { + private val queue = Channel(eventCapacity, BufferOverflow.DROP_OLDEST) + private val mutableState = MutableStateFlow(OneBotLinkState.CLOSED) + + 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 var counter = 0L + + /** The `echo` of the next call. */ + fun next(): String = "alexandrite-${++counter}" +} 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..d5de4083 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotForwardWebSocket.kt @@ -0,0 +1,170 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEventCodec +import org.java_websocket.client.WebSocketClient +import org.java_websocket.handshake.ServerHandshake +import java.net.URI +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The connection of an implementation that serves its API and pushes its events on one WebSocket. + * + * The standard answers a call on the same socket it was sent on, under the `echo` of the request, and pushes events + * on the same socket without an echo, so this class tells the two apart and keeps the calls that are still waiting. + * The socket is kept up: a connection that drops is made again after the instance's reconnect interval, and a call + * that waits when the socket drops fails rather than waiting for a socket that may never answer it. + */ +internal class OneBotForwardWebSocket(settings: OneBotSettings) : AbstractOneBotConnection(settings.eventCapacity) { + private val settings = settings + private var scope: CoroutineScope? = null + private var loop: Job? = null + + @Volatile + private var client: AnsweringClient? = null + + /** Connects and returns once the implementation answered the handshake. */ + public suspend fun start() { + check(scope == null) { "The connection is already open." } + moveTo(OneBotLinkState.CONNECTING) + val opened = CompletableDeferred() + val run = CoroutineScope(SupervisorJob() + Dispatchers.IO) + scope = run + loop = run.launch { connectLoop(opened) } + val failure = withTimeout(settings.connectTimeoutMillis) { opened.await() } + if (failure != null) throw failure + } + + override suspend fun send(action: String, params: JsonObject): JsonObject { + val socket = client ?: throw IllegalStateException("The connection is not open.") + val echo = echoes.next() + val waiting = CompletableDeferred() + socket.awaiting[echo] = waiting + try { + socket.send(OneBotWire.encodeToString(requestOf(action, params, echo))) + } catch (e: Exception) { + socket.awaiting.remove(echo) + throw e + } + return try { + withTimeout(settings.firstByteTimeoutMillis) { waiting.await() } + } catch (e: Exception) { + socket.awaiting.remove(echo) + throw e + } + } + + override fun stop() { + 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..6a04027e --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpApi.kt @@ -0,0 +1,148 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +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, + ) + } + } +} + +/** 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 an object, empty when it carries another shape. */ +internal fun JsonObject.data(): JsonObject = this["data"] as? JsonObject ?: JsonObject(emptyMap()) + +/** 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..bd4088d0 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiver.kt @@ -0,0 +1,248 @@ +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"), + + /** 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 + + /** 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) } } + 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 + } + thread(name = "onebot-http-post-report", isDaemon = true) { + client.use { exchange(it) } + } + } + } + + private fun exchange(client: Socket) { + try { + client.soTimeout = settings.firstByteTimeoutMillis.toInt() + val input = BufferedInputStream(client.getInputStream()) + val request = read(input) ?: 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] ?: 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) + } + report(event) + acceptedReports.incrementAndGet() + when (val taken = answer(event)) { + is OneBotReportAnswer.QuickOperation -> respond(client, taken.operation) + is OneBotReportAnswer.Rejected -> respond(client, taken.rejection) + OneBotReportAnswer.Accepted -> 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) + 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: application/json\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) + + /** The request of [input], null when it is not an HTTP request this receiver reads. */ + private fun read(input: BufferedInputStream): Request? { + val head = readHead(input) ?: return null + val lines = head.split("\r\n") + val start = lines.firstOrNull()?.split(' ') ?: return null + if (start.size < 2) return null + 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 + if (length < 0) return null + val bytes = ByteArray(length) + var read = 0 + while (read < length) { + val count = input.read(bytes, read, length - read) + if (count < 0) return null + read += count + } + return 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): String? { + val text = StringBuilder() + var previous = -1 + while (text.length <= MAX_HEAD_BYTES) { + 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 { + const val MAX_REPORT_BYTES = 8 * 1024 * 1024 + + 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..612861b7 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotReverseWebSocket.kt @@ -0,0 +1,195 @@ +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 -> { + 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) { + 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) { + if (socket === connection) { + socket = null + if (state.value.isOpen) lost(IllegalStateException("The connection closed: $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 + } + + /** The port this connection listens on, which a configured port of 0 leaves to the operating system. */ + val port: Int get() = server.port + + override suspend fun send(action: String, params: JsonObject): JsonObject { + val connection = socket ?: throw IllegalStateException("No implementation is connected.") + val echo = echoes.next() + val waiting = CompletableDeferred() + awaiting[echo] = waiting + try { + connection.send(OneBotWire.encodeToString(requestOf(action, params, echo))) + } catch (e: Exception) { + awaiting.remove(echo) + throw e + } + return try { + withTimeout(settings.firstByteTimeoutMillis) { waiting.await() } + } catch (e: Exception) { + awaiting.remove(echo) + throw e + } + } + + override fun stop() { + runCatching { server.stop(CLOSE_TIMEOUT_MILLIS.toInt()) } + for (waiting in awaiting.values) { + waiting.completeExceptionally(IllegalStateException("The connection is closing.")) + } + awaiting.clear() + } + + private val echoes = Echo() + + internal companion object { + const val CLOSE_TIMEOUT_MILLIS = 1_000L + + /** The headers this listener reads, which the library answers only for the ones it holds. */ + val HEADERS = listOf(OneBotAuth.AUTHORIZATION, OneBotAuth.SELF_ID, OneBotAuth.CLIENT_ROLE) + + /** The `X-Client-Role` values one connection of this transport can carry the calls and the events both under. */ + val SERVED_ROLES = listOf("Universal", "Service") + + /** How long the listener gives the library to take over the socket it bound. */ + const val START_SETTLE_MILLIS = 150L + + /** The code a connection is closed under when this instance does not serve it. */ + const val REFUSAL_CODE = 1008 + } +} + +/** + * 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..0542c397 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotSettings.kt @@ -0,0 +1,50 @@ +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, + val rateLimitIntervalMillis: Long, +) { + /** 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, + rateLimitIntervalMillis = plugin.rateLimitIntervalMillis, + ) + } +} 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() 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/OneBotHttpTransportTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotHttpTransportTest.kt new file mode 100644 index 00000000..06d5c2cb --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotHttpTransportTest.kt @@ -0,0 +1,181 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +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.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.auth.Value +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotHttpApi +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}") + } + + /** 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/transport/OneBotHttpPostReceiverTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiverTest.kt new file mode 100644 index 00000000..a8b4cd94 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiverTest.kt @@ -0,0 +1,175 @@ +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.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", "").replace("\"self_id\": 10001000,", "") + val response = post(body, OneBotAuth.signature(secret, body), "/") + + assertEquals(403, response.statusCode()) + assertEquals(0, 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")) + } + + private fun OneBotHttpPostReceiver.reported(): Int = reportedCount + + 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, + rateLimitIntervalMillis = 0, + ) + + 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..b584217f --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/WsTransportTest.kt @@ -0,0 +1,207 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.coroutines.CompletableDeferred +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 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, + rateLimitIntervalMillis = 0, + ) + + /** 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 + } + + 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 a0a3a31a095919bb42086ecf47c66d540a5fbbd2 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Sat, 10 Oct 2026 20:08:09 +0800 Subject: [PATCH 03/36] onebot: Map OneBot messages to the channel contract In: a private chat is private:, a group is group:, a temporary session keeps the group as its chat and the sender as its thread, media becomes attachments, a reply becomes a quote, and a segment this version has no shape for becomes a display fact rather than being dropped. Out: plain text, Markdown images as image segments, and a reply leading with the reply segment. A failure of the implementation becomes a delivery failure the SDK knows. --- .../channel/onebot/mapping/OneBotMessages.kt | 282 ++++++++++++++++++ .../onebot/mapping/OneBotMessagesTest.kt | 220 ++++++++++++++ 2 files changed, 502 insertions(+) create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessagesTest.kt diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt new file mode 100644 index 00000000..9b82fc3e --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt @@ -0,0 +1,282 @@ +package org.foedusprogramme.alexandrite.channel.onebot.mapping + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailureKind +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.sdk.channel.ChannelCapabilities +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.DeliveryFailure +import org.foedusprogramme.alexandrite.sdk.channel.DisplayFact +import org.foedusprogramme.alexandrite.sdk.channel.IncomingMessage +import org.foedusprogramme.alexandrite.sdk.channel.Markup +import org.foedusprogramme.alexandrite.sdk.channel.MediaAttachment +import org.foedusprogramme.alexandrite.sdk.channel.MediaContent +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatInfo +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.chat.Quote +import org.foedusprogramme.alexandrite.sdk.chat.QuoteTrust +import org.foedusprogramme.alexandrite.sdk.chat.UserAddress +import org.foedusprogramme.alexandrite.sdk.transcript.MediaKind +import java.time.Instant + +/** + * The chats of an OneBot instance, printed the way the rest of Alexandrite reads them. + * + * A private chat is `private:` and a group is `group:`, the two ids the standard reports. A + * temporary session, which is a private message that came from inside a group, keeps the group as the chat and the + * sender as its thread, so that a reply goes back to the sender rather than to the group. + */ +public object OneBotChats { + /** The prefix of a private chat. */ + public const val PRIVATE: String = "private" + + /** The prefix of a group chat. */ + public const val GROUP: String = "group" + + /** The chat [event] happened in. */ + public fun addressOf(instance: ChannelInstanceId, event: OneBotEvent.Message): ChatAddress = when (event) { + is OneBotEvent.Message.Private -> if (event.subType == OneBotEvent.Message.GROUP_TEMPORARY) { + ChatAddress(instance, tempChat(event), thread = event.userId.value) + } else { + ChatAddress(instance, "$PRIVATE:${event.userId}") + } + + is OneBotEvent.Message.Group -> ChatAddress(instance, "$GROUP:${event.groupId}") + } + + /** The user of [id] in this instance. */ + public fun user(instance: ChannelInstanceId, id: UserId): UserAddress = UserAddress(instance, id.value) + + /** The chat `private:`. */ + public fun privateChat(instance: ChannelInstanceId, id: UserId): ChatAddress = + ChatAddress(instance, "$PRIVATE:${id.value}") + + /** The chat `group:`. */ + public fun groupChat(instance: ChannelInstanceId, id: GroupId): ChatAddress = + ChatAddress(instance, "$GROUP:${id.value}") + + /** The group a temporary session came from, as the implementation reported it, or a chat of the sender alone. */ + private fun tempChat(event: OneBotEvent.Message.Private): String { + val group = (event.raw["group_id"] as? JsonPrimitive)?.content + return if (group.isNullOrEmpty()) "$PRIVATE:temporary" else "$GROUP:$group" + } +} + +/** + * The messages of an OneBot instance, in both directions. + * + * A segment the SDK has no shape for is not dropped: it becomes a [DisplayFact] on the way in, so the model still + * reads that it was there, and every message keeps the raw event, so a consumer that needs the original sees it. + */ +public object OneBotMessages { + /** What an OneBot channel can do, as this module reports it. */ + public fun capabilities(): ChannelCapabilities = ChannelCapabilities.builder() + .markups(setOf(Markup.PLAIN, Markup.MARKDOWN)) + .streaming(false) + .proactive(true) + .maxPartsPerReply(null) + .build() + + /** The message [event] reported, as Alexandrite reads it. */ + public fun incoming( + instance: ChannelInstanceId, + event: OneBotEvent.Message, + admins: Set = emptySet(), + ): IncomingMessage { + val chat = OneBotChats.addressOf(instance, event) + val segments = event.message.segments + return IncomingMessage.builder( + ref = ChannelMessageRef(chat, event.messageId.value), + sender = sender(instance, event, admins), + chatInfo = chatInfo(event), + receivedAt = Instant.ofEpochSecond(event.time), + forwarded = null, + ) + .text(segments.filterIsInstance().joinToString("") { it.text }) + .media(media(segments)) + .quote(quote(event, chat)) + .facts(facts(event, segments)) + .build() + } + + /** The message that sends [message] in [chat]. */ + public fun outgoing(chat: ChatAddress, message: OutboundMessage): OneBotMessage { + val segments = when (message.markup.id) { + Markup.MARKDOWN.id -> markdown(message.text) + else -> listOf(OneBotSegment.Text(message.text)) + } + val reply = message.replyTo?.let { OneBotSegment.Reply(MessageId(it.id)) } + 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 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 -> { + val data = result.data["data"] as? JsonObject ?: result.data + val id = (data["message_id"] as? JsonPrimitive)?.content ?: result.echo?.toString() ?: "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 (result.failure.kind) { + OneBotFailureKind.TIMEOUT, OneBotFailureKind.CONNECTION -> DeliveryFailure.TRANSIENT + OneBotFailureKind.AUTHENTICATION -> DeliveryFailure.FORBIDDEN + else -> DeliveryFailure.UNKNOWN + }, + result.failure.message, + ) + + is OneBotResult.Malformed -> Delivery.NotDelivered(DeliveryFailure.UNKNOWN, result.detail) + } + + /** The text of [message] as segments, where a Markdown image becomes an image segment. */ + private fun markdown(text: String): List { + val segments = mutableListOf() + val plain = StringBuilder() + var at = 0 + for (match in IMAGE.findAll(text)) { + plain.append(text, at, match.range.first) + if (plain.isNotEmpty()) { + segments += OneBotSegment.Text(plain.toString()) + plain.setLength(0) + } + segments += OneBotSegment.Image(file = match.groupValues[2]) + at = match.range.last + 1 + } + plain.append(text, at, text.length) + if (plain.isNotEmpty()) segments += OneBotSegment.Text(plain.toString()) + return segments + } + + /** The failure an implementation's [retcode] means, as far as this version can tell. */ + private fun failureKind(retcode: Int): DeliveryFailure = when (retcode) { + RATE_LIMITED -> DeliveryFailure.RATE_LIMITED + FORBIDDEN, NOT_ALLOWED -> DeliveryFailure.FORBIDDEN + TOO_LONG -> DeliveryFailure.TOO_LONG + NOT_FOUND -> DeliveryFailure.CHAT_GONE + else -> DeliveryFailure.UNKNOWN + } + + private fun sender(instance: ChannelInstanceId, event: OneBotEvent.Message, admins: Set): ChatUser { + val reported = event.sender + val id = event.userId.value + val card = reported.card?.takeIf { it.isNotEmpty() } + return ChatUser( + address = OneBotChats.user(instance, event.userId), + displayName = card ?: reported.nickname ?: id, + username = null, + isBot = reported.userId == event.selfId.value, + isAdmin = id in admins || (event is OneBotEvent.Message.Group && reported.isAdmin), + ) + } + + private fun chatInfo(event: OneBotEvent.Message): ChatInfo = when (event) { + is OneBotEvent.Message.Private -> ChatInfo(ChatKind.DIRECT, null, null) + is OneBotEvent.Message.Group -> ChatInfo(ChatKind.GROUP, event.sender.card, null) + } + + private fun media(segments: List): List = segments.mapNotNull { segment -> + val attachment = when (segment) { + is OneBotSegment.Image -> MediaKind.IMAGE to (segment.url ?: segment.file) + is OneBotSegment.Record -> MediaKind.AUDIO to (segment.url ?: segment.file) + is OneBotSegment.Video -> MediaKind.VIDEO to (segment.url ?: segment.file) + else -> return@mapNotNull null + } + val (kind, reference) = attachment + if (reference.isNullOrEmpty()) return@mapNotNull null + MediaAttachment.builder(kind, mediaType(kind), unread(reference)) + .name(reference.substringAfterLast('/').takeIf { it.isNotEmpty() }) + .build() + } + + /** The media type this version reports while it cannot read the file itself. */ + private fun mediaType(kind: MediaKind): String = when (kind) { + MediaKind.IMAGE -> "image/*" + MediaKind.AUDIO -> "audio/*" + else -> "video/*" + } + + /** + * The bytes of the file an implementation named, which this version does not read yet. + * + * Reading it needs the plugin's transport, so it stays the task of whoever asks for the bytes; failing here keeps + * that visible rather than answering with an empty file. + */ + private fun unread(reference: String): MediaContent = MediaContent { + throw UnsupportedOperationException("Reading '$reference' is not implemented yet.") + } + + private fun quote(event: OneBotEvent.Message, chat: ChatAddress): Quote? { + val reply = event.message.segments.filterIsInstance().firstOrNull() ?: return null + return Quote.builder("") + .target(ChannelMessageRef(chat, reply.id.value)) + .trust(QuoteTrust.UNTRUSTED) + .build() + } + + private fun facts(event: OneBotEvent.Message, segments: List): List = buildList { + if (event is OneBotEvent.Message.Group) { + event.anonymous?.let { add(DisplayFact("anonymous", it.name ?: it.flag ?: "unnamed")) } + } + for (segment in segments) { + when (segment) { + is OneBotSegment.Unknown -> add(DisplayFact("unsupported segment", segment.type)) + is OneBotSegment.Json -> add(DisplayFact("json message", segment.data)) + is OneBotSegment.Xml -> add(DisplayFact("xml message", segment.data)) + is OneBotSegment.Forward -> add(DisplayFact("forwarded message", segment.id)) + is OneBotSegment.At -> add(DisplayFact("mentioned", segment.userId)) + is OneBotSegment.Image -> add(DisplayFact("image", segment.file ?: segment.url ?: "unnamed")) + is OneBotSegment.Record -> add(DisplayFact("voice", segment.file ?: segment.url ?: "unnamed")) + is OneBotSegment.Video -> add(DisplayFact("video", segment.file ?: segment.url ?: "unnamed")) + is OneBotSegment.Face -> add(DisplayFact("emoji", segment.id)) + is OneBotSegment.Share -> add(DisplayFact("link", segment.url)) + else -> Unit + } + } + } + + private val IMAGE = Regex("!\\[([^\\]]*)]\\(([^)\\s]+)\\)") + + private const val RATE_LIMITED = 1004 + + private const val FORBIDDEN = 1003 + + private const val NOT_ALLOWED = 1002 + + private const val TOO_LONG = 1005 + + private const val NOT_FOUND = 100 +} diff --git a/libraries/channels/onebot/src/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..c4838128 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessagesTest.kt @@ -0,0 +1,220 @@ +package org.foedusprogramme.alexandrite.channel.onebot.mapping + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +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 `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 `markdown leaves its images as image segments`() { + val message = OutboundMessage.builder( + "see ![a](http://x/1.jpg) done", + org.foedusprogramme.alexandrite.sdk.channel.MessageKind.REPLY, + ) + .markup(Markup.MARKDOWN) + .build() + val segments = OneBotMessages.outgoing(chat(), message).segments + assertEquals(3, segments.size) + assertEquals("see ", assertIs(segments[0]).text) + assertEquals("http://x/1.jpg", assertIs(segments[1]).file) + assertEquals(" done", assertIs(segments[2]).text) + } + + @Test + fun `a reply to another message leads with the reply segment`() { + val message = OutboundMessage.builder("hi", org.foedusprogramme.alexandrite.sdk.channel.MessageKind.REPLY) + .replyTo(org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef(chat(), "42")) + .build() + val segments = OneBotMessages.outgoing(chat(), message).segments + assertEquals( + OneBotSegment.Reply(org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId("42")), + segments[0], + ) + assertEquals(OneBotSegment.Text("hi"), segments[1]) + } + + @Test + fun `a chat address is read back into the user or the group it names`() { + assertEquals("12345678", OneBotMessages.privateUser(chat())?.value) + assertNull(OneBotMessages.group(chat())) + assertEquals("100100", OneBotMessages.group(ChatAddress(instance, "group:100100"))?.value) + assertNull(OneBotMessages.privateUser(ChatAddress(instance, "group:100100"))) + assertTrue(OneBotMessages.isGroup(ChatAddress(instance, "group:100100"))) + } + + @Test + fun `a delivered message names what the implementation answered`() { + val answer = kotlinx.serialization.json.buildJsonObject { put("message_id", 7) } + val delivery = OneBotMessages.delivery(OneBotResult.Ok(answer), chat()) + assertEquals("7", assertIs(delivery).messages.single().id) + } + + @Test + fun `a refused send becomes the failure the retcode means`() { + val limited = OneBotMessages.delivery(OneBotResult.Failed(1004), chat()) + assertEquals(DeliveryFailure.RATE_LIMITED, assertIs(limited).kind) + val forbidden = OneBotMessages.delivery(OneBotResult.Failed(1003), chat()) + assertEquals(DeliveryFailure.FORBIDDEN, assertIs(forbidden).kind) + } + + @Test + fun `an unreachable peer is a transient failure`() { + val delivery = OneBotMessages.delivery( + OneBotResult.Unreachable( + org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailure( + org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailureKind.TIMEOUT, + "no answer", + ), + ), + chat(), + ) + val notDelivered = assertIs(delivery) + assertEquals(DeliveryFailure.TRANSIENT, notDelivered.kind) + assertTrue(notDelivered.retryable) + } + + @Test + fun `the capabilities of the channel name what it can do`() { + val capabilities = OneBotMessages.capabilities() + assertTrue(capabilities.markups.contains(Markup.PLAIN)) + assertTrue(capabilities.markups.contains(Markup.MARKDOWN)) + assertTrue(capabilities.proactive) + assertEquals(false, capabilities.streaming) + assertNull(capabilities.maxPartsPerReply) + } + + private fun chat(): ChatAddress = ChatAddress(instance, "private:12345678") + + /** The JSON of a report, as the codec reads it. */ + private fun parse(json: String): kotlinx.serialization.json.JsonElement = Json.parseToJsonElement(json) + + private fun privateEvent(message: String = "hello there"): OneBotEvent.Message = + assertIs(OneBotEventCodec.decode(parse(privateJson(message = message)))) + + private fun privateJson(subType: String = "friend", groupId: Int? = null, message: String = "hello there"): String = + buildString { + append("""{"time":1515204254,"self_id":10001000,"post_type":"message","message_type":"private",""") + append(""""sub_type":"$subType","message_id":12,"user_id":12345678,""") + groupId?.let { append(""""group_id":$it,""") } + append(""""message":"$message","raw_message":"$message",""") + append(""""sender":{"user_id":12345678,"nickname":"someone","sex":"male","age":18}}""") + } + + private fun groupEvent(role: String = "member", anonymous: Boolean = false): OneBotEvent.Message = assertIs( + OneBotEventCodec.decode( + parse( + buildString { + append( + """{"time":1515204254,"self_id":10001000,"post_type":"message","message_type":"group",""", + ) + append(""""sub_type":"normal","message_id":12,"group_id":100100,"user_id":12345678,""") + append(""""message":"hi","raw_message":"hi",""") + if (anonymous) append(""""anonymous":{"id":1,"name":"no one","flag":"abc"},""") + append( + """"sender":{"user_id":12345678,"nickname":"someone","card":"the card","role":"$role"}}""", + ) + }, + ), + ), + ) +} From b0551709a558d175ac9de6c5511fb3b1b07528d7 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 19:12:49 +0800 Subject: [PATCH 04/36] onebot: Contribute the channel, its instance API and the app wiring The channel speaks the contract: it maps what it was told into incoming messages and what the agent answers back into segments, and it offers the whole action surface of the standard to other plugins as an instance API. The app loads it and the example config names it. --- app/build.gradle.kts | 1 + app/src/dist/config/alexandrite.example.json | 16 ++ .../channel/onebot/api/OneBotApi.kt | 167 +++++++++++++ .../channel/onebot/api/OneBotApiImpl.kt | 230 ++++++++++++++++++ .../channel/onebot/channel/OneBotChannel.kt | 87 +++++++ .../channel/onebot/channel/OneBotInbox.kt | 67 +++++ .../channel/onebot/channel/OneBotLink.kt | 102 ++++++++ .../onebot/protocol/result/OneBotResult.kt | 10 +- .../onebot/OneBotChannelIntegrationTest.kt | 183 ++++++++++++++ 9 files changed, 859 insertions(+), 4 deletions(-) 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/channel/OneBotChannel.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/OneBotLink.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotChannelIntegrationTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 76147ba5..d66fe7fb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation(project(":libraries:commands")) implementation(project(":libraries:channels:telegram")) implementation(project(":libraries:channel-common")) + implementation(project(":libraries:channels:onebot")) implementation(project(":libraries:providers:openai-compatible")) implementation(project(":libraries:providers:deepseek")) implementation(project(":libraries:providers:openrouter")) diff --git a/app/src/dist/config/alexandrite.example.json b/app/src/dist/config/alexandrite.example.json index dd3258a4..01864c08 100644 --- a/app/src/dist/config/alexandrite.example.json +++ b/app/src/dist/config/alexandrite.example.json @@ -5,5 +5,21 @@ "shutdownGraceSeconds": 15, "startTimeoutSeconds": 30, "plugins": [] + }, + "channels": { + "onebot": { + "enabled": false, + "settings": { + "eventQueueCapacity": 1024, + "rateLimitIntervalMillis": 500 + }, + "instances": { + "main": { + "transport": "ws_reverse", + "listenHost": "127.0.0.1", + "listenPort": 8080 + } + } + } } } diff --git a/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..c509b00d --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApi.kt @@ -0,0 +1,167 @@ +package org.foedusprogramme.alexandrite.channel.onebot.api + +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..a9f930ce --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApiImpl.kt @@ -0,0 +1,230 @@ +package org.foedusprogramme.alexandrite.channel.onebot.api + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.builtins.ListSerializer +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/channel/OneBotChannel.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotChannel.kt new file mode 100644 index 00000000..80b91317 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotChannel.kt @@ -0,0 +1,87 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.onebot.api.OneBotApi +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendGroupMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendPrivateMessage +import org.foedusprogramme.alexandrite.sdk.channel.Channel +import org.foedusprogramme.alexandrite.sdk.channel.ChannelCapabilities +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.DeliveryFailure +import org.foedusprogramme.alexandrite.sdk.channel.Markup +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.channel.ReplyEnd +import org.foedusprogramme.alexandrite.sdk.channel.ReplyRequest +import org.foedusprogramme.alexandrite.sdk.channel.ReplySink +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.turn.TurnSubmitter + +/** + * The OneBot front end of one channel instance. + * + * A message the implementation reported becomes an [IncomingMessage] and goes to the agent through the + * [TurnSubmitter], and what the agent answers goes back as segments over the actions of the standard. Which + * transport carries either is [OneBotLink]'s business, not this class's. + */ +@ChannelInstanceScoped +@Contribute(Channel::class, name = "onebot") +internal class OneBotChannel( + private val instance: ChannelInstance, + private val config: OneBotInstanceConfig, + private val link: OneBotLink, + /** The agent, null while the runtime has none. */ + private val submitter: TurnSubmitter?, +) : Channel { + /** The action that sends a message to [chat], null when this instance serves no such chat. */ + internal fun action(chat: ChatAddress): Pair? = when { + OneBotMessages.isGroup(chat) && OneBotMessages.group(chat) != null -> "send_group_msg" to chat + OneBotMessages.privateUser(chat) != null -> "send_private_msg" to chat + else -> null + } + + /** The parameters that send [message] to [chat], null when this instance serves no such chat. */ + internal fun params(chat: ChatAddress, message: OutboundMessage): kotlinx.serialization.json.JsonObject? { + val user = OneBotMessages.privateUser(chat) + val group = OneBotMessages.group(chat) + val segments = OneBotMessages.outgoing(chat, message) + return when { + group != null -> SendGroupMessage(group, segments).toJson() + user != null -> SendPrivateMessage(user, segments).toJson() + else -> null + } + } + + override suspend fun capabilities(chat: ChatAddress): ChannelCapabilities = OneBotMessages.capabilities() + + override suspend fun partsNeeded(chat: ChatAddress, text: String, markup: Markup): Int = + if (text.isEmpty()) 0 else 1 + + override suspend fun openReply(request: ReplyRequest): ReplySink = Sink(this, request) + + override suspend fun send(chat: ChatAddress, message: OutboundMessage): Delivery { + val target = + action(chat) ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + val params = params(chat, message) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + return OneBotMessages.delivery(link.call(target.first, params), chat) + } + + /** The reply of one turn, which delivers the final message of the agent. */ + private class Sink(private val channel: OneBotChannel, private val request: ReplyRequest) : ReplySink { + override suspend fun preview(segment: Int, text: String) = Unit + + override suspend fun complete(message: OutboundMessage): Delivery { + val target = channel.action(request.turn.chat) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no chat of this turn.") + val params = channel.params(request.turn.chat, message) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no chat of this turn.") + return OneBotMessages.delivery(channel.link.call(target.first, params), request.turn.chat) + } + + override suspend fun abandon(end: ReplyEnd) = Unit + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotInbox.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotInbox.kt new file mode 100644 index 00000000..74fd7b8b --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotInbox.kt @@ -0,0 +1,67 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.foedusprogramme.alexandrite.sdk.turn.Submission +import org.foedusprogramme.alexandrite.sdk.turn.TurnSubmitter +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * What the implementation reports, taken into the agent. + * + * A message becomes a submission, which the agent queues as a turn of its chat. Every other event, a notice, a request + * or a heartbeat, is read and dropped here: it is not a turn, and this version has no way to hand one to another + * plugin. The connection can carry it, so the way out is a decision about the shape of that interface rather than + * about this class, and until it is taken a plugin that needs a notice or a request goes through [OneBotApi] for the + * state it wants. An instance whose runtime has no agent accepts the events and hands them nowhere, which is reported + * once so that the silence is visible rather than puzzling. + */ +@ChannelInstanceScoped +internal class OneBotInbox( + private val instance: ChannelInstance, + private val config: OneBotInstanceConfig, + private val link: OneBotLink, + /** The agent, null while the runtime has none. */ + private val submitter: TurnSubmitter?, +) : Lifecycle { + private var reading: Job? = null + + override suspend fun onStart() { + if (submitter == null) { + logger.warn( + "{}: the runtime has no agent, so the messages of channel instance {} reach nobody", + PLUGIN, + instance.id, + ) + return + } + reading = instance.scope.launch { read() } + } + + override suspend fun onClose() { + reading?.cancel() + reading = null + } + + private suspend fun read() { + link.events.collect { event -> + if (event !is OneBotEvent.Message) return@collect + val message = OneBotMessages.incoming(instance.id, event, config.admins) + submitter?.submit(Submission.Message(message)) + } + } + + private companion object { + const val PLUGIN = "alexandrite-channel-onebot" + + val logger: Logger = LoggerFactory.getLogger(OneBotInbox::class.java) + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotLink.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotLink.kt new file mode 100644 index 00000000..e5528f3f --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotLink.kt @@ -0,0 +1,102 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +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.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle + +/** + * The connection of one channel instance, whichever transport its config chose. + * + * This is the one place where the plugin picks a transport and opens it, so that the channel above it calls an + * action without knowing whether an HTTP request, a Websocket or a listener carries it. The instance scope owns it, + * so the container starts it when the instance opens and closes it when the instance stops. + */ +@ChannelInstanceScoped +internal class OneBotLink(private val config: OneBotConfig, private val instanceConfig: OneBotInstanceConfig) : + Lifecycle { + private val settings = OneBotSettings.of(config, instanceConfig) + + private val http: OneBotHttpApi? = if (instanceConfig.kind == OneBotTransport.HTTP) { + OneBotHttpApi( + endpoint = settings.requireEndpoint().trimEnd('/'), + token = settings.token, + connectTimeoutMillis = settings.connectTimeoutMillis, + firstByteTimeoutMillis = settings.firstByteTimeoutMillis, + idleTimeoutMillis = settings.idleTimeoutMillis, + ) + } else { + null + } + + private val receiver: OneBotHttpPostReceiver? = if (instanceConfig.kind == OneBotTransport.HTTP_POST) { + OneBotHttpPostReceiver(settings) + } else { + null + } + + private val forward: OneBotForwardWebSocket? = if (instanceConfig.kind == OneBotTransport.WS) { + OneBotForwardWebSocket(settings) + } else { + null + } + + private val reverse: OneBotReverseWebSocket? = if (instanceConfig.kind == OneBotTransport.WS_REVERSE) { + OneBotReverseWebSocket(settings) + } else { + null + } + + /** The events of the implementation, from whichever transport reports them. */ + internal val events: Flow = when { + receiver != null -> receiver.events + forward != null -> forward.events + reverse != null -> reverse.events + else -> emptyFlow() + } + + /** Runs one call of [action], with the suffix the caller chose already applied. */ + internal suspend fun call(action: String, params: JsonObject): OneBotResult = when { + http != null -> http.call(action, params) + + forward != null -> OneBotResult.Ok(forward.send(action, params)) + + reverse != null -> OneBotResult.Ok(reverse.send(action, params)) + + else -> OneBotResult.Unreachable( + OneBotFailure( + OneBotFailureKind.HTTP_STATUS, + "A transport of '${instanceConfig.transport}' only takes reports; it has no endpoint to call.", + ), + ) + } + + /** 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/protocol/result/OneBotResult.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/result/OneBotResult.kt index 0f945366..92ce0059 100644 --- 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 @@ -76,13 +76,15 @@ public sealed interface OneBotResult { } /** An answer this version cannot read, with what the implementation sent. */ - public data class Malformed(public val detail: String, override val echo: JsonElement? = null) : - OneBotResult { + 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 - - override val raw: JsonObject get() = JsonObject(emptyMap()) } } 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..57df5205 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotChannelIntegrationTest.kt @@ -0,0 +1,183 @@ +package org.foedusprogramme.alexandrite.channel.onebot + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.sdk.channel.ChannelDirectory +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.key +import org.foedusprogramme.alexandrite.sdk.turn.Submission +import org.foedusprogramme.alexandrite.testkit.PluginHarness +import org.foedusprogramme.alexandrite.testkit.PluginHarness.Running +import org.foedusprogramme.alexandrite.testkit.RecordingTurnSubmitter +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The OneBot channel inside a real runtime. + * + * A harness builds the plugin set and the container, a peer built from the library serves the implementation, and the + * test drives what a user would do: the implementation reports a message, the agent is asked for a turn, and the + * answer the agent writes goes back out through the actions of the standard. + */ +class OneBotChannelIntegrationTest { + private val instance = ChannelInstanceId(ChannelType("onebot"), "main") + + private val directory = key() + + @Test + fun `a reported message reaches the agent and the answer goes back to the implementation`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection(), "the channel did not connect to the implementation") + peer.report(privateMessage()) + val submission = awaitSubmission(submitter) + val message = assertIs(submission) + assertEquals("private:12345678", message.message.chat.chat) + assertEquals("hello there", message.message.text) + + val channel = org.foedusprogramme.alexandrite.sdk.channel.ChannelDirectory::class.java + val delivery = sendTo(message.message.chat) + assertEquals("7", assertIs(delivery).messages.single().id) + val call = peer.awaitCall("send_private_msg") + assertTrue(call.contains("hello back"), "the implementation was not sent the reply: $call") + } + } + } finally { + peer.stop() + } + } + + private suspend fun org.foedusprogramme.alexandrite.testkit.PluginHarness.Running.awaitSubmission( + submitter: RecordingTurnSubmitter, + ): Submission { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + submitter.submissions.firstOrNull()?.let { return it } + kotlinx.coroutines.delay(20) + } + error("the agent was asked for no turn") + } + + private suspend fun Running.sendTo(chat: ChatAddress): Delivery { + val front = get(directory).channel(instance) ?: error("the instance has no channel") + val message = OutboundMessage.builder("hello back", MessageKind.REPLY).build() + return front.send(chat, message) + } + + private fun config(port: Int): String = """ + { + "settings": {"eventQueueCapacity": 64}, + "instances": { + "main": { + "transport": "ws", + "endpoint": "ws://127.0.0.1:$port", + "connectTimeoutMillis": 5000, + "firstByteTimeoutMillis": 5000, + "idleTimeoutMillis": 5000, + "reconnectIntervalMillis": 200 + } + } + } + """.trimIndent() + + private fun privateMessage(): String = """ + { + "time": 1515204254, + "self_id": 10001000, + "post_type": "message", + "message_type": "private", + "sub_type": "friend", + "message_id": 12, + "user_id": 12345678, + "message": "hello there", + "raw_message": "hello there", + "sender": {"user_id": 12345678, "nickname": "someone"} + } + """.trimIndent() + + /** An implementation that serves the API and reports what the test tells it to. */ + private class ImplementationPeer : WebSocketServer(InetSocketAddress("127.0.0.1", 0)) { + private val started = CountDownLatch(1) + private val connected = CountDownLatch(1) + private val calls = CopyOnWriteArrayList() + private val callWaiters = CopyOnWriteArrayList>() + + @Volatile + private var socket: WebSocket? = null + + override fun onStart() { + started.countDown() + } + + override fun onOpen(connection: WebSocket, handshake: ClientHandshake) { + socket = connection + connected.countDown() + } + + override fun onMessage(connection: WebSocket, message: String) { + calls += message + val echo = message.substringAfter("\"echo\":\"", "").substringBefore('"') + if (echo.isEmpty()) return + connection.send( + """{"status":"ok","retcode":0,"data":{"message_id":7},"echo":"$echo"}""", + ) + } + + override fun onClose(connection: WebSocket, code: Int, reason: String, remote: Boolean) { + if (socket === connection) socket = null + } + + override fun onError(connection: WebSocket?, ex: Exception) = Unit + + /** Starts listening and returns the port this peer took. */ + fun startAndPort(): Int { + start() + check(started.await(5, TimeUnit.SECONDS)) { "the peer did not start" } + return port + } + + /** Reports [json] as an event of the implementation. */ + fun report(json: String) { + val connection = socket ?: error("nothing is connected") + connection.send(json) + } + + /** Waits for the channel to connect and returns whether it did. */ + fun awaitConnection(): Boolean = connected.await(5, TimeUnit.SECONDS) + + /** Waits for a call of [action] and returns it, whether it arrived before or after this call. */ + fun awaitCall(action: String): String { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + calls.lastOrNull { it.contains("\"$action\"") }?.let { return it } + Thread.sleep(20) + } + error("no call of $action arrived, saw $calls") + } + } +} From 0349d59ef171046cbaa9f934c33109e0b922d9cf Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 19:12:49 +0800 Subject: [PATCH 05/36] onebot: Read a socket answer as an answer, and keep the socket honest An answer is told from an event by the echo it carries, and the shape of its data is kept rather than flattened. A call that is still waiting fails when the connection that would answer it goes, instead of waiting for a socket that may never answer it, and a temporary session is answered in the shape it was asked in. --- .../channel/onebot/api/OneBotApi.kt | 5 +- .../channel/onebot/api/OneBotApiImpl.kt | 7 +-- .../channel/onebot/channel/OneBotChannel.kt | 10 +++- .../channel/onebot/channel/OneBotLink.kt | 8 +-- .../channel/onebot/config/OneBotConfig.kt | 12 +---- .../channel/onebot/mapping/OneBotMessages.kt | 22 ++++++-- .../onebot/protocol/api/OneBotRequests.kt | 21 +++++--- .../onebot/protocol/event/OneBotEventCodec.kt | 2 +- .../channel/onebot/transport/Connection.kt | 31 ++++++++--- .../channel/onebot/transport/OneBotHttpApi.kt | 53 +++++++++++++++++-- .../transport/OneBotHttpPostReceiver.kt | 6 ++- .../transport/OneBotReverseWebSocket.kt | 14 +++++ .../onebot/transport/OneBotSettings.kt | 2 - .../onebot/channel/OneBotHttpTransportTest.kt | 28 ++++++++++ .../onebot/mapping/OneBotMessagesTest.kt | 33 ++++++++++++ .../protocol/event/OneBotEventCodecTest.kt | 5 +- .../transport/OneBotHttpPostReceiverTest.kt | 30 +++++++++-- .../onebot/transport/WsTransportTest.kt | 51 +++++++++++++++++- 18 files changed, 290 insertions(+), 50 deletions(-) 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 index c509b00d..4d595286 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -150,7 +151,7 @@ public interface OneBotApi { 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 + public suspend fun raw(action: String, params: JsonObject = JsonObject(emptyMap())): OneBotResult /** * Calls one action through this version's own request type, under [suffix]. @@ -163,5 +164,5 @@ public interface OneBotApi { action: String, request: OneBotRequest, suffix: OneBotCallSuffix = OneBotCallSuffix.NONE, - ): OneBotResult + ): 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 index a9f930ce..556cceb1 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -175,13 +176,13 @@ internal class OneBotApiImpl(private val link: OneBotLink) : OneBotApi { 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 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()) + ): OneBotResult = link.call(action + suffix.suffix, request.toJson()) /** Runs an action whose data is one object of the standard. */ private suspend fun action( @@ -220,7 +221,7 @@ internal class OneBotApiImpl(private val link: OneBotLink) : OneBotApi { link.call(name, request.toJson()).asUnit() /** Turns an answer into one that carries no data. */ - private fun OneBotResult.asUnit(): OneBotResult = when (this) { + 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 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 80b91317..587fd619 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 @@ -36,8 +36,14 @@ internal class OneBotChannel( /** The agent, null while the runtime has none. */ private val submitter: TurnSubmitter?, ) : Channel { - /** The action that sends a message to [chat], null when this instance serves no such chat. */ + /** + * The action that sends a message to [chat], null when this instance serves no such chat. + * + * A temporary session of a group is a private chat whose thread names the sender, so it is asked for first: a + * reply to it goes to the sender and not into the group, which is what its address means. + */ internal fun action(chat: ChatAddress): Pair? = when { + OneBotMessages.privateTemporary(chat) != null -> "send_private_msg" to chat OneBotMessages.isGroup(chat) && OneBotMessages.group(chat) != null -> "send_group_msg" to chat OneBotMessages.privateUser(chat) != null -> "send_private_msg" to chat else -> null @@ -45,10 +51,12 @@ internal class OneBotChannel( /** The parameters that send [message] to [chat], null when this instance serves no such chat. */ internal fun params(chat: ChatAddress, message: OutboundMessage): kotlinx.serialization.json.JsonObject? { + val temporary = OneBotMessages.privateTemporary(chat) val user = OneBotMessages.privateUser(chat) val group = OneBotMessages.group(chat) val segments = OneBotMessages.outgoing(chat, message) return when { + temporary != null -> SendPrivateMessage(temporary, segments).toJson() group != null -> SendGroupMessage(group, segments).toJson() user != null -> SendPrivateMessage(user, segments).toJson() else -> null 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 index e5528f3f..328daf64 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -15,6 +16,7 @@ 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 @@ -69,12 +71,12 @@ internal class OneBotLink(private val config: OneBotConfig, private val instance } /** Runs one call of [action], with the suffix the caller chose already applied. */ - internal suspend fun call(action: String, params: JsonObject): OneBotResult = when { + internal suspend fun call(action: String, params: JsonObject): OneBotResult = when { http != null -> http.call(action, params) - forward != null -> OneBotResult.Ok(forward.send(action, params)) + forward != null -> envelopeResult(forward.send(action, params)) - reverse != null -> OneBotResult.Ok(reverse.send(action, params)) + reverse != null -> envelopeResult(reverse.send(action, params)) else -> OneBotResult.Unreachable( OneBotFailure( 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 index bfd189ea..8877e2e4 100644 --- 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 @@ -53,25 +53,15 @@ internal const val ONEBOT_SETTINGS_PATH: String = "settings" @ConfigSection(path = ONEBOT_SETTINGS_PATH) @Serializable public class OneBotConfig( - /** How many events wait for the plugin to read them before the oldest are dropped. */ + /** How many events wait for the plugin to read them before the newest are dropped. */ @SerialName("eventQueueCapacity") public val eventQueueCapacity: Int = DEFAULT_EVENT_QUEUE, - /** The interval between two calls the implementation rate limits, in milliseconds. */ - @SerialName("rateLimitIntervalMillis") public val rateLimitIntervalMillis: Long = DEFAULT_RATE_LIMIT, - /** Whether the transport logs the raw JSON of an answer this version cannot read. */ - @SerialName("logUnreadableAnswers") public val logUnreadableAnswers: Boolean = false, ) { init { require(eventQueueCapacity > 0) { "An event queue holds at least one event, was $eventQueueCapacity." } - require(rateLimitIntervalMillis >= 0) { - "A rate limit interval is at least 0, was $rateLimitIntervalMillis." - } } public companion object { public const val DEFAULT_EVENT_QUEUE: Int = 1024 - - /** The interval the standard gives `api.rate_limit_interval`: half a second. */ - public const val DEFAULT_RATE_LIMIT: Long = 500L } } diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt index 9b82fc3e..6eaa6085 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt @@ -1,5 +1,6 @@ package org.foedusprogramme.alexandrite.channel.onebot.mapping +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId @@ -124,6 +125,16 @@ public object OneBotMessages { /** 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 + ":") } @@ -139,10 +150,15 @@ public object OneBotMessages { ?.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) { + public fun delivery(result: OneBotResult, chat: ChatAddress): Delivery = when (result) { is OneBotResult.Ok -> { - val data = result.data["data"] as? JsonObject ?: result.data - val id = (data["message_id"] as? JsonPrimitive)?.content ?: result.echo?.toString() ?: "sent" + // 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 + val id = + ((data as? JsonObject)?.get("message_id") as? JsonPrimitive)?.content + ?: result.echo?.toString() + ?: "sent" Delivery.Delivered(listOf(ChannelMessageRef(chat, id))) } 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 index 17cf9028..a05533d0 100644 --- 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 @@ -29,8 +29,11 @@ public class SendPrivateMessage( ) : OneBotRequest { override fun toJson(): JsonObject = buildJsonObject { put("user_id", userId.value) - put("message", OneBotSegmentCodec.encodeMessageArray(message)) - if (autoEscape) put("auto_escape", true) + 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) } } @@ -42,8 +45,11 @@ public class SendGroupMessage( ) : OneBotRequest { override fun toJson(): JsonObject = buildJsonObject { put("group_id", groupId.value) - put("message", OneBotSegmentCodec.encodeMessageArray(message)) - if (autoEscape) put("auto_escape", true) + 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) } } @@ -67,8 +73,11 @@ public class SendMessage( messageType?.let { put("message_type", it) } userId?.let { put("user_id", it.value) } groupId?.let { put("group_id", it.value) } - put("message", OneBotSegmentCodec.encodeMessageArray(message)) - if (autoEscape) put("auto_escape", true) + 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 { 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 index cfbcd583..b6d15708 100644 --- 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 @@ -64,7 +64,7 @@ public object OneBotEventCodec { raw = raw, ) - else -> OneBotEvent.Unknown(time, selfId, messageType.orEmpty(), raw) + else -> OneBotEvent.Unknown(time, selfId, OneBotEvent.Message.POST_TYPE, raw) } private fun decodeNotice(raw: JsonObject, time: Long, selfId: SelfId): OneBotEvent { 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 index 4673b2d3..9c6413ba 100644 --- 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 @@ -6,9 +6,11 @@ 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 { @@ -54,16 +56,28 @@ public interface OneBotConnection : AutoCloseable { * 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 counts what - * it dropped, so that a connection that cannot keep up is visible rather than silent. + * 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(eventCapacity: Int) : OneBotConnection { +internal abstract class AbstractOneBotConnection(private val eventCapacity: Int) : OneBotConnection { private val queue = Channel(eventCapacity, BufferOverflow.DROP_OLDEST) private val mutableState = MutableStateFlow(OneBotLinkState.CLOSED) + private val reported = AtomicLong() + private val read = AtomicLong() + + /** + * 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 what was lost is what it took, less what + * a reader has seen and what its room still holds. + */ + internal val droppedEvents: Long + get() = (reported.get() - read.get() - eventCapacity).coerceAtLeast(0L) override val state: StateFlow = mutableState.asStateFlow() - override val events: Flow = queue.receiveAsFlow() + override val events: Flow = queue.receiveAsFlow().onEach { read.incrementAndGet() } /** The last failure of the connection, null while it is healthy. */ @Volatile @@ -73,7 +87,10 @@ internal abstract class AbstractOneBotConnection(eventCapacity: Int) : OneBotCon 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 + protected fun report(event: OneBotEvent): Boolean { + reported.incrementAndGet() + return queue.trySend(event).isSuccess + } /** Moves the connection to [state]. */ protected fun moveTo(state: OneBotLinkState) { @@ -108,8 +125,8 @@ internal abstract class AbstractOneBotConnection(eventCapacity: Int) : OneBotCon /** The key a call is answered under, which an implementation returns as it is. */ internal class Echo { - private var counter = 0L + private val counter = java.util.concurrent.atomic.AtomicLong() /** The `echo` of the next call. */ - fun next(): String = "alexandrite-${++counter}" + fun next(): String = "alexandrite-${counter.incrementAndGet()}" } 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 index 6a04027e..0be54bf8 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -45,7 +46,7 @@ public class OneBotHttpApi( ) /** Sends one call, with [action] already carrying its suffix. */ - public suspend fun call(action: String, params: JsonObject): OneBotResult { + 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 @@ -92,7 +93,7 @@ public class OneBotHttpApi( OneBotFailure(kind, OneBotAuth.redact(message, masked), httpStatus), ) - private fun resultOf(answer: JsonObject, call: HttpCall): OneBotResult { + private fun resultOf(answer: JsonObject, call: HttpCall): OneBotResult { val status = answer.status() val retcode = answer.retcode() val echo = answer["echo"]?.takeUnless { it is JsonNull } @@ -125,6 +126,45 @@ public class OneBotHttpApi( } } +/** + * 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) @@ -132,8 +172,13 @@ internal fun JsonObject.status(): OneBotStatus? = /** 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 an object, empty when it carries another shape. */ -internal fun JsonObject.data(): JsonObject = this["data"] as? JsonObject ?: JsonObject(emptyMap()) +/** + * 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? = 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 index bd4088d0..e98e8f8a 100644 --- 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 @@ -143,7 +143,7 @@ internal class OneBotHttpPostReceiver( return respond(client, OneBotReportRejection.MALFORMED) } val selfId = settings.selfId - val reported = request.headers[OneBotAuth.SELF_ID] ?: reportedInBody(request.body) + 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. @@ -211,7 +211,9 @@ internal class OneBotHttpPostReceiver( 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 - if (length < 0) return null + // 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. A declared body over the limit is read as none, which its caller refuses. + if (length < 0 || length > MAX_REPORT_BYTES) return null val bytes = ByteArray(length) var read = 0 while (read < length) { 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 612861b7..093b27eb 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 @@ -102,6 +102,10 @@ internal class OneBotReverseWebSocket(private val settings: OneBotSettings) : 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) { @@ -130,6 +134,16 @@ internal class OneBotReverseWebSocket(private val settings: OneBotSettings) : 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 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 index 0542c397..a55579a7 100644 --- 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 @@ -24,7 +24,6 @@ internal class OneBotSettings( val idleTimeoutMillis: Long, val reconnectIntervalMillis: Long, val eventCapacity: Int, - val rateLimitIntervalMillis: Long, ) { /** 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." } @@ -44,7 +43,6 @@ internal class OneBotSettings( idleTimeoutMillis = instance.idleTimeoutMillis, reconnectIntervalMillis = instance.reconnectIntervalMillis, eventCapacity = plugin.eventQueueCapacity, - rateLimitIntervalMillis = plugin.rateLimitIntervalMillis, ) } } 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 index 06d5c2cb..a79bac53 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -8,6 +9,8 @@ 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 @@ -109,6 +112,31 @@ class OneBotHttpTransportTest { 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()) 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 index c4838128..713c4730 100644 --- 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 @@ -1,8 +1,13 @@ 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 @@ -95,6 +100,20 @@ class OneBotMessagesTest { 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( @@ -105,6 +124,20 @@ class OneBotMessagesTest { 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( 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 index 7817f319..e8f6e09d 100644 --- 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 @@ -307,7 +307,10 @@ class OneBotEventCodecTest { put("message", "hi") } val event = assertIs(OneBotEventCodec.decode(raw)) - assertEquals("channel", event.postType) + // `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) } 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 index a8b4cd94..7cb076e6 100644 --- 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 @@ -73,13 +73,25 @@ class OneBotHttpPostReceiverTest { @Test fun `a report that names no account is refused when the instance names one`() { - val body = privateMessage().replace(",\"self_id\": 10001000", "").replace("\"self_id\": 10001000,", "") - val response = post(body, OneBotAuth.signature(secret, body), "/") + 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. This case would pass for the wrong reason if the header were ignored: the + // body below names the account the instance serves, so it is taken whichever of the two is read. + val body = privateMessage() + 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() } @@ -123,6 +135,19 @@ class OneBotHttpPostReceiverTest { 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") + } + private fun post(body: String, signature: String?, path: String): HttpResponse = postTo(receiver.port, body, signature, path, selfId = "10001000") @@ -155,7 +180,6 @@ class OneBotHttpPostReceiverTest { idleTimeoutMillis = 5_000, reconnectIntervalMillis = 100, eventCapacity = 16, - rateLimitIntervalMillis = 0, ) private fun privateMessage(): String = """ 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 b584217f..4f297d8d 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 @@ -1,6 +1,7 @@ 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 @@ -95,6 +96,29 @@ class WsTransportTest { } } + @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") @@ -117,7 +141,6 @@ class WsTransportTest { idleTimeoutMillis = 5_000, reconnectIntervalMillis = 100, eventCapacity = 16, - rateLimitIntervalMillis = 0, ) /** An implementation that serves the API and pushes one event, as the standard's server does. */ @@ -195,6 +218,32 @@ class WsTransportTest { 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\"," + From 7f033cef4b5fb05ead2ef6cec56c029c4f4b2c5e Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 19:12:49 +0800 Subject: [PATCH 06/36] onebot: Bound the report listener and answer what it cannot take A report that is too large is answered with the status that says so rather than read out, the listener runs a bounded number of reports at once and refuses the rest, each peer is kept to itself, and the queue counts what it drops. --- .../channel/onebot/transport/Connection.kt | 19 ++- .../transport/OneBotHttpPostReceiver.kt | 115 +++++++++++++++--- .../transport/OneBotReverseWebSocket.kt | 14 ++- .../transport/OneBotHttpPostReceiverTest.kt | 44 ++++++- 4 files changed, 157 insertions(+), 35 deletions(-) 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 index 9c6413ba..1820ff69 100644 --- 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 @@ -61,23 +61,21 @@ public interface OneBotConnection : AutoCloseable { * keep up is visible rather than silent. */ internal abstract class AbstractOneBotConnection(private val eventCapacity: Int) : OneBotConnection { - private val queue = Channel(eventCapacity, BufferOverflow.DROP_OLDEST) + private val dropped = AtomicLong() + private val queue = Channel(eventCapacity, BufferOverflow.DROP_OLDEST) { dropped.incrementAndGet() } private val mutableState = MutableStateFlow(OneBotLinkState.CLOSED) - private val reported = AtomicLong() - private val read = AtomicLong() /** * 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 what was lost is what it took, less what - * a reader has seen and what its room still holds. + * 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() = (reported.get() - read.get() - eventCapacity).coerceAtLeast(0L) + internal val droppedEvents: Long get() = dropped.get() override val state: StateFlow = mutableState.asStateFlow() - override val events: Flow = queue.receiveAsFlow().onEach { read.incrementAndGet() } + override val events: Flow = queue.receiveAsFlow() /** The last failure of the connection, null while it is healthy. */ @Volatile @@ -87,10 +85,7 @@ internal abstract class AbstractOneBotConnection(private val eventCapacity: Int) private var closed = false /** Reports [event], dropping the oldest when nothing reads them fast enough. */ - protected fun report(event: OneBotEvent): Boolean { - reported.incrementAndGet() - return queue.trySend(event).isSuccess - } + protected fun report(event: OneBotEvent): Boolean = queue.trySend(event).isSuccess /** Moves the connection to [state]. */ protected fun moveTo(state: OneBotLinkState) { 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 index e98e8f8a..c36daff7 100644 --- 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 @@ -25,6 +25,9 @@ public enum class OneBotReportRejection(public val status: Int, public val reaso /** 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"), @@ -64,6 +67,27 @@ internal class OneBotHttpPostReceiver( 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() @@ -97,6 +121,9 @@ internal class OneBotHttpPostReceiver( 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 } @@ -109,17 +136,36 @@ internal class OneBotHttpPostReceiver( if (accepting.get()) lost(e) return } - thread(name = "onebot-http-post-report", isDaemon = true) { - client.use { exchange(it) } + 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()) - val request = read(input) ?: return respond(client, OneBotReportRejection.NOT_A_REPORT) + // 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) + 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 == @@ -200,35 +246,65 @@ internal class OneBotHttpPostReceiver( private class Request(val method: String, val path: String, val headers: Map, val body: String) - /** The request of [input], null when it is not an HTTP request this receiver reads. */ - private fun read(input: BufferedInputStream): Request? { - val head = readHead(input) ?: return null + /** 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) { + val piece = ByteArray(DRAIN_PIECE_BYTES) + var left = declaredBytes.toLong() + while (left > 0) { + 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 null - if (start.size < 2) return null + 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. A declared body over the limit is read as none, which its caller refuses. - if (length < 0 || length > MAX_REPORT_BYTES) return null + // 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 null + if (count < 0) return ReadOutcome.NotAReport read += count } - return Request(start[0].uppercase(), start[1], headers, String(bytes, StandardCharsets.UTF_8)) + 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): String? { + 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()) @@ -241,7 +317,16 @@ internal class OneBotHttpPostReceiver( } private companion object { - const val MAX_REPORT_BYTES = 8 * 1024 * 1024 + 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 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 093b27eb..28aa2180 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 @@ -77,6 +77,10 @@ internal class OneBotReverseWebSocket(private val settings: OneBotSettings) : 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) { @@ -98,10 +102,12 @@ internal class OneBotReverseWebSocket(private val settings: OneBotSettings) : } override fun onClose(connection: WebSocket, code: Int, reason: String, remote: Boolean) { - if (socket === connection) { - socket = null - if (state.value.isOpen) lost(IllegalStateException("The connection closed: $code $reason")) - } + // 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. 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 index 7cb076e6..d9abba4a 100644 --- 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 @@ -7,6 +7,8 @@ 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 @@ -82,10 +84,10 @@ class OneBotHttpPostReceiverTest { @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. This case would pass for the wrong reason if the header were ignored: the - // body below names the account the instance serves, so it is taken whichever of the two is read. - val body = privateMessage() + // 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()) @@ -133,6 +135,17 @@ class OneBotHttpPostReceiverTest { 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 @@ -148,6 +161,29 @@ class OneBotHttpPostReceiverTest { 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 { + repeat(20) { + Thread.sleep(100) + writer.write('x'.code) + writer.flush() + } + } + } + + val elapsed = (System.nanoTime() - started) / 1_000_000 + assertTrue(elapsed < 10_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") From 63769f67f78233142fbb9fed529e75d0972cc5fa Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 19:12:49 +0800 Subject: [PATCH 07/36] onebot: Write the values of the standard, and say a refusal as text Ids are written as the numbers the standard asks for, a group is named by its id rather than by one of its members, and a scalar is not taken for an event. A refusal is answered as text, a secret is redacted wherever an answer is logged, and a message the agent refused is reported with the reason it gave. --- .../channel/onebot/auth/OneBotAuth.kt | 12 ++-- .../channel/onebot/channel/OneBotInbox.kt | 14 +++- .../channel/onebot/channel/OneBotLink.kt | 19 ++++- .../channel/onebot/mapping/OneBotMessages.kt | 27 ++++++-- .../onebot/protocol/api/OneBotRequests.kt | 60 ++++++++-------- .../onebot/protocol/event/OneBotEventCodec.kt | 8 ++- .../channel/onebot/protocol/message/CqCode.kt | 41 ++++++++--- .../protocol/message/OneBotSegmentCodec.kt | 69 +++++++++++-------- .../transport/OneBotForwardWebSocket.kt | 4 ++ .../transport/OneBotHttpPostReceiver.kt | 32 ++++++--- .../transport/OneBotReverseWebSocket.kt | 11 +++ .../onebot/mapping/OneBotMessagesTest.kt | 12 ++++ .../onebot/protocol/message/CqCodeTest.kt | 21 +++++- .../transport/OneBotHttpPostReceiverTest.kt | 6 +- 14 files changed, 239 insertions(+), 97 deletions(-) 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 index 9873b9d9..e140772c 100644 --- 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 @@ -72,18 +72,20 @@ public object OneBotAuth { 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. */ + /** + * [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.length >= MIN_REDACTED } + .filter { it.isNotEmpty() } .sortedByDescending { it.length } .fold(text) { masked, secret -> masked.replace(secret, MASKED) } private const val MASKED = "***" - /** The shortest value [redact] replaces, so that a short token does not hide unrelated text. */ - private const val MIN_REDACTED = 4 - private fun hmacSha1(key: ByteArray, message: ByteArray): String { val mac = Mac.getInstance("HmacSHA1") mac.init(SecretKeySpec(key, "HmacSHA1")) 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 74fd7b8b..a8736c8f 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 @@ -9,6 +9,7 @@ import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.foedusprogramme.alexandrite.sdk.turn.Admission import org.foedusprogramme.alexandrite.sdk.turn.Submission import org.foedusprogramme.alexandrite.sdk.turn.TurnSubmitter import org.slf4j.Logger @@ -55,7 +56,18 @@ internal class OneBotInbox( link.events.collect { event -> if (event !is OneBotEvent.Message) return@collect val message = OneBotMessages.incoming(instance.id, event, config.admins) - submitter?.submit(Submission.Message(message)) + val admission = submitter?.submit(Submission.Message(message)) + // 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. + if (admission is Admission.Refused) { + logger.warn( + "{}: instance {} took a message of {} no further: {}", + PLUGIN, + instance.id, + message.chat, + admission.reason, + ) + } } } 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 index 328daf64..1fc3c974 100644 --- 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 @@ -74,9 +74,9 @@ internal class OneBotLink(private val config: OneBotConfig, private val instance internal suspend fun call(action: String, params: JsonObject): OneBotResult = when { http != null -> http.call(action, params) - forward != null -> envelopeResult(forward.send(action, params)) + forward != null -> overSocket { forward.send(action, params) } - reverse != null -> envelopeResult(reverse.send(action, params)) + reverse != null -> overSocket { reverse.send(action, params) } else -> OneBotResult.Unreachable( OneBotFailure( @@ -86,6 +86,21 @@ internal class OneBotLink(private val config: OneBotConfig, private val instance ) } + /** + * 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 diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt index 6eaa6085..3b49ef30 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt @@ -118,7 +118,13 @@ public object OneBotMessages { Markup.MARKDOWN.id -> markdown(message.text) else -> listOf(OneBotSegment.Text(message.text)) } - val reply = message.replyTo?.let { OneBotSegment.Reply(MessageId(it.id)) } + // 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) } @@ -155,9 +161,11 @@ public object OneBotMessages { // 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 - ?: result.echo?.toString() ?: "sent" Delivery.Delivered(listOf(ChannelMessageRef(chat, id))) } @@ -167,9 +175,13 @@ public object OneBotMessages { is OneBotResult.Failed -> Delivery.NotDelivered(failureKind(result.retcode), result.message) is OneBotResult.Unreachable -> Delivery.NotDelivered( - when (result.failure.kind) { - OneBotFailureKind.TIMEOUT, OneBotFailureKind.CONNECTION -> DeliveryFailure.TRANSIENT - OneBotFailureKind.AUTHENTICATION -> DeliveryFailure.FORBIDDEN + 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, @@ -221,7 +233,10 @@ public object OneBotMessages { private fun chatInfo(event: OneBotEvent.Message): ChatInfo = when (event) { is OneBotEvent.Message.Private -> ChatInfo(ChatKind.DIRECT, null, null) - is OneBotEvent.Message.Group -> ChatInfo(ChatKind.GROUP, event.sender.card, null) + + // A group message carries no name of the group, and the card of whoever spoke is the name of that member, so + // putting it here would label the chat with the last person who said something. + is OneBotEvent.Message.Group -> ChatInfo(ChatKind.GROUP, null, null) } private fun media(segments: List): List = segments.mapNotNull { segment -> 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 index a05533d0..7d79f413 100644 --- 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 @@ -28,7 +28,7 @@ public class SendPrivateMessage( public val autoEscape: Boolean = false, ) : OneBotRequest { override fun toJson(): JsonObject = buildJsonObject { - put("user_id", userId.value) + 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 @@ -44,7 +44,7 @@ public class SendGroupMessage( public val autoEscape: Boolean = false, ) : OneBotRequest { override fun toJson(): JsonObject = buildJsonObject { - put("group_id", groupId.value) + 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 @@ -71,8 +71,8 @@ public class SendMessage( override fun toJson(): JsonObject = buildJsonObject { messageType?.let { put("message_type", it) } - userId?.let { put("user_id", it.value) } - groupId?.let { put("group_id", it.value) } + 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 @@ -88,12 +88,16 @@ public class SendMessage( /** `delete_msg`: recalls a message. */ public class DeleteMessage(public val messageId: MessageId) : OneBotRequest { - override fun toJson(): JsonObject = buildJsonObject { put("message_id", messageId.value) } + 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", messageId.value) } + override fun toJson(): JsonObject = buildJsonObject { + put("message_id", OneBotSegmentCodec.number(messageId.value)) + } } /** `get_forward_msg`: reads the nodes of a forwarded message. */ @@ -108,7 +112,7 @@ public class SendLike(public val userId: UserId, public val times: Int = 1) : On } override fun toJson(): JsonObject = buildJsonObject { - put("user_id", userId.value) + put("user_id", OneBotSegmentCodec.number(userId.value)) put("times", times) } } @@ -121,8 +125,8 @@ public class SetGroupKick( public val rejectAddRequest: Boolean = false, ) : OneBotRequest { override fun toJson(): JsonObject = buildJsonObject { - put("group_id", groupId.value) - put("user_id", userId.value) + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) if (rejectAddRequest) put("reject_add_request", true) } } @@ -139,8 +143,8 @@ public class SetGroupBan( } override fun toJson(): JsonObject = buildJsonObject { - put("group_id", groupId.value) - put("user_id", userId.value) + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) put("duration", duration) } @@ -162,7 +166,7 @@ public class SetGroupAnonymousBan( } override fun toJson(): JsonObject = buildJsonObject { - put("group_id", groupId.value) + put("group_id", OneBotSegmentCodec.number(groupId.value)) put("anonymous_flag", flag) put("duration", duration) } @@ -171,7 +175,7 @@ public class SetGroupAnonymousBan( /** `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", groupId.value) + put("group_id", OneBotSegmentCodec.number(groupId.value)) put("enable", enable) } } @@ -180,8 +184,8 @@ public class SetGroupWholeBan(public val groupId: GroupId, public val enable: Bo 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", groupId.value) - put("user_id", userId.value) + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) put("enable", enable) } } @@ -189,7 +193,7 @@ public class SetGroupAdmin(public val groupId: GroupId, public val userId: UserI /** `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", groupId.value) + put("group_id", OneBotSegmentCodec.number(groupId.value)) put("enable", enable) } } @@ -198,8 +202,8 @@ public class SetGroupAnonymous(public val groupId: GroupId, public val enable: B public class SetGroupCard(public val groupId: GroupId, public val userId: UserId, public val card: String = "") : OneBotRequest { override fun toJson(): JsonObject = buildJsonObject { - put("group_id", groupId.value) - put("user_id", userId.value) + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) put("card", card) } } @@ -211,7 +215,7 @@ public class SetGroupName(public val groupId: GroupId, public val groupName: Str } override fun toJson(): JsonObject = buildJsonObject { - put("group_id", groupId.value) + put("group_id", OneBotSegmentCodec.number(groupId.value)) put("group_name", groupName) } } @@ -219,7 +223,7 @@ public class SetGroupName(public val groupId: GroupId, public val groupName: Str /** `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", groupId.value) + put("group_id", OneBotSegmentCodec.number(groupId.value)) if (isDismiss) put("is_dismiss", true) } } @@ -233,8 +237,8 @@ public class SetGroupSpecialTitle( public val duration: Long = PERMANENT, ) : OneBotRequest { override fun toJson(): JsonObject = buildJsonObject { - put("group_id", groupId.value) - put("user_id", userId.value) + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) put("special_title", specialTitle) put("duration", duration) } @@ -293,7 +297,7 @@ public class SetGroupAddRequest( /** `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", userId.value) + put("user_id", OneBotSegmentCodec.number(userId.value)) if (noCache) put("no_cache", true) } } @@ -301,7 +305,7 @@ public class GetStrangerInfo(public val userId: UserId, public val noCache: Bool /** `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", groupId.value) + put("group_id", OneBotSegmentCodec.number(groupId.value)) if (noCache) put("no_cache", true) } } @@ -313,15 +317,15 @@ public class GetGroupMemberInfo( public val noCache: Boolean = false, ) : OneBotRequest { override fun toJson(): JsonObject = buildJsonObject { - put("group_id", groupId.value) - put("user_id", userId.value) + 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", groupId.value) } + override fun toJson(): JsonObject = buildJsonObject { put("group_id", OneBotSegmentCodec.number(groupId.value)) } } /** `get_group_honor_info`: reads the honors of a group. */ @@ -331,7 +335,7 @@ public class GetGroupHonorInfo(public val groupId: GroupId, public val type: Str } override fun toJson(): JsonObject = buildJsonObject { - put("group_id", groupId.value) + put("group_id", OneBotSegmentCodec.number(groupId.value)) put("type", type) } 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 index b6d15708..130545ca 100644 --- 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 @@ -21,9 +21,12 @@ import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSeg * so does an event whose kind it models but whose own type it does not. */ public object OneBotEventCodec { - /** The event of [element]. */ + /** The event of [element], which throws when it is no object, an event of the standard being one. */ public fun decode(element: JsonElement): OneBotEvent { - val raw = element as? JsonObject ?: return OneBotEvent.Unknown(0L, SelfId("0"), "", JsonObject(emptyMap())) + // 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")) { @@ -120,6 +123,7 @@ public object OneBotEventCodec { operatorId = operatorId(), isBan = subType == OneBotEvent.Notice.BAN, duration = raw.number("duration"), + subType = subType, raw = raw, ) 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 index 4293a695..d41da043 100644 --- 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 @@ -15,6 +15,9 @@ import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId 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 = '=' @@ -163,21 +166,37 @@ public object CqCode { is OneBotSegment.Json -> parameters("json", "data" to segment.data) } - /** The value of [text] with the characters that end a code escaped. */ - public fun escape(text: String): String = buildString(text.length) { + /** + * 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("&") - '[' -> append("[") - ']' -> append("]") - ',' -> append(",") + 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 = buildString(text.length) { + 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] @@ -186,7 +205,7 @@ public object CqCode { at++ continue } - val entity = ENTITIES.firstOrNull { text.startsWith(it.first, at) } + val entity = ENTITIES.firstOrNull { text.startsWith(it.first, at) && (comma || it.first != COMMA_ENTITY) } if (entity == null) { append(char) at++ @@ -207,7 +226,7 @@ public object CqCode { append(PREFIX).append(type) for ((key, value) in pairs) { if (value == null) continue - append(SEPARATOR).append(key).append(ASSIGNMENT).append(escape(value)) + append(SEPARATOR).append(key).append(ASSIGNMENT).append(escapeValue(value)) } append(SUFFIX) } @@ -229,7 +248,7 @@ public object CqCode { for (part in parts.drop(1)) { val assignment = part.indexOf(ASSIGNMENT) if (assignment <= 0) continue - parameters[part.substring(0, assignment).trim()] = unescape(part.substring(assignment + 1)) + parameters[part.substring(0, assignment).trim()] = unescapeValue(part.substring(assignment + 1)) } return segment(type, parameters) } 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 index 22e7a4ec..0ffdd8dc 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -72,28 +73,28 @@ public object OneBotSegmentCodec { is OneBotSegment.Image -> putJsonObject("data") { put("file", segment.file) - put("type", segment.subtype) - put("url", segment.url) - put("cache", segment.cache?.let(::flag)) - put("proxy", segment.proxy?.let(::flag)) - put("timeout", segment.timeout) + 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) - put("magic", segment.magic?.let(::flag)) - put("url", segment.url) - put("cache", segment.cache?.let(::flag)) - put("proxy", segment.proxy?.let(::flag)) - put("timeout", segment.timeout) + 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) - put("url", segment.url) - put("cache", segment.cache?.let(::flag)) - put("proxy", segment.proxy?.let(::flag)) - put("timeout", segment.timeout) + optional("url", segment.url) + optional("cache", segment.cache?.let(::flag)) + optional("proxy", segment.proxy?.let(::flag)) + optional("timeout", segment.timeout) } is OneBotSegment.At -> putJsonObject("data") { put("qq", user(segment.userId)) } @@ -103,16 +104,16 @@ public object OneBotSegmentCodec { is OneBotSegment.Poke -> putJsonObject("data") { put("type", segment.pokeType) put("id", segment.id) - put("name", segment.name) + optional("name", segment.name) } - is OneBotSegment.Anonymous -> putJsonObject("data") { put("ignore", segment.ignore?.let(::flag)) } + is OneBotSegment.Anonymous -> putJsonObject("data") { optional("ignore", segment.ignore?.let(::flag)) } is OneBotSegment.Share -> putJsonObject("data") { - put("url", segment.url) - put("title", segment.title) - put("content", segment.content) - put("image", segment.image) + optional("url", segment.url) + optional("title", segment.title) + optional("content", segment.content) + optional("image", segment.image) } is OneBotSegment.Contact -> putJsonObject("data") { @@ -123,18 +124,18 @@ public object OneBotSegmentCodec { is OneBotSegment.Location -> putJsonObject("data") { put("lat", segment.lat) put("lon", segment.lon) - put("title", segment.title) - put("content", segment.content) + optional("title", segment.title) + optional("content", segment.content) } is OneBotSegment.Music -> putJsonObject("data") { put("type", segment.musicType) put("id", segment.id) - put("url", segment.url) - put("audio", segment.audio) - put("title", segment.title) - put("content", segment.content) - put("image", segment.image) + 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)) } @@ -158,8 +159,18 @@ public object OneBotSegmentCodec { private fun flag(value: Boolean): String = if (value) "1" else "0" - /** [id] as a JSON number when it is one, as a string when it is too large for a [Long]. */ - private fun number(id: String): JsonElement = id.toLongOrNull()?.let(::JsonPrimitive) ?: JsonPrimitive(id) + /** [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) 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 d5de4083..eb72092b 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 @@ -66,6 +66,10 @@ internal class OneBotForwardWebSocket(settings: OneBotSettings) : AbstractOneBot } 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 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 index c36daff7..3fda22d2 100644 --- 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 @@ -158,7 +158,7 @@ internal class OneBotHttpPostReceiver( // 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) + drain(input, outcome.declaredBytes, deadline) return respond(client, OneBotReportRejection.TOO_LARGE) } @@ -196,12 +196,22 @@ internal class OneBotHttpPostReceiver( if (selfId != null && reported != selfId) { return respond(client, OneBotReportRejection.WRONG_ACCOUNT) } - report(event) - acceptedReports.incrementAndGet() + // 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.QuickOperation -> respond(client, taken.operation) - is OneBotReportAnswer.Rejected -> respond(client, taken.rejection) - OneBotReportAnswer.Accepted -> respond(client, null) + 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 @@ -222,11 +232,14 @@ internal class OneBotHttpPostReceiver( 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: application/json\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), @@ -264,10 +277,13 @@ internal class OneBotHttpPostReceiver( * 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) { + 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 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 28aa2180..76e59ca1 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 @@ -39,6 +39,14 @@ internal class OneBotReverseWebSocket(private val settings: OneBotSettings) : 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) } @@ -196,6 +204,9 @@ internal class OneBotReverseWebSocket(private val settings: OneBotSettings) : /** 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 } } 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 index 713c4730..c71dd6e7 100644 --- 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 @@ -153,6 +153,18 @@ class OneBotMessagesTest { assertEquals(" done", assertIs(segments[2]).text) } + @Test + fun `a reply to a message whose id is not one of the implementation is sent without the reply segment`() { + // The delivery of a message this version could not name is referenced as `sent` or `async`, which names no + // message of the implementation. Answering one has to send the answer rather than fail before sending it. + val message = OutboundMessage.builder("hi", org.foedusprogramme.alexandrite.sdk.channel.MessageKind.REPLY) + .replyTo(org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef(chat(), "sent")) + .build() + + val segments = OneBotMessages.outgoing(chat(), message).segments + assertEquals(listOf(OneBotSegment.Text("hi")), segments) + } + @Test fun `a reply to another message leads with the reply segment`() { val message = OutboundMessage.builder("hi", org.foedusprogramme.alexandrite.sdk.channel.MessageKind.REPLY) 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 index efc0413b..35768e4d 100644 --- 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 @@ -20,7 +20,10 @@ class CqCodeTest { 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")) - assertEquals(listOf(OneBotSegment.Text("x,y")), CqCode.decode("x,y")) + // 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("")) } @@ -54,8 +57,10 @@ class CqCodeTest { 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) - assertEquals(text, CqCode.encode(decoded)) + 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 @@ -165,6 +170,16 @@ class CqCodeTest { 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 { 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 index d9abba4a..e3b0a842 100644 --- 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 @@ -171,7 +171,9 @@ class OneBotHttpPostReceiverTest { writer.write("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 100000\r\n\r\n".toByteArray()) writer.flush() runCatching { - repeat(20) { + // 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() @@ -180,7 +182,7 @@ class OneBotHttpPostReceiverTest { } val elapsed = (System.nanoTime() - started) / 1_000_000 - assertTrue(elapsed < 10_000, "the listener read a slow report for ${elapsed}ms") + assertTrue(elapsed < 8_000, "the listener read a slow report for ${elapsed}ms") assertEquals(0, receiver.reported()) } From 466331714d2f5dda7bb1b714a120df22663e0f6d Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 08:12:21 +0800 Subject: [PATCH 08/36] onebot: Take a command, and a group message only when the bot is addressed A report carried into a turn whatever it said, so every message of a group was answered by the agent. The channel now tells the two apart. A command starts a message and is named after a `/`, which is the prefix every channel of this project spells one with. It is submitted as a command rather than as a message, so the handlers of the runtime see it and the arguments it carries. A mention before it leaves the command alone, because the text of a report is its text segments and the mention is a segment of its own. Everything else becomes a turn only when the bot is addressed: a direct chat always, a group when the message names the bot or answers one of its messages. What a message answers is reported as an id and not as an author, so the ids of the messages this instance sent are kept and a reply is matched against them. A message the agent refused is reported with the reason, which a chat that no agent serves is refused for, and which nothing else says. --- .../channel/onebot/channel/OneBotChannel.kt | 21 ++- .../channel/onebot/channel/OneBotInbox.kt | 126 +++++++++++++--- .../onebot/channel/OneBotSentMessages.kt | 36 +++++ .../onebot/OneBotChannelIntegrationTest.kt | 123 +++++++++++++++ .../onebot/channel/OneBotCommandsTest.kt | 142 ++++++++++++++++++ 5 files changed, 427 insertions(+), 21 deletions(-) create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSentMessages.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandsTest.kt 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 587fd619..a5102055 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 @@ -33,6 +33,8 @@ internal class OneBotChannel( private val instance: ChannelInstance, private val config: OneBotInstanceConfig, private val link: OneBotLink, + /** The messages this instance sent, which tell a reply to the bot from a reply to somebody else. */ + private val sent: OneBotSentMessages, /** The agent, null while the runtime has none. */ private val submitter: TurnSubmitter?, ) : Channel { @@ -75,10 +77,15 @@ internal class OneBotChannel( action(chat) ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") val params = params(chat, message) ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") - return OneBotMessages.delivery(link.call(target.first, params), chat) + return remembered(OneBotMessages.delivery(link.call(target.first, params), chat)) } - /** The reply of one turn, which delivers the final message of the agent. */ + /** + * The reply of one turn, which delivers the final message of the agent. + * + * Every delivery of this instance goes through here or through [send], so that a later message answering this one + * is known to be answering the bot. + */ private class Sink(private val channel: OneBotChannel, private val request: ReplyRequest) : ReplySink { override suspend fun preview(segment: Int, text: String) = Unit @@ -87,9 +94,17 @@ internal class OneBotChannel( ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no chat of this turn.") val params = channel.params(request.turn.chat, message) ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no chat of this turn.") - return OneBotMessages.delivery(channel.link.call(target.first, params), request.turn.chat) + return channel.remembered( + OneBotMessages.delivery(channel.link.call(target.first, params), request.turn.chat), + ) } override suspend fun abandon(end: ReplyEnd) = Unit } + + /** Remembers the ids of what [delivery] delivered, which is what a reply is matched against. */ + private fun remembered(delivery: Delivery): Delivery { + (delivery as? Delivery.Delivered)?.messages?.forEach { sent.remember(it.id) } + return delivery + } } 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 a8736c8f..6f1aaf5e 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 @@ -6,10 +6,14 @@ import kotlinx.coroutines.launch import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +import org.foedusprogramme.alexandrite.sdk.channel.IncomingMessage import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped import org.foedusprogramme.alexandrite.sdk.di.Lifecycle 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 @@ -18,18 +22,24 @@ import org.slf4j.LoggerFactory /** * What the implementation reports, taken into the agent. * - * A message becomes a submission, which the agent queues as a turn of its chat. Every other event, a notice, a request - * or a heartbeat, is read and dropped here: it is not a turn, and this version has no way to hand one to another - * plugin. The connection can carry it, so the way out is a decision about the shape of that interface rather than - * about this class, and until it is taken a plugin that needs a notice or a request goes through [OneBotApi] for the - * state it wants. An instance whose runtime has no agent accepts the events and hands them nowhere, which is reported - * once so that the silence is visible rather than puzzling. + * 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, + /** The messages this instance sent, which tell a reply to the bot from a reply to somebody else. */ + private val sent: OneBotSentMessages, /** The agent, null while the runtime has none. */ private val submitter: TurnSubmitter?, ) : Lifecycle { @@ -55,25 +65,105 @@ internal class OneBotInbox( private suspend fun read() { link.events.collect { event -> if (event !is OneBotEvent.Message) return@collect + // 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) + if (command == null && !addressed(event)) return@collect val message = OneBotMessages.incoming(instance.id, event, config.admins) - val admission = submitter?.submit(Submission.Message(message)) - // 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. - if (admission is Admission.Refused) { - logger.warn( - "{}: instance {} took a message of {} no further: {}", - PLUGIN, - instance.id, - message.chat, - admission.reason, - ) - } + val submission = if (command == null) Submission.Message(message) else commandOf(command, message) + report(message, submitter?.submit(submission)) } } + /** 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 = CommandInvocation.builder(message.chat, command.name, message.sender) + .arguments(command.arguments) + .trigger(message.ref) + .build(), + message = message, + capacity = if (message.sender.isAdmin) Capacity.EXEMPT else Capacity.COUNTED, + ) + + /** Whether the bot of this instance is addressed by [event]. */ + private fun addressed(event: OneBotEvent.Message): 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) }, + ) + + /** + * 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. A command is taken whether or not the bot + * is named, because naming it is what a command may do to be sure of a bot, not what makes it a command. + */ +internal object OneBotCommands { + /** The prefix a command is named after. */ + const val PREFIX = "/" + + /** 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() } + if (!token.startsWith(PREFIX) || token.length == PREFIX.length) return null + return Parsed(token.removePrefix(PREFIX), 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/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/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotChannelIntegrationTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotChannelIntegrationTest.kt index 57df5205..4a0f79a8 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 @@ -72,6 +72,108 @@ class OneBotChannelIntegrationTest { } } + @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 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() + } + } + private suspend fun org.foedusprogramme.alexandrite.testkit.PluginHarness.Running.awaitSubmission( submitter: RecordingTurnSubmitter, ): Submission { @@ -120,6 +222,27 @@ 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"}}""") + } + + /** 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) 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..b18f45c5 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandsTest.kt @@ -0,0 +1,142 @@ +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 `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))) +} From bb8ba8388037657069e62f5d09a46426182bb925 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 19:12:50 +0800 Subject: [PATCH 09/36] onebot: Answer an approval by replying to the message that asked for it The channel had left the contract answer that says it cannot ask for approvals, so every call only a person may allow was refused by policy. An approval is shown in the chat of an admin's own turn, or in the direct chats of the admins, and an admin answers it by replying to that message with /approve or /deny, or by writing the id of the request; an id that names several requests is refused and listed rather than guessed at. Its texts come from the plugin's language files in the language of the chat, and an answer that was taken is said once. --- app/src/dist/config/alexandrite.example.json | 4 +- .../zh-CN.properties | 26 ++ .../alexandrite/app/AppTextsTest.kt | 5 +- .../channel/onebot/channel/OneBotApprovals.kt | 421 ++++++++++++++++++ .../channel/onebot/channel/OneBotChannel.kt | 72 +-- .../channel/onebot/channel/OneBotInbox.kt | 20 + .../channel/onebot/channel/OneBotSender.kt | 63 +++ .../channel/onebot/config/OneBotConfig.kt | 5 + .../channel/onebot/i18n/TextKeys.kt | 69 +++ .../channel/onebot/mapping/OneBotMessages.kt | 4 + .../alexandrite-channel-onebot/en.properties | 26 ++ .../onebot/OneBotApprovalIntegrationTest.kt | 307 +++++++++++++ .../channel/onebot/i18n/TextKeysTest.kt | 34 ++ 13 files changed, 993 insertions(+), 63 deletions(-) create mode 100644 app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-onebot/zh-CN.properties 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/OneBotSender.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/resources/alexandrite/i18n/alexandrite-channel-onebot/en.properties 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/i18n/TextKeysTest.kt diff --git a/app/src/dist/config/alexandrite.example.json b/app/src/dist/config/alexandrite.example.json index 01864c08..699e4497 100644 --- a/app/src/dist/config/alexandrite.example.json +++ b/app/src/dist/config/alexandrite.example.json @@ -17,7 +17,9 @@ "main": { "transport": "ws_reverse", "listenHost": "127.0.0.1", - "listenPort": 8080 + "listenPort": 8080, + "admins": [], + "approvalMinutes": 10 } } } 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..bb11e0be --- /dev/null +++ b/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-onebot/zh-CN.properties @@ -0,0 +1,26 @@ +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。 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 bbecb125..80790949 100644 --- a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/AppTextsTest.kt +++ b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/AppTextsTest.kt @@ -80,7 +80,10 @@ class AppTextsTest { val classLoader = AppPlugin::class.java.classLoader val packs = AppTexts.packs(classLoader) - assertEquals(setOf("alexandrite-agent", "alexandrite-channel-telegram", "alexandrite-commands"), packs.plugins) + assertEquals( + setOf("alexandrite-agent", "alexandrite-channel-telegram", "alexandrite-channel-onebot", "alexandrite-commands"), + packs.plugins, + ) for (plugin in packs.plugins) assertEquals(listOf(chinese), assertTextsComplete(plugin, packs, classLoader)) } 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..f10a8968 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApprovals.kt @@ -0,0 +1,421 @@ +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.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 { + for (chat in chats) post(waiting, chat, languageOf(waiting.request, chat)) + if (waiting.carried.isEmpty()) return UNREACHABLE + 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 that carries [waiting]'s request to [chat] in [language], remembering the message it was sent as. */ + private suspend fun post(waiting: Pending, chat: ChatAddress, language: LanguageTag?) { + val text = render(waiting.request, language) + val delivery = try { + sender.send(chat, OutboundMessage.builder(text, MessageKind.REPLY).build()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn( + "{} could not show approval {} in chat {}: {}", + instance.id, + waiting.request.id, + chat, + e.message, + ) + return + } + val delivered = delivery as? Delivery.Delivered + if (delivered == null) { + logger.warn("{} could not show approval {} in chat {}: {}", instance.id, waiting.request.id, chat, delivery) + return + } + val carried = waiting.carried.getOrPut(chat) { ConcurrentHashMap.newKeySet() } + delivered.messages.forEach { carried += it.id } + } + + /** 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") + } + + /** [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 index a5102055..0921f12e 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 @@ -3,13 +3,12 @@ package org.foedusprogramme.alexandrite.channel.onebot.channel import org.foedusprogramme.alexandrite.channel.onebot.api.OneBotApi import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages -import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendGroupMessage -import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendPrivateMessage +import org.foedusprogramme.alexandrite.sdk.channel.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.Delivery -import org.foedusprogramme.alexandrite.sdk.channel.DeliveryFailure import org.foedusprogramme.alexandrite.sdk.channel.Markup import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage import org.foedusprogramme.alexandrite.sdk.channel.ReplyEnd @@ -32,39 +31,12 @@ import org.foedusprogramme.alexandrite.sdk.turn.TurnSubmitter internal class OneBotChannel( private val instance: ChannelInstance, private val config: OneBotInstanceConfig, - private val link: OneBotLink, - /** The messages this instance sent, which tell a reply to the bot from a reply to somebody else. */ - private val sent: OneBotSentMessages, + private val sender: OneBotSender, + /** The approvals this instance asks its admins about. */ + private val approvals: OneBotApprovals, /** The agent, null while the runtime has none. */ private val submitter: TurnSubmitter?, ) : Channel { - /** - * The action that sends a message to [chat], null when this instance serves no such chat. - * - * A temporary session of a group is a private chat whose thread names the sender, so it is asked for first: a - * reply to it goes to the sender and not into the group, which is what its address means. - */ - internal fun action(chat: ChatAddress): Pair? = when { - OneBotMessages.privateTemporary(chat) != null -> "send_private_msg" to chat - OneBotMessages.isGroup(chat) && OneBotMessages.group(chat) != null -> "send_group_msg" to chat - OneBotMessages.privateUser(chat) != null -> "send_private_msg" to chat - else -> null - } - - /** The parameters that send [message] to [chat], null when this instance serves no such chat. */ - internal fun params(chat: ChatAddress, message: OutboundMessage): kotlinx.serialization.json.JsonObject? { - val temporary = OneBotMessages.privateTemporary(chat) - val user = OneBotMessages.privateUser(chat) - val group = OneBotMessages.group(chat) - val segments = OneBotMessages.outgoing(chat, message) - return when { - temporary != null -> SendPrivateMessage(temporary, segments).toJson() - group != null -> SendGroupMessage(group, segments).toJson() - user != null -> SendPrivateMessage(user, segments).toJson() - else -> null - } - } - override suspend fun capabilities(chat: ChatAddress): ChannelCapabilities = OneBotMessages.capabilities() override suspend fun partsNeeded(chat: ChatAddress, text: String, markup: Markup): Int = @@ -72,39 +44,17 @@ internal class OneBotChannel( override suspend fun openReply(request: ReplyRequest): ReplySink = Sink(this, request) - override suspend fun send(chat: ChatAddress, message: OutboundMessage): Delivery { - val target = - action(chat) ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") - val params = params(chat, message) - ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") - return remembered(OneBotMessages.delivery(link.call(target.first, params), chat)) - } + override suspend fun send(chat: ChatAddress, message: OutboundMessage): Delivery = sender.send(chat, message) + + override suspend fun askApproval(request: ApprovalRequest): ApprovalAnswer = approvals.ask(request) - /** - * The reply of one turn, which delivers the final message of the agent. - * - * Every delivery of this instance goes through here or through [send], so that a later message answering this one - * is known to be answering the bot. - */ + /** The reply of one turn, which delivers the final message of the agent. */ private class Sink(private val channel: OneBotChannel, private val request: ReplyRequest) : ReplySink { override suspend fun preview(segment: Int, text: String) = Unit - override suspend fun complete(message: OutboundMessage): Delivery { - val target = channel.action(request.turn.chat) - ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no chat of this turn.") - val params = channel.params(request.turn.chat, message) - ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no chat of this turn.") - return channel.remembered( - OneBotMessages.delivery(channel.link.call(target.first, params), request.turn.chat), - ) - } + override suspend fun complete(message: OutboundMessage): Delivery = + channel.sender.send(request.turn.chat, message) override suspend fun abandon(end: ReplyEnd) = Unit } - - /** Remembers the ids of what [delivery] delivered, which is what a reply is matched against. */ - private fun remembered(delivery: Delivery): Delivery { - (delivery as? Delivery.Delivered)?.messages?.forEach { sent.remember(it.id) } - return delivery - } } 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 6f1aaf5e..5ddafcc7 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 @@ -4,11 +4,14 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotChats import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance 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.di.ChannelInstanceScoped import org.foedusprogramme.alexandrite.sdk.di.Lifecycle import org.foedusprogramme.alexandrite.sdk.turn.Admission @@ -38,6 +41,10 @@ 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, /** The agent, null while the runtime has none. */ @@ -69,12 +76,25 @@ internal class OneBotInbox( // would each become a turn of the agent. val command = OneBotCommands.of(event) if (command == null && !addressed(event)) 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 asked = OneBotMessages.senderOf(instance.id, event, config.admins) + val answer = approvals.answer(event, command, asked) + if (answer != null) { + sender.send(OneBotChats.addressOf(instance.id, event), said(answer)) + } + return@collect + } val message = OneBotMessages.incoming(instance.id, event, config.admins) val submission = if (command == null) Submission.Message(message) else commandOf(command, message) report(message, submitter?.submit(submission)) } } + /** The message that says [text] as the answer to an approval command. */ + private fun said(text: String): OutboundMessage = OutboundMessage.builder(text, MessageKind.REPLY).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( 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..f4807689 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSender.kt @@ -0,0 +1,63 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendGroupMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendPrivateMessage +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.DeliveryFailure +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped + +/** + * 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) { + /** + * 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. */ + suspend fun send(chat: ChatAddress, message: OutboundMessage): Delivery { + val target = action(chat) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + val params = params(chat, message) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + return remember(OneBotMessages.delivery(link.call(target.first, params), chat)) + } + + /** 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 + } +} 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 index 8877e2e4..c32547d9 100644 --- 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 @@ -101,6 +101,8 @@ public class OneBotInstanceConfig( @SerialName("reconnectIntervalMillis") public val reconnectIntervalMillis: Long = DEFAULT_RECONNECT, /** The accounts that may operate this instance, which the channel marks as admins. */ public val admins: Set = emptySet(), + /** How long an approval request waits for an admin's answer, in minutes. */ + @SerialName("approvalMinutes") public val approvalMinutes: Int = DEFAULT_APPROVAL_MINUTES, ) { /** How this instance reaches its implementation. */ public val kind: OneBotTransport = OneBotTransport.of(transport) ?: throw IllegalArgumentException( @@ -112,6 +114,7 @@ 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." } 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. @@ -153,6 +156,8 @@ public class OneBotInstanceConfig( public const val DEFAULT_RECONNECT: Long = 3_000L + public const val DEFAULT_APPROVAL_MINUTES: Int = 10 + public const val MAX_PORT: Int = 65_535 } } 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..cdb54834 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/i18n/TextKeys.kt @@ -0,0 +1,69 @@ +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" +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt index 3b49ef30..3882451d 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt @@ -218,6 +218,10 @@ 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. */ + 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 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..85ea9ea5 --- /dev/null +++ b/libraries/channels/onebot/src/main/resources/alexandrite/i18n/alexandrite-channel-onebot/en.properties @@ -0,0 +1,26 @@ +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. 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..853831cc --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt @@ -0,0 +1,307 @@ +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 `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, + "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() + + @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\"") + val id = if (sending) "${counter.incrementAndGet()}" else "${counter.get()}" + 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) + } + + 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/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" + } +} From 016221892048efde2bdddbb3f7cf7b21ddcd453d Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 19:12:50 +0800 Subject: [PATCH 10/36] onebot: Say what a message that was quoted holds A report names the message it answers and says nothing about it, so a quote reached the agent as a name it could not read. What the channel read as it arrives is kept and a quote is answered from it, and only a message older than what is kept is read back from the implementation through get_msg. A card is named by what it says, such as the title and singer of a song, so a quoted card is not a name without content. --- .../channel/onebot/channel/OneBotInbox.kt | 45 ++- .../onebot/channel/OneBotRecentMessages.kt | 49 ++++ .../channel/onebot/mapping/OneBotMessages.kt | 112 +++++++- .../onebot/OneBotQuoteIntegrationTest.kt | 257 ++++++++++++++++++ 4 files changed, 456 insertions(+), 7 deletions(-) create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotRecentMessages.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotQuoteIntegrationTest.kt 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 5ddafcc7..43e34421 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 @@ -1,17 +1,24 @@ 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.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.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.IncomingMessage 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.di.ChannelInstanceScoped import org.foedusprogramme.alexandrite.sdk.di.Lifecycle import org.foedusprogramme.alexandrite.sdk.turn.Admission @@ -47,6 +54,8 @@ internal class OneBotInbox( 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, /** The agent, null while the runtime has none. */ private val submitter: TurnSubmitter?, ) : Lifecycle { @@ -72,6 +81,10 @@ internal class OneBotInbox( private suspend fun read() { link.events.collect { event -> if (event !is OneBotEvent.Message) 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) @@ -82,16 +95,44 @@ internal class OneBotInbox( val asked = OneBotMessages.senderOf(instance.id, event, config.admins) val answer = approvals.answer(event, command, asked) if (answer != null) { - sender.send(OneBotChats.addressOf(instance.id, event), said(answer)) + sender.send(chat, said(answer)) } return@collect } - val message = OneBotMessages.incoming(instance.id, event, config.admins) + val message = OneBotMessages.incoming(instance.id, event, config.admins, quoted(chat, event)) val submission = if (command == null) Submission.Message(message) else commandOf(command, message) report(message, submitter?.submit(submission)) } } + /** + * 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 the answer to an approval command. */ private fun said(text: String): OutboundMessage = OutboundMessage.builder(text, MessageKind.REPLY).build() 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/mapping/OneBotMessages.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt index 3882451d..a7eaa795 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt @@ -1,14 +1,17 @@ package org.foedusprogramme.alexandrite.channel.onebot.mapping import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotMessageInfo import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotMessage import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegmentCodec import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailureKind import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult import org.foedusprogramme.alexandrite.sdk.channel.ChannelCapabilities @@ -90,11 +93,17 @@ public object OneBotMessages { .maxPartsPerReply(null) .build() - /** The message [event] reported, as Alexandrite reads it. */ + /** + * 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, ): IncomingMessage { val chat = OneBotChats.addressOf(instance, event) val segments = event.message.segments @@ -107,7 +116,7 @@ public object OneBotMessages { ) .text(segments.filterIsInstance().joinToString("") { it.text }) .media(media(segments)) - .quote(quote(event, chat)) + .quote(quote(event, chat, quoted)) .facts(facts(event, segments)) .build() } @@ -274,14 +283,91 @@ public object OneBotMessages { throw UnsupportedOperationException("Reading '$reference' is not implemented yet.") } - private fun quote(event: OneBotEvent.Message, chat: ChatAddress): Quote? { + /** + * 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, + ) + } + + /** 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 + return QuotedMessage( + text = quotedText(OneBotSegmentCodec.decodeMessage(info.message ?: JsonNull).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, + ) + } + + /** + * 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 - return Quote.builder("") - .target(ChannelMessageRef(chat, reply.id.value)) + 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")) } @@ -315,3 +401,19 @@ public object OneBotMessages { 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, +) 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..a731b724 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotQuoteIntegrationTest.kt @@ -0,0 +1,257 @@ +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 + } + } + } + """.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) + } +} From 15dd55008c18ad50be98ef81e34f1170e1c454f0 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 18:42:09 +0800 Subject: [PATCH 11/36] onebot: Ask who may run a command before running it Nothing asked. The agent's control takes the operator of a change as a parameter and does not judge it, so anyone who could write in a chat that the bot listens to could set the model of that chat, its language, and the agent it speaks to. The rules the Telegram channel applies are applied here as well: a command a handler claims that the table does not list is an admin's alone, settings is anyone's, model is anyone's without arguments and an admin's with them, and the rest are for a direct chat. A refusal is said in the chat the command was written in, because the agent never sees the command at all. The rules are a copy of the Telegram channel's rather than the same code: they belong with the rest of what every channel shares, and a copy is what can be made without deciding that shape for both channels at once. --- .../zh-CN.properties | 3 + .../onebot/channel/OneBotCommandRules.kt | 75 +++++++++++++ .../channel/onebot/channel/OneBotInbox.kt | 32 +++++- .../channel/onebot/i18n/TextKeys.kt | 6 ++ .../alexandrite-channel-onebot/en.properties | 3 + .../onebot/channel/OneBotCommandRulesTest.kt | 100 ++++++++++++++++++ 6 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRules.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt 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 bb11e0be..e28365a8 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 @@ -24,3 +24,6 @@ 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}。 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..4aa1df55 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRules.kt @@ -0,0 +1,75 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.onebot.i18n.TextKeys +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 + } + } + + 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( + "settings" 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), + "new" to CommandRule(Who.UNLINKED_DIRECT), + "cancel" to CommandRule(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 index 43e34421..75d2d76b 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 @@ -21,6 +21,7 @@ import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress 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 @@ -56,6 +57,10 @@ internal class OneBotInbox( 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 texts of this channel for people. */ + private val texts: Texts, /** The agent, null while the runtime has none. */ private val submitter: TurnSubmitter?, ) : Lifecycle { @@ -100,6 +105,16 @@ internal class OneBotInbox( return@collect } val message = OneBotMessages.incoming(instance.id, event, config.admins, quoted(chat, event)) + 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) report(message, submitter?.submit(submission)) } @@ -133,16 +148,23 @@ internal class OneBotInbox( } } - /** The message that says [text] as the answer to an approval command. */ + /** 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 = CommandInvocation.builder(message.chat, command.name, message.sender) - .arguments(command.arguments) - .trigger(message.ref) - .build(), + invocation = invocationOf(command, message), message = message, capacity = if (message.sender.isAdmin) Capacity.EXEMPT else Capacity.COUNTED, ) 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 index cdb54834..71a7cdeb 100644 --- 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 @@ -66,4 +66,10 @@ internal object TextKeys { /** Takes `{ids}`. */ const val APPROVAL_SEVERAL: String = "approval.several" + + /** Takes `{command}`. */ + const val REFUSED_ADMIN: String = "refused.admin" + + /** Takes `{command}`. */ + const val REFUSED_ADMIN_HERE: String = "refused.admin_here" } 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 85ea9ea5..95c74b62 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 @@ -24,3 +24,6 @@ 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. 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..4aa956be --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt @@ -0,0 +1,100 @@ +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.assertNull + +/** 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 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)) + } + + /** 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("settings", "agent", "reasoning", "language", "model", "mode", "new", "cancel") + } +} From 3eac791e59ad90bb1c4213cfd45cfcddf3dd158b Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 18:50:38 +0800 Subject: [PATCH 12/36] onebot: Serve a chat only once somebody said it may A bot that was invited into a group answered everything it was addressed with, and ran the commands of anyone who could write there. No chat had to be approved, because this channel had no pairing of its own: the runtime offers every channel a pairing desk, and this one was never asked for. The pairing of the shared channel code now covers the chats of every OneBot instance. A chat is served when the config declares it in `allowedChats`, or when the host approves the code the chat was offered, which is what the `alexandrite pair` command of the app takes. A chat that is neither is offered a code when it addresses the bot, and nothing else of it passes: no turn, no command, and nothing the agent starts itself, since the channel now reports itself unable to speak unprompted in a chat it does not serve. The warning the gate writes when an agent's home chat or a linked chat is not served is the one the Telegram channel writes, down to the config path it names. --- .../zh-CN.properties | 1 + libraries/channels/onebot/build.gradle.kts | 1 + .../onebot/channel/OneBotAccessGate.kt | 143 ++++++++++++++++++ .../channel/onebot/channel/OneBotChannel.kt | 5 +- .../channel/onebot/channel/OneBotInbox.kt | 14 +- .../channel/onebot/config/OneBotConfig.kt | 7 + .../channel/onebot/i18n/TextKeys.kt | 3 + .../channel/onebot/mapping/OneBotMessages.kt | 4 +- .../onebot/pairing/OneBotPairingDesk.kt | 13 ++ .../alexandrite-channel-onebot/en.properties | 1 + .../onebot/OneBotApprovalIntegrationTest.kt | 1 + .../onebot/OneBotChannelIntegrationTest.kt | 50 +++++- .../onebot/OneBotQuoteIntegrationTest.kt | 3 +- .../onebot/mapping/OneBotMessagesTest.kt | 3 +- 14 files changed, 241 insertions(+), 8 deletions(-) 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/pairing/OneBotPairingDesk.kt 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 e28365a8..e2582ad4 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 @@ -27,3 +27,4 @@ approval.several=这没有指出唯一的请求:{ids}。请写出所需那一 refused.admin=只有本实例的管理员可以使用 /{command}。 refused.admin_here=只有本实例的管理员可以在本聊天使用 /{command}。 +pairing.code=本聊天尚未与变石配对。请把配对码 {code} 交给运维方,该码 {minutes} 分钟内有效。 diff --git a/libraries/channels/onebot/build.gradle.kts b/libraries/channels/onebot/build.gradle.kts index b059679b..e6fa3f24 100644 --- a/libraries/channels/onebot/build.gradle.kts +++ b/libraries/channels/onebot/build.gradle.kts @@ -4,6 +4,7 @@ plugins { } dependencies { + implementation(project(":libraries:channel-common")) implementation(project(":libraries:plugin-sdk")) implementation(project(":libraries:internal")) 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/OneBotChannel.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotChannel.kt index 0921f12e..792567c9 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 @@ -34,10 +34,13 @@ internal class OneBotChannel( private val sender: OneBotSender, /** 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?, ) : Channel { - override suspend fun capabilities(chat: ChatAddress): ChannelCapabilities = OneBotMessages.capabilities() + override suspend fun capabilities(chat: ChatAddress): ChannelCapabilities = + OneBotMessages.capabilities(proactive = gate.isPaired(chat)) override suspend fun partsNeeded(chat: ChatAddress, text: String, markup: Markup): Int = if (text.isEmpty()) 0 else 1 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 75d2d76b..f61b56ef 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 @@ -18,7 +18,9 @@ import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance 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.i18n.Texts @@ -59,6 +61,8 @@ internal class OneBotInbox( 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, /** The agent, null while the runtime has none. */ @@ -93,11 +97,17 @@ internal class OneBotInbox( // 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) - if (command == null && !addressed(event)) return@collect + val addressed = addressed(event) + if (command == null && !addressed) 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 asked = OneBotMessages.senderOf(instance.id, event, config.admins) val answer = approvals.answer(event, command, asked) if (answer != null) { sender.send(chat, said(answer)) 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 index c32547d9..287233a1 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -101,6 +102,8 @@ public class OneBotInstanceConfig( @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, ) { @@ -161,3 +164,7 @@ public class OneBotInstanceConfig( 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 index 71a7cdeb..c90a0964 100644 --- 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 @@ -67,6 +67,9 @@ internal object TextKeys { /** 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" diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt index a7eaa795..6885e7f6 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt @@ -86,10 +86,10 @@ public object OneBotChats { */ public object OneBotMessages { /** What an OneBot channel can do, as this module reports it. */ - public fun capabilities(): ChannelCapabilities = ChannelCapabilities.builder() + public fun capabilities(proactive: Boolean): ChannelCapabilities = ChannelCapabilities.builder() .markups(setOf(Markup.PLAIN, Markup.MARKDOWN)) .streaming(false) - .proactive(true) + .proactive(proactive) .maxPartsPerReply(null) .build() 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) 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 95c74b62..d7681236 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 @@ -27,3 +27,4 @@ approval.several=That names no one request: {ids}. Write the whole id of the one 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. 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 index 853831cc..7b4deefd 100644 --- 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 @@ -227,6 +227,7 @@ class OneBotApprovalIntegrationTest { "firstByteTimeoutMillis": 5000, "idleTimeoutMillis": 5000, "reconnectIntervalMillis": 200, + "allowedChats": ["group:987654", "private:12345678"], "admins": ["$USER"], "approvalMinutes": 1 } 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 4a0f79a8..bb890c8d 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 @@ -174,6 +174,53 @@ class OneBotChannelIntegrationTest { } } + @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 { @@ -201,7 +248,8 @@ class OneBotChannelIntegrationTest { "connectTimeoutMillis": 5000, "firstByteTimeoutMillis": 5000, "idleTimeoutMillis": 5000, - "reconnectIntervalMillis": 200 + "reconnectIntervalMillis": 200, + "allowedChats": ["group:987654", "private:12345678"] } } } 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 index a731b724..ade09855 100644 --- 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 @@ -163,7 +163,8 @@ class OneBotQuoteIntegrationTest { "connectTimeoutMillis": 5000, "firstByteTimeoutMillis": 5000, "idleTimeoutMillis": 5000, - "reconnectIntervalMillis": 200 + "reconnectIntervalMillis": 200, + "allowedChats": ["group:987654", "private:12345678"] } } } 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 index c71dd6e7..65e41687 100644 --- 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 @@ -220,10 +220,11 @@ class OneBotMessagesTest { @Test fun `the capabilities of the channel name what it can do`() { - val capabilities = OneBotMessages.capabilities() + 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) assertEquals(false, capabilities.streaming) assertNull(capabilities.maxPartsPerReply) } From f58cccdbfb3041a967d7633711ffc30d86313da1 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 18:55:17 +0800 Subject: [PATCH 13/36] onebot: Read the bytes a reported picture, voice or video names An attachment a report named was handed to the agent as a read that threw, so every picture and every voice message reached a turn as a file that could not be loaded: the model was told a message carried something and never what. The bytes are now read from the address the report names, and a report that names only what the implementation keeps the file as has that turned into an address first, through `get_image` for a picture and `get_record` for a voice message. A read stops at the size the agent allows: a body longer than that is refused without reading it out, and one whose length says it is too long is refused before it is read at all. What still cannot be read is named as before and throws as before, which is what tells the agent to say the file could not be loaded rather than to forget that the message carried one. --- .../channel/onebot/channel/OneBotInbox.kt | 4 +- .../channel/onebot/channel/OneBotMedia.kt | 118 +++++++++++ .../channel/onebot/mapping/OneBotMessages.kt | 61 +++--- .../onebot/OneBotMediaIntegrationTest.kt | 184 ++++++++++++++++++ 4 files changed, 344 insertions(+), 23 deletions(-) create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotMedia.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotMediaIntegrationTest.kt 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 f61b56ef..6b7928c1 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 @@ -65,6 +65,8 @@ internal class OneBotInbox( 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, /** The agent, null while the runtime has none. */ private val submitter: TurnSubmitter?, ) : Lifecycle { @@ -114,7 +116,7 @@ internal class OneBotInbox( } return@collect } - val message = OneBotMessages.incoming(instance.id, event, config.admins, quoted(chat, event)) + val message = OneBotMessages.incoming(instance.id, event, config.admins, quoted(chat, event), 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. 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/mapping/OneBotMessages.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt index 6885e7f6..dda7e03a 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt @@ -104,6 +104,8 @@ public object OneBotMessages { 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 @@ -115,7 +117,7 @@ public object OneBotMessages { forwarded = null, ) .text(segments.filterIsInstance().joinToString("") { it.text }) - .media(media(segments)) + .media(media(segments, reader)) .quote(quote(event, chat, quoted)) .facts(facts(event, segments)) .build() @@ -252,18 +254,31 @@ public object OneBotMessages { is OneBotEvent.Message.Group -> ChatInfo(ChatKind.GROUP, null, null) } - private fun media(segments: List): List = segments.mapNotNull { segment -> - val attachment = when (segment) { - is OneBotSegment.Image -> MediaKind.IMAGE to (segment.url ?: segment.file) - is OneBotSegment.Record -> MediaKind.AUDIO to (segment.url ?: segment.file) - is OneBotSegment.Video -> MediaKind.VIDEO to (segment.url ?: segment.file) - else -> return@mapNotNull null + 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() } - val (kind, reference) = attachment - if (reference.isNullOrEmpty()) return@mapNotNull null - MediaAttachment.builder(kind, mediaType(kind), unread(reference)) - .name(reference.substringAfterLast('/').takeIf { it.isNotEmpty() }) - .build() + + /** + * The 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. */ @@ -273,16 +288,6 @@ public object OneBotMessages { else -> "video/*" } - /** - * The bytes of the file an implementation named, which this version does not read yet. - * - * Reading it needs the plugin's transport, so it stays the task of whoever asks for the bytes; failing here keeps - * that visible rather than answering with an empty file. - */ - private fun unread(reference: String): MediaContent = MediaContent { - throw UnsupportedOperationException("Reading '$reference' is not implemented yet.") - } - /** * What [event] is, as a message that another one can quote. * @@ -417,3 +422,15 @@ public class QuotedMessage( public val author: UserAddress?, public val authorIsBot: Boolean, ) + +/** + * 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/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..9eaca884 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotMediaIntegrationTest.kt @@ -0,0 +1,184 @@ +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}", + ) + } + } + + /** 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) + } +} From c0953432fc0a3b9d8f30e13f052a0b085657b691 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 19:12:51 +0800 Subject: [PATCH 14/36] plugin-sdk, onebot: Publish what a report says besides a message A report of a membership change, a recall or a request was read and dropped, so no plugin could see one of them. The SDK names such a report as a ChannelNotice and the point a channel fires, which is the hook mechanism the agent's turns already use: a subscriber contributes an ObserverHook and the runtime needs nothing new. onebot maps every report that is not a message to one and publishes it, whether or not the chat is served, and keeps the name the implementation gave to an event the standard does not have. --- .../channel/onebot/channel/OneBotInbox.kt | 13 +- .../channel/onebot/mapping/OneBotNotices.kt | 210 +++++++++++++++ .../onebot/OneBotNoticeIntegrationTest.kt | 160 +++++++++++ .../onebot/mapping/OneBotNoticesTest.kt | 248 ++++++++++++++++++ .../alexandrite/sdk/channel/ChannelNotice.kt | 84 ++++++ .../alexandrite/sdk/channel/ChannelPoints.kt | 20 ++ 6 files changed, 734 insertions(+), 1 deletion(-) create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotNotices.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/mapping/OneBotNoticesTest.kt 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/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 6b7928c1..bbf6b9d3 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 @@ -7,6 +7,7 @@ import kotlinx.coroutines.launch 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 @@ -15,6 +16,7 @@ import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSeg 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 @@ -23,6 +25,7 @@ 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 @@ -67,6 +70,8 @@ internal class OneBotInbox( 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 agent, null while the runtime has none. */ private val submitter: TurnSubmitter?, ) : Lifecycle { @@ -91,7 +96,13 @@ internal class OneBotInbox( private suspend fun read() { link.events.collect { event -> - if (event !is OneBotEvent.Message) return@collect + // 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. 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/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/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/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") +} From 0a9587eceb43a92ee3ff0e4d7ccb87f2572bcd01 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 19:15:43 +0800 Subject: [PATCH 15/36] onebot: Let the agent act on a chat of the platform The channel could report and answer, and the whole action surface of the standard was offered to other plugins as an instance API, but nothing called it: the API belongs to one channel instance and a tool is one object for the whole runtime, so a tool had no way to reach the API of the chat its turn is in. An instance is now served from the registry while it runs, which is where a tool reads the API of the chat of its turn. The registry is written by the onebot plugin itself, so nothing about it reaches the SDK or the agent: a tool of this plugin calls its own API, and the agent learns of the tool the way it learns of any other. Three tools act on a chat: taking a message back, adding an emoji response, and answering a friend request or a request to join a group, which is what a turn that was told about one can now do something with. Each names the risk of the action, so the permission layer gates them like any other call. --- .../channel/onebot/channel/OneBotApis.kt | 34 ++++++++++ .../channel/onebot/channel/OneBotInbox.kt | 9 +++ .../channel/onebot/tools/AnswerRequestTool.kt | 64 +++++++++++++++++++ .../channel/onebot/tools/OneBotToolSupport.kt | 53 +++++++++++++++ .../onebot/tools/ReactToMessageTool.kt | 46 +++++++++++++ .../channel/onebot/tools/RecallMessageTool.kt | 36 +++++++++++ 6 files changed, 242 insertions(+) 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/tools/AnswerRequestTool.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/ReactToMessageTool.kt create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/RecallMessageTool.kt 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/OneBotInbox.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotInbox.kt index bbf6b9d3..6b76e7e2 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 @@ -4,6 +4,7 @@ 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 @@ -72,12 +73,19 @@ internal class OneBotInbox( 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", @@ -92,6 +100,7 @@ internal class OneBotInbox( override suspend fun onClose() { reading?.cancel() reading = null + apis.forget(instance.id) } private suspend fun read() { diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/AnswerRequestTool.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/AnswerRequestTool.kt new file mode 100644 index 00000000..11c3a5bd --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/AnswerRequestTool.kt @@ -0,0 +1,64 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetFriendAddRequest +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAddRequest +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 + +/** + * Answers a request the bot was told about: to be a friend, or to join a group. + * + * The flag is the one the request reported, so a request can only be answered by a turn that saw it. A group request + * also names how it came, `add` for one asking to join and `invite` for one inviting the bot into a group. + */ +@Singleton +@Contribute(Tool::class) +internal class AnswerRequestTool(private val apis: OneBotApis) : Tool { + override val definition: ToolDefinition = ToolDefinition( + name = "onebot.answer_request", + description = "Answers a QQ friend request or a request to join a group, accepting or refusing it. The flag " + + "must be the one the request reported, which this bot was told about.", + parameters = arguments( + "flag" to "The flag the request reported.", + "kind" to "Either friend or group.", + "sub_type" to "For a group request, add when somebody asks to join and invite when the bot is invited.", + "approve" to "yes to accept the request, no to refuse it.", + "message" to "A remark to keep with a friend the call accepts, or the reason to give for a refusal.", + ), + risk = ToolRisk.EXTERNAL_ACTION, + ) + + override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult { + val flag = arguments.text("flag") ?: return ToolResult("A call needs the flag, as flag.", isError = true) + val approve = arguments.text("approve")?.let { it.equals("yes", true) || it.equals("true", true) } + ?: return ToolResult("A call needs whether to accept, as approve: yes or no.", isError = true) + val message = arguments.text("message").orEmpty() + val api = apis.serving(context.turn.chat) + return when (arguments.text("kind")?.lowercase()) { + "friend" -> ToolResult( + api.setFriendAddRequest(SetFriendAddRequest(flag, approve, message)) + .outcome(if (approve) "The friend request was accepted." else "The friend request was refused."), + ) + + "group" -> { + val subtype = arguments.text("sub_type")?.lowercase() + if (subtype != SetGroupAddRequest.ADD && subtype != SetGroupAddRequest.INVITE) { + return ToolResult("A group request names how it came, as sub_type: add or invite.", isError = true) + } + ToolResult( + api.setGroupAddRequest(SetGroupAddRequest(flag, subtype, approve, message)) + .outcome(if (approve) "The request was accepted." else "The request was refused."), + ) + } + + else -> ToolResult("A call names the kind of request, as kind: friend or group.", isError = true) + } + } +} 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..92917861 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolSupport.kt @@ -0,0 +1,53 @@ +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.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +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}.") + +/** What [what] says when the call was taken, and what the implementation said when it was not. */ +internal fun OneBotResult<*>.outcome(what: String): String = when (this) { + is OneBotResult.Ok -> 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." +} + +/** 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) +} + +/** The schema of a call whose arguments are [properties], every one of them required. */ +internal fun arguments(vararg properties: Pair): JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + properties.forEach { (name, description) -> + putJsonObject(name) { + put("type", "string") + put("description", description) + } + } + } + putJsonArray("required") { properties.forEach { add(JsonPrimitive(it.first)) } } + put("additionalProperties", false) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/ReactToMessageTool.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/ReactToMessageTool.kt new file mode 100644 index 00000000..e75957ac --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/ReactToMessageTool.kt @@ -0,0 +1,46 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis +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 + +/** + * Adds an emoji response to a message. + * + * The response is an action an implementation may have and the standard does not, so it is called by its name and an + * implementation without it answers that the action is unknown, which the tool says rather than hides. + */ +@Singleton +@Contribute(Tool::class) +internal class ReactToMessageTool(private val apis: OneBotApis) : Tool { + override val definition: ToolDefinition = ToolDefinition( + name = "onebot.react_to_message", + description = "Adds a QQ emoji response to a message of a chat this bot acts in, such as a thumbs up. The " + + "implementation must offer the action; a number names the emoji.", + parameters = arguments( + "message_id" to "The id of the message to respond to.", + "emoji_id" to "The number of the emoji to add, such as 128077 for a thumbs up.", + ), + risk = ToolRisk.EXTERNAL_ACTION, + ) + + override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult { + val id = arguments.text("message_id") + ?: return ToolResult("A call needs the id of the message, as message_id.", isError = true) + val emoji = arguments.text("emoji_id") + ?: return ToolResult("A call needs the number of the emoji, as emoji_id.", isError = true) + val api = apis.serving(context.turn.chat) + val params = buildJsonObject { + putId("message_id", id) + putId("emoji_id", emoji) + } + return ToolResult(api.raw("set_msg_emoji_like", params).outcome("The emoji response was added.")) + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/RecallMessageTool.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/RecallMessageTool.kt new file mode 100644 index 00000000..c9398acf --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/RecallMessageTool.kt @@ -0,0 +1,36 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.DeleteMessage +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 + +/** Takes back a message of a chat this bot acts in. */ +@Singleton +@Contribute(Tool::class) +internal class RecallMessageTool(private val apis: OneBotApis) : Tool { + override val definition: ToolDefinition = ToolDefinition( + name = "onebot.recall_message", + description = + "Takes back a message in a QQ chat this bot acts in. Only what the platform allows can be taken " + + "back: usually the bot's own messages, and anyone's within a short time of sending them.", + parameters = arguments( + "message_id" to "The id of the message to take back, as a reported or quoted message named it.", + ), + risk = ToolRisk.EXTERNAL_ACTION, + ) + + override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult { + val id = arguments.text("message_id") + ?: return ToolResult("A call needs the id of the message, as message_id.", isError = true) + val api = apis.serving(context.turn.chat) + return ToolResult(api.deleteMsg(DeleteMessage(MessageId(id))).outcome("The message was taken back.")) + } +} From 64d2fc32d36d187168023bb00fbc441f3080a7ca Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 19:49:24 +0800 Subject: [PATCH 16/36] plugin-sdk, agent, onebot: Let a tool act on the message its turn is about A tool that acts on what a person just wrote needs the id of that message, and a prompt names no id: the framing gives the sender, the time, the chat and what was quoted, but never the id of the message it answers. A tool that demanded one could therefore only be answered by a person who went and found it, which is what the onebot tools for taking a message back and adding an emoji response asked for. The context of a call now carries the message its turn was started by. The agent already held it, as the same value its reply is threaded onto, so nothing new is derived. A turn that no message started, such as a heartbeat, carries none, and a tool that needs one then says so as before. The field has a default, so a channel or plugin that implements the context keeps compiling, and every channel gets it without doing anything. The tools of a channel also stop marking every argument required. A schema that says an argument must be given stops a caller from leaving it out, which is the opposite of what a tool that can work without one needs: the id of a message is now optional and says in its description what it defaults to, and the arguments a call cannot do without say that they are required and are checked where they are read. This is how the other tools of this project declare theirs. --- .../alexandrite/agent/tool/AgentToolContext.kt | 5 +++++ .../alexandrite/agent/tool/ToolExecutor.kt | 7 +++++-- .../alexandrite/agent/turn/AgentTurnRunner.kt | 11 ++++++++++- .../agent/permission/PermissionStatusToolTest.kt | 2 +- .../alexandrite/agent/tool/ToolExecutorTest.kt | 2 +- .../channel/onebot/tools/AnswerRequestTool.kt | 12 +++++++----- .../channel/onebot/tools/OneBotToolSupport.kt | 9 +++++++-- .../channel/onebot/tools/ReactToMessageTool.kt | 8 +++++--- .../channel/onebot/tools/RecallMessageTool.kt | 7 +++++-- .../org/foedusprogramme/alexandrite/sdk/tool/Tool.kt | 10 ++++++++++ 10 files changed, 56 insertions(+), 17 deletions(-) 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 98fd69e8..37273de0 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 @@ -4,6 +4,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext 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.permission.Clearance @@ -18,6 +19,7 @@ internal class AgentToolContext( private val workspaces: Workspaces, clearance: Clearance?, val taint: TurnTaint, + trigger: ChannelMessageRef?, ) : ToolContext { @Volatile private var open = true @@ -36,6 +38,9 @@ internal class AgentToolContext( override val clearance: Clearance? = clearance get() = field.also { checkOpen() } + override val trigger: ChannelMessageRef? = trigger + get() = field.also { checkOpen() } + override suspend fun openWorkspace(): Path { val agent = turn.agent val path = withContext(Dispatchers.IO) { workspaces.open(agent) } 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 aeddaac2..bd836e1b 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 @@ -16,6 +16,7 @@ import org.foedusprogramme.alexandrite.agent.config.AgentSettings import org.foedusprogramme.alexandrite.agent.permission.TurnTaint import org.foedusprogramme.alexandrite.agent.prompt.Escaper import org.foedusprogramme.alexandrite.agent.workspace.Workspaces +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.Tool @@ -65,6 +66,8 @@ internal class ToolExecutor(private val gate: ToolGate, settings: AgentSettings, callable: Set, taint: TurnTaint, calls: TurnCalls, + /** The message the turn was started by, which a tool that acts on it reads from its context. */ + trigger: ChannelMessageRef?, ): ToolCallEnd { val tool = tools[call.name] ?: return ended(call, "No tool named '${call.name}' is offered.", NotRunReason.UNKNOWN_TOOL) @@ -74,7 +77,7 @@ internal class ToolExecutor(private val gate: ToolGate, settings: AgentSettings, val arguments = call.parseArguments() ?: return ended(call, "The arguments are not a JSON object.", NotRunReason.INVALID_ARGUMENTS) val limit = tool.definition.timeout?.let { maxOf(timeout, it + DECLARED_TIMEOUT_GRACE) } ?: timeout - val reviewing = AgentToolContext(turn, call.id, workspaces, null, taint) + val reviewing = AgentToolContext(turn, call.id, workspaces, null, taint, trigger) val admission = try { gate.admit(GateCall(turn, call, tool, arguments, limit, taint, reviewing, calls)) } finally { @@ -85,7 +88,7 @@ internal class ToolExecutor(private val gate: ToolGate, settings: AgentSettings, is GateResult.Admitted -> { calls.starting(call.id) - val context = AgentToolContext(turn, call.id, workspaces, admission.clearance, taint) + val context = AgentToolContext(turn, call.id, workspaces, admission.clearance, taint, trigger) ToolCallEnd(runTool(turn, call, tool, arguments, limit, context), null, context.touchedPaths) } } 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 2bafc71f..5c01caf3 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 @@ -482,7 +482,16 @@ internal class AgentTurnRunner( launch { slots.withPermit { val end = - executor.execute(turn, call, tools.offered, timeout, tools.callable, taint, tracked) + executor.execute( + turn, + call, + tools.offered, + timeout, + tools.callable, + taint, + tracked, + plan.trigger, + ) reads.reason(turn.agent, 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/permission/PermissionStatusToolTest.kt b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/permission/PermissionStatusToolTest.kt index 7bb0fd22..ed32b800 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 @@ -77,7 +77,7 @@ class PermissionStatusToolTest { } private fun context(turn: TurnInfo, taint: TurnTaint): ToolContext = - AgentToolContext(turn, ToolCallId("call-1"), Workspaces(hostPaths(null), settings("{}")), null, taint) + AgentToolContext(turn, ToolCallId("call-1"), Workspaces(hostPaths(null), settings("{}")), null, taint, null) private suspend fun status( tool: PermissionStatusTool = tool(), 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 f5cdfb30..cd5e8e17 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 @@ -102,7 +102,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 } diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/AnswerRequestTool.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/AnswerRequestTool.kt index 11c3a5bd..1f5e5ccf 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/AnswerRequestTool.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/AnswerRequestTool.kt @@ -26,11 +26,13 @@ internal class AnswerRequestTool(private val apis: OneBotApis) : Tool { description = "Answers a QQ friend request or a request to join a group, accepting or refusing it. The flag " + "must be the one the request reported, which this bot was told about.", parameters = arguments( - "flag" to "The flag the request reported.", - "kind" to "Either friend or group.", - "sub_type" to "For a group request, add when somebody asks to join and invite when the bot is invited.", - "approve" to "yes to accept the request, no to refuse it.", - "message" to "A remark to keep with a friend the call accepts, or the reason to give for a refusal.", + "flag" to "The flag the request reported. Required.", + "kind" to "Either friend or group. Required.", + "sub_type" to + "For a group request: add when somebody asks to join, invite when the bot is invited. Required for a group request.", + "approve" to "yes to accept the request, no to refuse it. Required.", + "message" to + "A remark to keep with a friend the call accepts, or the reason to give for a refusal. Optional.", ), risk = ToolRisk.EXTERNAL_ACTION, ) 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 index 92917861..2d0eece0 100644 --- 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 @@ -37,7 +37,13 @@ internal fun JsonObjectBuilder.putId(name: String, value: String) { if (number == null) put(name, JsonPrimitive(value)) else put(name, number) } -/** The schema of a call whose arguments are [properties], every one of them required. */ +/** + * The schema of a call whose arguments are [properties], named with what each one is for. + * + * An argument that must be given says so in its description and is checked where it is read, which is how the + * other tools of this project declare theirs: a schema that marks one required stops a caller from leaving it out, + * which is exactly what a tool that can work without it must allow. + */ internal fun arguments(vararg properties: Pair): JsonObject = buildJsonObject { put("type", "object") putJsonObject("properties") { @@ -48,6 +54,5 @@ internal fun arguments(vararg properties: Pair): JsonObject = bu } } } - putJsonArray("required") { properties.forEach { add(JsonPrimitive(it.first)) } } put("additionalProperties", false) } diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/ReactToMessageTool.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/ReactToMessageTool.kt index e75957ac..536d9e73 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/ReactToMessageTool.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/ReactToMessageTool.kt @@ -25,14 +25,16 @@ internal class ReactToMessageTool(private val apis: OneBotApis) : Tool { description = "Adds a QQ emoji response to a message of a chat this bot acts in, such as a thumbs up. The " + "implementation must offer the action; a number names the emoji.", parameters = arguments( - "message_id" to "The id of the message to respond to.", - "emoji_id" to "The number of the emoji to add, such as 128077 for a thumbs up.", + "message_id" to "The id of the message to respond to. Default: the message this turn is about.", + "emoji_id" to "The number of the emoji to add, such as 128077 for a thumbs up. Required.", ), risk = ToolRisk.EXTERNAL_ACTION, ) override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult { - val id = arguments.text("message_id") + // The message this turn is about is what "this message" means, and its id is the one thing a prompt does not + // name. A turn that no message started has none, so such a call must say which message it means. + val id = arguments.text("message_id") ?: context.trigger?.id ?: return ToolResult("A call needs the id of the message, as message_id.", isError = true) val emoji = arguments.text("emoji_id") ?: return ToolResult("A call needs the number of the emoji, as emoji_id.", isError = true) diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/RecallMessageTool.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/RecallMessageTool.kt index c9398acf..677c5ebf 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/RecallMessageTool.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/RecallMessageTool.kt @@ -22,13 +22,16 @@ internal class RecallMessageTool(private val apis: OneBotApis) : Tool { "Takes back a message in a QQ chat this bot acts in. Only what the platform allows can be taken " + "back: usually the bot's own messages, and anyone's within a short time of sending them.", parameters = arguments( - "message_id" to "The id of the message to take back, as a reported or quoted message named it.", + "message_id" to + "The id of the message to take back. Default: the message this turn is about.", ), risk = ToolRisk.EXTERNAL_ACTION, ) override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult { - val id = arguments.text("message_id") + // The message this turn is about is what a call about the message just written means, and its id is the one + // thing a prompt does not name. A turn that no message started has none, so such a call must say which. + val id = arguments.text("message_id") ?: context.trigger?.id ?: return ToolResult("A call needs the id of the message, as message_id.", isError = true) val api = apis.serving(context.turn.chat) return ToolResult(api.deleteMsg(DeleteMessage(MessageId(id))).outcome("The message was taken back.")) 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 150f711a..30667a5b 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 @@ -66,6 +67,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) } From ce48f1d1a4ee2285b168169f8e9665d6efb3e767 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 19:49:24 +0800 Subject: [PATCH 17/36] agent: Name the message a turn answers, and the one it quotes A prompt said who wrote a message, when, where, and what it answered, but never the id of the message itself. So a person could not be told the id of what they sent, and a tool that acts on a message could only be given one by somebody who went and found it: the framing did not carry the one thing such a call needs. Message framing now names the id of the message, and the id of the message a message answers, which is reported as an id and nothing else. Both are escaped like every other name the framing shows. The ids are what the channel reports, so nothing is derived and no channel does anything: every channel that reports a message gets both. The golden files of the format are updated with the line, and the two of them are written with the line endings the format is compared by. --- .../alexandrite/agent/prompt/MessageFraming.kt | 8 +++++++- .../agent/src/test/resources/golden/message.v1.direct.txt | 3 ++- .../agent/src/test/resources/golden/message.v1.full.txt | 3 ++- 3 files changed, 11 insertions(+), 3 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 6d7b8dce..070c9e57 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 @@ -49,6 +49,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) @@ -56,7 +59,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/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 f656a5e33a68fad2f88d8daee4a0d32c06f5f3e6 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 20:33:08 +0800 Subject: [PATCH 18/36] onebot: Declare the platform's actions once, and serve them with one tool Every action of the platform that the agent may be asked for is one row of a table now: what it is for, which arguments it takes, what they mean and whether it changes something. Two tools read that table, one for the actions that only read and one for the actions that change, so an action is added by adding a row rather than by writing a tool. The actions that hand out the account's credentials, that restart the implementation and that carry out anything at all by name are therefore not reachable, since a call can only name a row. A call cannot put an argument of its own into a request either: only the arguments a row declares are sent. Three tools that each served one action are gone, their actions are rows now, and an action about a message still means the message of the turn where the row allows the argument to be left out. --- .../channel/onebot/tools/AnswerRequestTool.kt | 66 ------- .../channel/onebot/tools/OneBotActionTool.kt | 135 ++++++++++++++ .../channel/onebot/tools/OneBotActions.kt | 166 ++++++++++++++++++ .../channel/onebot/tools/OneBotToolSupport.kt | 23 --- .../onebot/tools/ReactToMessageTool.kt | 48 ----- .../channel/onebot/tools/RecallMessageTool.kt | 39 ---- 6 files changed, 301 insertions(+), 176 deletions(-) delete mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/AnswerRequestTool.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 delete mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/ReactToMessageTool.kt delete mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/RecallMessageTool.kt diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/AnswerRequestTool.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/AnswerRequestTool.kt deleted file mode 100644 index 1f5e5ccf..00000000 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/AnswerRequestTool.kt +++ /dev/null @@ -1,66 +0,0 @@ -package org.foedusprogramme.alexandrite.channel.onebot.tools - -import kotlinx.serialization.json.JsonObject -import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis -import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetFriendAddRequest -import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAddRequest -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 - -/** - * Answers a request the bot was told about: to be a friend, or to join a group. - * - * The flag is the one the request reported, so a request can only be answered by a turn that saw it. A group request - * also names how it came, `add` for one asking to join and `invite` for one inviting the bot into a group. - */ -@Singleton -@Contribute(Tool::class) -internal class AnswerRequestTool(private val apis: OneBotApis) : Tool { - override val definition: ToolDefinition = ToolDefinition( - name = "onebot.answer_request", - description = "Answers a QQ friend request or a request to join a group, accepting or refusing it. The flag " + - "must be the one the request reported, which this bot was told about.", - parameters = arguments( - "flag" to "The flag the request reported. Required.", - "kind" to "Either friend or group. Required.", - "sub_type" to - "For a group request: add when somebody asks to join, invite when the bot is invited. Required for a group request.", - "approve" to "yes to accept the request, no to refuse it. Required.", - "message" to - "A remark to keep with a friend the call accepts, or the reason to give for a refusal. Optional.", - ), - risk = ToolRisk.EXTERNAL_ACTION, - ) - - override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult { - val flag = arguments.text("flag") ?: return ToolResult("A call needs the flag, as flag.", isError = true) - val approve = arguments.text("approve")?.let { it.equals("yes", true) || it.equals("true", true) } - ?: return ToolResult("A call needs whether to accept, as approve: yes or no.", isError = true) - val message = arguments.text("message").orEmpty() - val api = apis.serving(context.turn.chat) - return when (arguments.text("kind")?.lowercase()) { - "friend" -> ToolResult( - api.setFriendAddRequest(SetFriendAddRequest(flag, approve, message)) - .outcome(if (approve) "The friend request was accepted." else "The friend request was refused."), - ) - - "group" -> { - val subtype = arguments.text("sub_type")?.lowercase() - if (subtype != SetGroupAddRequest.ADD && subtype != SetGroupAddRequest.INVITE) { - return ToolResult("A group request names how it came, as sub_type: add or invite.", isError = true) - } - ToolResult( - api.setGroupAddRequest(SetGroupAddRequest(flag, subtype, approve, message)) - .outcome(if (approve) "The request was accepted." else "The request was refused."), - ) - } - - else -> ToolResult("A call names the kind of request, as kind: friend or group.", isError = true) - } - } -} 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..d466a0c6 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionTool.kt @@ -0,0 +1,135 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis +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 + +/** + * 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 the 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) : Tool { + override val definition: ToolDefinition = ToolDefinition( + name = "onebot.read", + description = describe("Reads what the QQ platform knows", OneBotActions.rows.filter { !it.changes }), + parameters = schema(), + risk = ToolRisk.READ_ONLY, + ) + + override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult = + call(apis, OneBotActions.rows.filter { !it.changes }, 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) : Tool { + override val definition: ToolDefinition = ToolDefinition( + name = "onebot.act", + description = describe( + "Changes something of a QQ chat this bot acts in", + OneBotActions.rows.filter { + it.changes + }, + ), + parameters = schema(), + risk = ToolRisk.EXTERNAL_ACTION, + ) + + override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult = + call(apis, OneBotActions.rows.filter { it.changes }, 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 listed in the description.") + } + putJsonObject("params") { + put("type", "object") + put("description", "The arguments of that action, by the names the description gives them.") + put("additionalProperties", true) + } + } + put("required", kotlinx.serialization.json.JsonArray(listOf(kotlinx.serialization.json.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:\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 [action] of [rows] in the chat of [context], and says what the platform answered. + * + * Only the arguments the row declares are sent, so a call cannot put anything of its own into the request. An + * argument that was left out falls back to the message of this turn where the row allows it, and is refused + * otherwise, because a prompt does not name that id. + */ +private suspend fun call( + apis: OneBotApis, + rows: List, + 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 = buildJsonObject { + row.parameters.forEach { parameter -> + val value = given[parameter.name] + if (value == null || value is JsonNull) { + val message = parameter.name == OneBotActions.MESSAGE_ID && parameter.optional + val fallback = if (message) context.trigger?.id else null + when { + fallback != null -> putId(parameter.name, fallback) + + parameter.required -> return ToolResult( + "The action '$name' needs ${parameter.name}: ${parameter.description}", + isError = true, + ) + } + } else { + put(parameter.name, value) + } + } + } + val api = apis.serving(context.turn.chat) + return ToolResult(api.raw(name, params).outcome(row.said)) +} 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..0411c9a3 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActions.kt @@ -0,0 +1,166 @@ +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 risky it is. 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. + */ +internal object OneBotActions { + /** + * 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 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 changing: List = listOf( + Row( + name = "delete_msg", + said = "The message was taken back.", + parameters = listOf( + Parameter( + MESSAGE_ID, + "The message to take back. Default: the message this turn is about.", + required = false, + ), + ), + ), + Row( + name = "set_msg_emoji_like", + said = "The emoji response was added.", + parameters = listOf( + Parameter( + MESSAGE_ID, + "The message to respond to. Default: the message this turn is about.", + required = false, + ), + Parameter("emoji_id", "The number of the emoji, such as 128077 for a thumbs up."), + ), + extension = true, + ), + 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: true or false."), + 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: true or false."), + Parameter("reason", "The reason to give for a request the call refuses.", required = false), + ), + ), + Row( + name = "set_group_ban", + said = "The mute was set.", + parameters = listOf( + Parameter("group_id", "The group."), + Parameter("user_id", "The member to mute."), + Parameter("duration", "How many seconds the mute lasts, 0 to lift it."), + ), + ), + Row( + name = "set_group_kick", + said = "The member was removed from the group.", + parameters = listOf( + Parameter("group_id", "The group."), + Parameter("user_id", "The member to remove."), + Parameter("reject_add_request", "Whether to refuse their requests to join again.", required = false), + ), + ), + Row( + name = "set_group_special_title", + said = "The title was set.", + parameters = listOf( + Parameter("group_id", "The group."), + Parameter("user_id", "The member."), + Parameter("special_title", "The title to give them."), + ), + ), + ) + + private val reading: List = listOf( + Row( + name = "get_msg", + said = "The message was read.", + parameters = listOf( + Parameter( + MESSAGE_ID, + "The message to read. Default: the message this turn is about.", + required = false, + ), + ), + ), + Row( + name = "get_group_member_info", + said = "The member was read.", + parameters = listOf( + Parameter("group_id", "The group."), + Parameter("user_id", "The member to read."), + Parameter( + "no_cache", + "Whether to read the member afresh rather than from the cache.", + required = false, + ), + ), + ), + ) + + /** Everything this bot may be asked for, the reads first. */ + val rows: List = reading + 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 index 2d0eece0..c18f1f9e 100644 --- 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 @@ -3,12 +3,9 @@ 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.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonArray -import kotlinx.serialization.json.putJsonObject 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 @@ -36,23 +33,3 @@ internal fun JsonObjectBuilder.putId(name: String, value: String) { val number = value.toLongOrNull() if (number == null) put(name, JsonPrimitive(value)) else put(name, number) } - -/** - * The schema of a call whose arguments are [properties], named with what each one is for. - * - * An argument that must be given says so in its description and is checked where it is read, which is how the - * other tools of this project declare theirs: a schema that marks one required stops a caller from leaving it out, - * which is exactly what a tool that can work without it must allow. - */ -internal fun arguments(vararg properties: Pair): JsonObject = buildJsonObject { - put("type", "object") - putJsonObject("properties") { - properties.forEach { (name, description) -> - putJsonObject(name) { - put("type", "string") - put("description", description) - } - } - } - put("additionalProperties", false) -} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/ReactToMessageTool.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/ReactToMessageTool.kt deleted file mode 100644 index 536d9e73..00000000 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/ReactToMessageTool.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.foedusprogramme.alexandrite.channel.onebot.tools - -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis -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 - -/** - * Adds an emoji response to a message. - * - * The response is an action an implementation may have and the standard does not, so it is called by its name and an - * implementation without it answers that the action is unknown, which the tool says rather than hides. - */ -@Singleton -@Contribute(Tool::class) -internal class ReactToMessageTool(private val apis: OneBotApis) : Tool { - override val definition: ToolDefinition = ToolDefinition( - name = "onebot.react_to_message", - description = "Adds a QQ emoji response to a message of a chat this bot acts in, such as a thumbs up. The " + - "implementation must offer the action; a number names the emoji.", - parameters = arguments( - "message_id" to "The id of the message to respond to. Default: the message this turn is about.", - "emoji_id" to "The number of the emoji to add, such as 128077 for a thumbs up. Required.", - ), - risk = ToolRisk.EXTERNAL_ACTION, - ) - - override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult { - // The message this turn is about is what "this message" means, and its id is the one thing a prompt does not - // name. A turn that no message started has none, so such a call must say which message it means. - val id = arguments.text("message_id") ?: context.trigger?.id - ?: return ToolResult("A call needs the id of the message, as message_id.", isError = true) - val emoji = arguments.text("emoji_id") - ?: return ToolResult("A call needs the number of the emoji, as emoji_id.", isError = true) - val api = apis.serving(context.turn.chat) - val params = buildJsonObject { - putId("message_id", id) - putId("emoji_id", emoji) - } - return ToolResult(api.raw("set_msg_emoji_like", params).outcome("The emoji response was added.")) - } -} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/RecallMessageTool.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/RecallMessageTool.kt deleted file mode 100644 index 677c5ebf..00000000 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/RecallMessageTool.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.foedusprogramme.alexandrite.channel.onebot.tools - -import kotlinx.serialization.json.JsonObject -import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis -import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId -import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.DeleteMessage -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 - -/** Takes back a message of a chat this bot acts in. */ -@Singleton -@Contribute(Tool::class) -internal class RecallMessageTool(private val apis: OneBotApis) : Tool { - override val definition: ToolDefinition = ToolDefinition( - name = "onebot.recall_message", - description = - "Takes back a message in a QQ chat this bot acts in. Only what the platform allows can be taken " + - "back: usually the bot's own messages, and anyone's within a short time of sending them.", - parameters = arguments( - "message_id" to - "The id of the message to take back. Default: the message this turn is about.", - ), - risk = ToolRisk.EXTERNAL_ACTION, - ) - - override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult { - // The message this turn is about is what a call about the message just written means, and its id is the one - // thing a prompt does not name. A turn that no message started has none, so such a call must say which. - val id = arguments.text("message_id") ?: context.trigger?.id - ?: return ToolResult("A call needs the id of the message, as message_id.", isError = true) - val api = apis.serving(context.turn.chat) - return ToolResult(api.deleteMsg(DeleteMessage(MessageId(id))).outcome("The message was taken back.")) - } -} From 18e772f5fb2a498d2c1151bf207fe1e4e7b2e510 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 20:34:49 +0800 Subject: [PATCH 19/36] onebot: Say what the table of actions holds and what it keeps out of reach The table is the only thing between a call and the platform, so what it holds is asserted rather than trusted: every action it holds is one the standard declares, unless the row says the standard does not have it, and no action is declared twice. The actions that must not be reachable are named in the test that they are not, which is what keeps them out: nothing of the account, nothing that carries out anything by name, nothing that restarts the implementation. An action about a message is asserted to let its id be left out, and every argument of every action is asserted to be named and explained. --- .../channel/onebot/tools/OneBotActionsTest.kt | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionsTest.kt 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..5ccaa400 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionsTest.kt @@ -0,0 +1,105 @@ +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("")) + } +} From 307213ba0a4498141d9d4d14d34f1fe63610e39b Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 20:37:37 +0800 Subject: [PATCH 20/36] onebot: Send what a call gave as the standard asks, and refuse what cannot be sent An argument that names a message, a group, a member or a length is one the standard writes as a number, and a model writes every argument as text. A call that gave one was sent as text while one left to the message of the turn was sent as a number, so the same argument reached the platform in two forms. An argument the table says is numeric is written as a number either way. An action about a message takes the message of this turn when a call named none. A call that named none on a turn no message started was sent without the argument at all, which the platform answers in a way that says nothing about what the call got wrong. It is refused now, naming the argument and what it is for. What a call sends is built by a function of the row, the arguments and the message of the turn, which is where the tests assert it. They say that the arguments a row declares are the ones sent, so nothing a call writes can reach the platform by a name the action does not take, and that an argument left out where the row does not allow it is refused rather than sent as nothing. --- .../channel/onebot/tools/OneBotActionTool.kt | 63 ++++++++--- .../channel/onebot/tools/OneBotActions.kt | 9 ++ .../channel/onebot/tools/OneBotPrepareTest.kt | 106 ++++++++++++++++++ 3 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotPrepareTest.kt 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 index d466a0c6..a5fdd9ae 100644 --- 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 @@ -2,7 +2,9 @@ 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 kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis @@ -111,25 +113,56 @@ private suspend fun call( 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 + } + val api = apis.serving(context.turn.chat) + return ToolResult(api.raw(name, params).outcome(row.said)) +} + +/** 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. + * + * Only the arguments the row declares are sent, so nothing a call writes under `params` can reach the platform + * unless the action takes it by that name. An argument that must be given and was not is refused, naming it and + * what it is for, rather than sending a request the platform would refuse for a reason the model cannot act on. + */ +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) { - val message = parameter.name == OneBotActions.MESSAGE_ID && parameter.optional - val fallback = if (message) context.trigger?.id else null - when { - fallback != null -> putId(parameter.name, fallback) - - parameter.required -> return ToolResult( - "The action '$name' needs ${parameter.name}: ${parameter.description}", - isError = true, - ) - } - } else { - put(parameter.name, value) + if (value != null && value !is JsonNull) { + val text = (value as? JsonPrimitive)?.contentOrNull + if (parameter.numeric && text != null) putId(parameter.name, text) else put(parameter.name, value) + 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, + // which the platform would answer in a way that says nothing about what the call got wrong. + if (parameter.name == OneBotActions.MESSAGE_ID && parameter.optional) { + val fallback = messageId ?: return Prepared.Refused( + "The action '${row.name}' needs ${parameter.name}: ${parameter.description}", + ) + putId(parameter.name, fallback) + return@forEach + } + if (parameter.required) { + return Prepared.Refused( + "The action '${row.name}' needs ${parameter.name}: ${parameter.description}", + ) } } } - val api = apis.serving(context.turn.chat) - return ToolResult(api.raw(name, params).outcome(row.said)) + return Prepared.Ready(params) } 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 index 0411c9a3..e6843d50 100644 --- 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 @@ -164,3 +164,12 @@ internal object OneBotActions { /** The row of [name], null when this bot may not be asked for it. */ fun of(name: String): Row? = byName[name] } + +/** + * Whether this argument is one the standard writes as a number rather than as text. + * + * A model writes every argument as text, so one that names a message, a group, a member or a length has to be written + * as the number the standard asks for, while one that names a title, a flag or a reason stays the text it was given. + */ +internal val OneBotActions.Parameter.numeric: Boolean + get() = name.endsWith("_id") || name == "duration" 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..b3a2bd7d --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotPrepareTest.kt @@ -0,0 +1,106 @@ +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) + } + + /** 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) + } +} From 2016b378b8df6aa80290f9f1adbf54064bf59ae3 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 20:40:24 +0800 Subject: [PATCH 21/36] onebot: Write every argument as the standard asks, and hold the actions that read and change An argument that asks whether to do something was sent as the text a call wrote, and the same was true of every other argument. A row now says how each of its arguments is written: a name, a title or a reason stays the text it was, a message, a group, a member or a length is the number it names, and a question is the yes or no it asks. What a call writes as yes, no, true, false, 1 or 0 is read as the flag it means, and anything else is refused, naming the argument and what it is for, rather than sent as something the platform would answer without saying what the call got wrong. The table holds twenty-two actions instead of nine. What it reads: a message, a forwarded message, an account, a group, its members, its honors. What it changes: taking a message back, an emoji response, a like, a friend or a join request answered, a member kicked, muted, made an administrator or renamed in the group, a group muted whole, renamed or left, and a title given. What it keeps out of reach is unchanged: nothing of the account's credentials, nothing that carries out anything by name, nothing that restarts the implementation. The arguments of every row are the names the platform's own requests are written with, read from them rather than guessed, and the tests say that the arguments a row declares are the ones sent, so nothing a call writes can reach the platform by a name an action does not take. --- .../channel/onebot/tools/OneBotActionTool.kt | 82 ++++++-- .../channel/onebot/tools/OneBotActions.kt | 199 ++++++++++++------ .../channel/onebot/tools/OneBotPrepareTest.kt | 27 +++ 3 files changed, 225 insertions(+), 83 deletions(-) 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 index a5fdd9ae..8b418afc 100644 --- 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 @@ -1,8 +1,11 @@ package org.foedusprogramme.alexandrite.channel.onebot.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.booleanOrNull import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.put @@ -21,21 +24,21 @@ import org.foedusprogramme.alexandrite.sdk.tool.ToolRisk * * 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 the 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. + * 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) : Tool { override val definition: ToolDefinition = ToolDefinition( name = "onebot.read", - description = describe("Reads what the QQ platform knows", OneBotActions.rows.filter { !it.changes }), + 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.filter { !it.changes }, arguments, context) + call(apis, OneBotActions.rows.filterNot { it.changes }, arguments, context) } /** Changes something of a chat or a member of the platform this bot acts in. */ @@ -46,9 +49,7 @@ internal class OneBotActTool(private val apis: OneBotApis) : Tool { name = "onebot.act", description = describe( "Changes something of a QQ chat this bot acts in", - OneBotActions.rows.filter { - it.changes - }, + OneBotActions.rows.filter { it.changes }, ), parameters = schema(), risk = ToolRisk.EXTERNAL_ACTION, @@ -64,15 +65,15 @@ private fun schema(): JsonObject = buildJsonObject { putJsonObject("properties") { putJsonObject("action") { put("type", "string") - put("description", "The action to call, named as one of the actions listed in the description.") + put("description", "The action to call, named as one of the actions the description lists.") } putJsonObject("params") { put("type", "object") - put("description", "The arguments of that action, by the names the description gives them.") + put("description", "That action's arguments, by the names the description gives them.") put("additionalProperties", true) } } - put("required", kotlinx.serialization.json.JsonArray(listOf(kotlinx.serialization.json.JsonPrimitive("action")))) + put("required", JsonArray(listOf(JsonPrimitive("action")))) put("additionalProperties", false) } @@ -80,7 +81,7 @@ private fun schema(): JsonObject = buildJsonObject { 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:\n") + 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 "?") }) @@ -92,11 +93,10 @@ private fun describe(header: String, rows: List): String = bu } /** - * Calls [action] of [rows] in the chat of [context], and says what the platform answered. + * Calls [name] of [rows] in the chat of [context], and says what the platform answered. * - * Only the arguments the row declares are sent, so a call cannot put anything of its own into the request. An - * argument that was left out falls back to the message of this turn where the row allows it, and is refused - * otherwise, because a prompt does not name that id. + * 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, @@ -134,27 +134,40 @@ internal sealed interface 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. * - * Only the arguments the row declares are sent, so nothing a call writes under `params` can reach the platform - * unless the action takes it by that name. An argument that must be given and was not is refused, naming it and - * what it is for, rather than sending a request the platform would refuse for a reason the model cannot act on. + * 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) { - val text = (value as? JsonPrimitive)?.contentOrNull - if (parameter.numeric && text != null) putId(parameter.name, text) else put(parameter.name, value) + when (parameter.kind) { + OneBotActions.Kind.TEXT -> put(parameter.name, value) + + OneBotActions.Kind.NUMBER -> put(parameter.name, number(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, - // which the platform would answer in a way that says nothing about what the call got wrong. + // 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}", ) - putId(parameter.name, fallback) + put(parameter.name, number(JsonPrimitive(fallback))) return@forEach } if (parameter.required) { @@ -166,3 +179,26 @@ internal fun prepare(row: OneBotActions.Row, given: JsonObject, messageId: Strin } 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 +} 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 index e6843d50..9ab081e0 100644 --- 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 @@ -6,15 +6,29 @@ 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 risky it is. 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. + * 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, + } + /** * What an action is, and what calling it takes. * @@ -37,7 +51,12 @@ internal object OneBotActions { } /** One argument of an action. */ - internal class Parameter(val name: String, val description: String, val required: Boolean = true) { + 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 } @@ -45,37 +64,86 @@ internal object OneBotActions { /** 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."), + ), + ), + ) + private val changing: List = listOf( Row( name = "delete_msg", said = "The message was taken back.", - parameters = listOf( - Parameter( - MESSAGE_ID, - "The message to take back. Default: the message this turn is about.", - required = false, - ), - ), + parameters = listOf(message("The message to take back.")), ), Row( name = "set_msg_emoji_like", said = "The emoji response was added.", parameters = listOf( - Parameter( - MESSAGE_ID, - "The message to respond to. Default: the message this turn is about.", - required = false, - ), - Parameter("emoji_id", "The number of the emoji, such as 128077 for a thumbs up."), + 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: true or false."), + Parameter("approve", "Whether to accept the request.", Kind.FLAG), Parameter("remark", "The remark to keep with a friend the call accepts.", required = false), ), ), @@ -85,66 +153,86 @@ internal object OneBotActions { 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: true or false."), + 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."), - Parameter("user_id", "The member to mute."), - Parameter("duration", "How many seconds the mute lasts, 0 to lift it."), + 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_kick", - said = "The member was removed from the group.", + name = "set_group_whole_ban", + said = "The mute of the whole group was set.", parameters = listOf( - Parameter("group_id", "The group."), - Parameter("user_id", "The member to remove."), - Parameter("reject_add_request", "Whether to refuse their requests to join again.", required = false), + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("enable", "Whether every member is muted.", Kind.FLAG), ), ), Row( - name = "set_group_special_title", - said = "The title was set.", + name = "set_group_admin", + said = "Whether the member is an administrator was set.", parameters = listOf( - Parameter("group_id", "The group."), - Parameter("user_id", "The member."), - Parameter("special_title", "The title to give them."), + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("user_id", "The member.", Kind.NUMBER), + Parameter("enable", "Whether they administer the group.", Kind.FLAG), ), ), - ) - - private val reading: List = listOf( Row( - name = "get_msg", - said = "The message was read.", + name = "set_group_card", + said = "The name the member goes by in the group was set.", parameters = listOf( - Parameter( - MESSAGE_ID, - "The message to read. Default: the message this turn is about.", - required = false, - ), + 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 = "get_group_member_info", - said = "The member was read.", + 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."), - Parameter("user_id", "The member to read."), - Parameter( - "no_cache", - "Whether to read the member afresh rather than from the cache.", - required = false, - ), + 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), ), ), ) + /** 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) + /** Everything this bot may be asked for, the reads first. */ val rows: List = reading + changing @@ -164,12 +252,3 @@ internal object OneBotActions { /** The row of [name], null when this bot may not be asked for it. */ fun of(name: String): Row? = byName[name] } - -/** - * Whether this argument is one the standard writes as a number rather than as text. - * - * A model writes every argument as text, so one that names a message, a group, a member or a length has to be written - * as the number the standard asks for, while one that names a title, a flag or a reason stays the text it was given. - */ -internal val OneBotActions.Parameter.numeric: Boolean - get() = name.endsWith("_id") || name == "duration" 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 index b3a2bd7d..25699e68 100644 --- 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 @@ -89,6 +89,33 @@ class OneBotPrepareTest { 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" } From d44dbac70671c13ba83148b7e3aa64dae134dcb3 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 20:48:57 +0800 Subject: [PATCH 22/36] onebot: Answer a read with what the platform gave A call that only reads was answered with the sentence that the action was carried out, and the platform's answer was dropped. A read of a message therefore told the model that the message was read and nothing else, which is what a turn about a picture in a quoted message got: the answer held the message, and the model saw none of it. A read is answered with the platform's answer now, cut where it is longer than a turn should carry and saying that it was cut, and an answer that holds nothing says so. A call that changes something keeps the sentence that says what it did, since the platform's answer to it holds nothing the model can use. What a read answers is what other people wrote, so a turn that carries it is marked as one that carries content this bot cannot vouch for, the way any other content read from outside is marked. This corrects what was said when the actions were first declared: the reads did not answer with the platform's data then, whatever the note said. --- .../channel/onebot/tools/OneBotActionTool.kt | 9 ++- .../channel/onebot/tools/OneBotToolSupport.kt | 26 ++++++- .../channel/onebot/tools/OneBotOutcomeTest.kt | 67 +++++++++++++++++++ 3 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotOutcomeTest.kt 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 index 8b418afc..8b0ff4ad 100644 --- 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 @@ -11,6 +11,7 @@ import kotlinx.serialization.json.contentOrNull 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.Tool @@ -118,7 +119,13 @@ private suspend fun call( is Prepared.Ready -> prepared.params } val api = apis.serving(context.turn.chat) - return ToolResult(api.raw(name, params).outcome(row.said)) + val result = api.raw(name, params) + // 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. */ 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 index c18f1f9e..c93410cd 100644 --- 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 @@ -15,15 +15,35 @@ import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress internal fun OneBotApis.serving(chat: ChatAddress): OneBotApi = of(chat.instance) ?: throw IllegalStateException("No OneBot channel instance serves ${chat.instance}.") -/** What [what] says when the call was taken, and what the implementation said when it was not. */ -internal fun OneBotResult<*>.outcome(what: String): String = when (this) { - is OneBotResult.Ok -> what +/** 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 + +/** + * 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() } 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) + } + } +} From 0ea60133400dd0f58a2e716985c06295d50c991e Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 20:50:49 +0800 Subject: [PATCH 23/36] onebot: Let a picture in the quoted message reach the turn A report answers a message and says nothing about it, so what the channel read of each message is kept: its text and, from now, what it was reported to hold as well. Only the text was kept, so a picture inside a quoted message reached the model as the marker for a picture and no picture, and a question about that picture could not be answered however the model reads pictures. That is what a quoted picture and a question about it got. What the quoted message held is attached to the turn beside the media of the message itself, read the same way, so a reader that can read the picture is asked for it. What cannot be read still leaves its marker in the quoted text, which says what it was. The test reports a picture and then a message that answers it and names the bot, and says that the turn carries the picture and its bytes. --- .../channel/onebot/mapping/OneBotMessages.kt | 14 +++++- .../onebot/OneBotMediaIntegrationTest.kt | 47 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt index dda7e03a..1158ed36 100644 --- a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessages.kt @@ -117,7 +117,9 @@ public object OneBotMessages { forwarded = null, ) .text(segments.filterIsInstance().joinToString("") { it.text }) - .media(media(segments, reader)) + // 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() @@ -301,17 +303,20 @@ public object OneBotMessages { 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(OneBotSegmentCodec.decodeMessage(info.message ?: JsonNull).segments), + 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, ) } @@ -421,6 +426,11 @@ public class QuotedMessage( 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(), ) /** 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 index 9eaca884..8bdbcf81 100644 --- 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 @@ -62,6 +62,53 @@ class OneBotMediaIntegrationTest { } } + @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) From bbd0cca220439051dbb907e7001b66adc7430edb Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 20:57:34 +0800 Subject: [PATCH 24/36] onebot: Read and write the file segment an implementation reports The standard sends a file through the actions for files rather than in a message, and an implementation that offers the extension carries one as a segment of its own. A report carrying that segment was kept as something unknown, so what it named was never read, and the channel had no way to write one. The segment is modelled now, read and written like the others: what the implementation knows the file by, what it is called and where it can be read. The compiler named the two places that had to be told as well, which is what a sealed set of segments is for. What an implementation says of a file beyond that is left behind, as it is for the other segments this version models, and a type that stays unknown is still kept whole. Reading a file's bytes out of a chat, and uploading a file of the workspace, are not part of this. The actions for them need a reading of the workspace that this plugin cannot yet be given: the floor that decides what may be read is bound by no plugin of this one, so asking for it stops the channel from starting at all, which eighteen of its tests said. --- .../channel/onebot/protocol/message/CqCode.kt | 7 ++ .../onebot/protocol/message/OneBotSegment.kt | 19 ++++ .../protocol/message/OneBotSegmentCodec.kt | 12 +++ .../protocol/message/OneBotFileSegmentTest.kt | 99 +++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotFileSegmentTest.kt 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 index d41da043..cc4714f0 100644 --- 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 @@ -101,6 +101,13 @@ public object CqCode { "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, 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 index add499cf..ba6d4eae 100644 --- 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 @@ -64,6 +64,24 @@ public sealed interface 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 @@ -199,6 +217,7 @@ public sealed interface OneBotSegment { 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" 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 index 0ffdd8dc..ddf18541 100644 --- 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 @@ -97,6 +97,12 @@ public object OneBotSegmentCodec { 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") {} @@ -206,6 +212,12 @@ public object OneBotSegmentCodec { 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 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) + } +} From 43aba050a63d5a819d1b4b8fbfd03fc4da0d85d1 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 21:00:58 +0800 Subject: [PATCH 25/36] onebot: Let a call hand the platform a file of the workspace The actions for files were declared but a call could not reach one: the platform is usually on another machine, where a path of this one means nothing, so what such a call carries is the bytes rather than the path, and what may be read is not this plugin's to decide. A row now says that an argument is a file of the workspace, and the tool reads it as the bytes the platform takes, written the way it takes them. What may be read is asked of the floor the tools for files ask: a path it denies is refused here with the reason it gave, and one it allows is read at the path it names. A floor is asked for as one of the things a runtime may have rather than one it must, the way this channel already asks for an agent: a runtime that decides nothing about what may be read refuses a call that needs a file instead of reading it of its own accord, and the channel still starts without one. A file larger than a call should carry is refused, naming the limit, since the bytes grow on the way and an implementation refuses what is too large without saying what the call got wrong. The distinction between a file of the workspace and a name the platform knows one by is asserted, since reading a file of the platform takes its name rather than a path. --- .../channel/onebot/tools/OneBotActionTool.kt | 93 +++++++++++++- .../channel/onebot/tools/OneBotActions.kt | 53 ++++++++ .../channel/onebot/tools/OneBotToolSupport.kt | 9 ++ .../channel/onebot/tools/OneBotActionsTest.kt | 39 ++++++ .../onebot/tools/OneBotFileArgumentTest.kt | 119 ++++++++++++++++++ 5 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotFileArgumentTest.kt 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 index 8b0ff4ad..c281186d 100644 --- 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 @@ -1,5 +1,7 @@ package org.foedusprogramme.alexandrite.channel.onebot.tools +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull @@ -8,17 +10,23 @@ 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 /** * Reads what the platform knows about a chat, a member or a message this bot acts in. @@ -30,7 +38,7 @@ import org.foedusprogramme.alexandrite.sdk.tool.ToolRisk */ @Singleton @Contribute(Tool::class) -internal class OneBotReadTool(private val apis: OneBotApis) : Tool { +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 }), @@ -39,13 +47,13 @@ internal class OneBotReadTool(private val apis: OneBotApis) : Tool { ) override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult = - call(apis, OneBotActions.rows.filterNot { it.changes }, arguments, context) + 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) : Tool { +internal class OneBotActTool(private val apis: OneBotApis, private val floor: HardFloor?) : Tool { override val definition: ToolDefinition = ToolDefinition( name = "onebot.act", description = describe( @@ -57,7 +65,7 @@ internal class OneBotActTool(private val apis: OneBotApis) : Tool { ) override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult = - call(apis, OneBotActions.rows.filter { it.changes }, arguments, context) + 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. */ @@ -102,6 +110,7 @@ private fun describe(header: String, rows: List): String = bu private suspend fun call( apis: OneBotApis, rows: List, + floor: HardFloor?, arguments: JsonObject, context: ToolContext, ): ToolResult { @@ -118,8 +127,14 @@ private suspend fun call( 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)) { + 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, params) + 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) { @@ -157,6 +172,8 @@ internal fun prepare(row: OneBotActions.Row, given: JsonObject, messageId: Strin 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( @@ -209,3 +226,69 @@ 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 is refused rather than sent, since the bytes grow on the way and an + * implementation refuses what is too large without saying what the call got wrong. + */ +internal suspend fun withFiles( + row: OneBotActions.Row, + params: JsonObject, + floor: HardFloor?, + context: ToolContext, +): 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 path = params[parameter.name]?.jsonPrimitive?.contentOrNull + ?: return Prepared.Refused( + "The action '${row.name}' needs ${parameter.name}: ${parameter.description}", + ) + val named = try { + Path.of(path) + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + return Prepared.Refused("'$path' is no path: ${parameter.description}") + } + val allowed = when (val check = reading.checkRead(named, context.turn.agent)) { + is FloorCheck.Denied -> return Prepared.Refused( + "The action '${row.name}' may not read '$path': ${check.reason}.", + ) + + is FloorCheck.Allowed -> check.canonical + } + val bytes = try { + if (Files.size(allowed) > FILE_LIMIT) { + return Prepared.Refused( + "The action '${row.name}' cannot send '$path', which is larger than the " + + "${FILE_LIMIT / (1024 * 1024)} MiB a call may carry.", + ) + } + Files.readAllBytes(allowed) + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + return Prepared.Refused("The action '${row.name}' could not read '$path': ${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://" 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 index 9ab081e0..8b3b9342 100644 --- 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 @@ -27,6 +27,15 @@ internal object OneBotActions { /** 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, } /** @@ -113,6 +122,29 @@ internal object OneBotActions { 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( @@ -227,6 +259,27 @@ internal object OneBotActions { 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. */ 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 index c93410cd..bd7ac641 100644 --- 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 @@ -18,6 +18,15 @@ internal fun OneBotApis.serving(chat: ChatAddress): OneBotApi = /** 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 may hand to the platform. + * + * 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. * 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 index 5ccaa400..5258594d 100644 --- 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 @@ -102,4 +102,43 @@ class OneBotActionsTest { 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..6201229d --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotFileArgumentTest.kt @@ -0,0 +1,119 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.foedusprogramme.alexandrite.sdk.InternalAlexandriteApi +import org.foedusprogramme.alexandrite.sdk.chat.AgentId +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.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") + 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)) + 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()), + ) + assertTrue(refused.said.contains("a place the test protects"), refused.said) + assertTrue(refused.said.contains("may not read"), refused.said) + } + + @Test + fun `a file larger than a call may carry is refused, naming what it passed`() = runBlocking { + val file = home.resolve("big.zip") + file.writeBytes(ByteArray((FILE_LIMIT + 1).toInt())) + val refused = assertIs(withFiles(upload, given(file), allows(), testToolContext())) + assertTrue(refused.said.contains("8 MiB"), 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())) + 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())) + 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())) + assertTrue(prepared.params.containsKey("duration"), prepared.params.toString()) + } + + /** 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?): FloorCheck = decision(path) + + override fun checkWrite(path: Path, agent: AgentId?): FloorCheck = FloorCheck.Denied("a test floor writes none") + + override fun checkTree(path: Path, agent: AgentId?): FloorCheck = FloorCheck.Denied("a test floor walks none") + } +} From 93dba75c4ad242dd9c903fb2adae95b9f34a208a Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 21:04:32 +0800 Subject: [PATCH 26/36] agent: Expect the framing that names the message id The framing names the id of the message a turn answers. Three tests of the first answer build the entry they expect in the test rather than from a golden file, so they went on expecting the framing without that line, and failed from the change that added it. That change updated the two golden files and missed these three, which is what the count of failing tests in this suite said: three of one class on top of the ones this platform already fails. --- .../foedusprogramme/alexandrite/agent/turn/FirstAnswerTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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"), From ecb93aa4a80dc9104e50e83cfcdaca5a1cc79df6 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 21:13:45 +0800 Subject: [PATCH 27/36] provider: Read the format of an image from its bytes where its channel named only the kind A channel that cannot say what format its bytes are says the kind alone, and that was sent as the type of the image: a request carried data:image/*;base64,... and the endpoint refused it, which fails the whole request and so every turn of a conversation that holds the image, since the attachment is part of its history. The bytes are there when the request is built, so the format is read from them where the channel named only the kind: png, jpeg, gif and webp, the four the endpoints take. A channel that names a format is still taken at its word. An image that is in none of those formats is left out and said to be, the way a kind the model cannot take already was: sending it as a type the endpoint refuses fails the turn that carries it, and there is nothing the model could do with it either way. --- .../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 099ada66d0d470b3fa4ce0bf9f0d49c3a119ab1e Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 21:21:04 +0800 Subject: [PATCH 28/36] onebot: Resolve the path a call names the way a tool for files resolves one A call that uploads a file names a path, and that path was handed to the floor as it was written. The floor is told of an absolute path, so a call naming one relative to the workspace failed with 'A path to canonicalize is absolute and of the default file system', and the file was never sent. A path is resolved the way the tools for files resolve one: one that is absolute as it is, one under the home directory where it begins with a tilde, and a relative one against the workspace, refused where the call has none. What a call may name is then what a tool for files may name, and the floor is asked about the path that comes of it, which is where what may be read is decided. The tests say that a relative path is resolved against the workspace and its bytes are carried, and that one with no workspace is refused rather than resolved against something else. --- .../channel/onebot/tools/OneBotActionTool.kt | 42 +++++++++++++++---- .../onebot/tools/OneBotFileArgumentTest.kt | 22 ++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) 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 index c281186d..72a52da2 100644 --- 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 @@ -254,19 +254,42 @@ internal suspend fun withFiles( val carried = buildJsonObject { params.forEach { (key, value) -> put(key, value) } named.forEach { parameter -> - val path = params[parameter.name]?.jsonPrimitive?.contentOrNull + val given = params[parameter.name]?.jsonPrimitive?.contentOrNull ?: return Prepared.Refused( "The action '${row.name}' needs ${parameter.name}: ${parameter.description}", ) - val named = try { - Path.of(path) + // 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("'$path' is no path: ${parameter.description}") + return Prepared.Refused("'$given' is no path: ${parameter.description}") } - val allowed = when (val check = reading.checkRead(named, context.turn.agent)) { + 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 '$path': ${check.reason}.", + "The action '${row.name}' may not read '$given': ${check.reason}.", ) is FloorCheck.Allowed -> check.canonical @@ -274,14 +297,14 @@ internal suspend fun withFiles( val bytes = try { if (Files.size(allowed) > FILE_LIMIT) { return Prepared.Refused( - "The action '${row.name}' cannot send '$path', which is larger than the " + + "The action '${row.name}' cannot send '$given', which is larger than the " + "${FILE_LIMIT / (1024 * 1024)} MiB a call may carry.", ) } Files.readAllBytes(allowed) } catch (e: Exception) { currentCoroutineContext().ensureActive() - return Prepared.Refused("The action '${row.name}' could not read '$path': ${e.message}") + 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)) @@ -292,3 +315,6 @@ internal suspend fun withFiles( /** 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://" + +/** 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/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 index 6201229d..3ae4fede 100644 --- 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 @@ -90,6 +90,28 @@ class OneBotFileArgumentTest { 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), + ) + + 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)), + ) + 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")) From 979229a9ac1ba012149a6b4303ea64c09726e811 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 21:31:18 +0800 Subject: [PATCH 29/36] onebot: Send a file too large for one request in pieces An implementation of NapCat's stream API takes a file in pieces and names it in answer, which is how a file larger than one request is sent at all. Until now a call refused anything larger than the eight mebibytes a request carries, so a file of any size beyond that could not be sent. The file is read twice and never held whole: once for its size and the digest that every piece is sent with, and once to send the pieces themselves, each of 256 KiB, ending with the call that says the file is complete. What the action carries is then the name the implementation answered with, which is what sending the file on refers to. A piece the implementation refuses ends the sending and nothing is sent on, so a file that was only partly streamed is never sent as if it were whole, and an answer naming no file leaves nothing to send on by and is refused as well. What may be read is unchanged: the floor is asked of the path before the file is read, whether it then goes in one request or in pieces. --- .../channel/onebot/tools/OneBotActionTool.kt | 37 ++++- .../channel/onebot/tools/OneBotToolSupport.kt | 2 +- .../channel/onebot/tools/OneBotUpload.kt | 119 ++++++++++++++ .../onebot/tools/OneBotFileArgumentTest.kt | 56 +++++-- .../channel/onebot/tools/OneBotUploadTest.kt | 154 ++++++++++++++++++ 5 files changed, 348 insertions(+), 20 deletions(-) create mode 100644 libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUpload.kt create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUploadTest.kt 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 index 72a52da2..6e5c68c8 100644 --- 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 @@ -129,7 +129,10 @@ private suspend fun call( } // 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)) { + 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 } @@ -236,14 +239,16 @@ private fun number(value: JsonElement): JsonElement { * 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 is refused rather than sent, since the bytes grow on the way and an - * implementation refuses what is too large without saying what the call got wrong. + * 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) @@ -294,13 +299,27 @@ internal suspend fun withFiles( is FloorCheck.Allowed -> check.canonical } + val size = try { + 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 { - if (Files.size(allowed) > FILE_LIMIT) { - return Prepared.Refused( - "The action '${row.name}' cannot send '$given', which is larger than the " + - "${FILE_LIMIT / (1024 * 1024)} MiB a call may carry.", - ) - } Files.readAllBytes(allowed) } catch (e: Exception) { currentCoroutineContext().ensureActive() 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 index bd7ac641..bbd494a5 100644 --- 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 @@ -19,7 +19,7 @@ internal fun OneBotApis.serving(chat: ChatAddress): OneBotApi = internal const val ANSWER_LIMIT: Int = 4_000 /** - * How large a file a call may hand to the platform. + * 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 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..5a7863d0 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUpload.kt @@ -0,0 +1,119 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +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? { + 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 null + index++ + } + } + val completed = send( + buildJsonObject { + put("stream_id", stream) + put("is_complete", true) + put("file_retention", STREAM_RETENTION_MS) + }, + ) + return (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) } +} 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 index 3ae4fede..f2fa95fb 100644 --- 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 @@ -1,9 +1,12 @@ 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.tool.FloorCheck @@ -16,6 +19,7 @@ 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 @@ -28,6 +32,9 @@ import kotlin.test.assertTrue */ 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 @@ -35,7 +42,7 @@ class OneBotFileArgumentTest { val file = home.resolve("notes.zip") file.writeBytes(byteArrayOf(1, 2, 3)) val context = testToolContext() - val prepared = assertIs(withFiles(upload, given(file), allows(), context)) + 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://"))) @@ -49,32 +56,61 @@ class OneBotFileArgumentTest { file.writeBytes(byteArrayOf(1)) val refused = assertIs( - withFiles(upload, given(file), denies("a place the test protects"), testToolContext()), + 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 larger than a call may carry is refused, naming what it passed`() = runBlocking { + 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())) - assertTrue(refused.said.contains("8 MiB"), refused.said) + + 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())) + 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())) + val refused = assertIs(withFiles(upload, given, allows(), testToolContext(), stream)) assertTrue(refused.said.contains("file"), refused.said) } @@ -86,7 +122,7 @@ class OneBotFileArgumentTest { put("user_id", JsonPrimitive("2222")) put("duration", JsonPrimitive("60")) } - val prepared = assertIs(withFiles(mute, given, null, testToolContext())) + val prepared = assertIs(withFiles(mute, given, null, testToolContext(), stream)) assertTrue(prepared.params.containsKey("duration"), prepared.params.toString()) } @@ -97,7 +133,7 @@ class OneBotFileArgumentTest { val context = testToolContext(workspace = home) val prepared = assertIs( - withFiles(upload, given(Path.of("relative.txt")), allows(), context), + withFiles(upload, given(Path.of("relative.txt")), allows(), context, stream), ) val carried = prepared.params.getValue("file").jsonPrimitive.content @@ -107,7 +143,7 @@ class OneBotFileArgumentTest { @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)), + withFiles(upload, given(Path.of("relative.txt")), allows(), testToolContext(workspace = null), stream), ) assertTrue(refused.said.contains("no workspace"), refused.said) } 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..a83b93b1 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUploadTest.kt @@ -0,0 +1,154 @@ +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.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.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) + } + + /** 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) } +} From 4eca1287baab06d5f6eac1993d69fef8f954b37b Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 21:40:56 +0800 Subject: [PATCH 30/36] onebot: Let a change take as long as a file of the workspace can need The tools declared no time of their own, so a call took the timeout of the turn, and a file of the workspace makes a call long: one larger than a request carries is sent to the implementation in pieces, each of them a call of its own, and is then sent on. A call ended that way is one whose outcome nobody knows, which is what a file that reached a group twice came of. The tool that changes something declares five minutes now, which is a ceiling rather than a wait: a call that is done sooner ends sooner. The tool that only reads needs none, which the test says as well. --- .../channel/onebot/tools/OneBotActionTool.kt | 12 ++++++++ .../onebot/tools/OneBotToolDefinitionTest.kt | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolDefinitionTest.kt 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 index 6e5c68c8..ca24ade0 100644 --- 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 @@ -27,6 +27,8 @@ 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. @@ -62,6 +64,7 @@ internal class OneBotActTool(private val apis: OneBotApis, private val floor: Ha ), parameters = schema(), risk = ToolRisk.EXTERNAL_ACTION, + timeout = actTimeout, ) override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult = @@ -335,5 +338,14 @@ internal suspend fun withFiles( /** 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/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) + } +} From 95c92232c74f103d90e7377585f12d0d1fd0d21e Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 21:42:33 +0800 Subject: [PATCH 31/36] onebot: Read the files of a call on the dispatcher for reading files A file of the workspace is read where the call runs, and the executor runs a tool where it is called, so reading a large file held the dispatcher the turn runs on and a cancellation could not take effect until the read was done. The files are read on the dispatcher for reading files now, which is what the rest of the project does with a file, and the sending of the pieces waits the same way it did. A call cancelled while it waits ends cancelled rather than answered, which the test says: nothing is known of what such a call did, and the count of it must not be taken as one that did nothing. --- .../channel/onebot/tools/OneBotActionTool.kt | 6 ++++-- .../channel/onebot/tools/OneBotUpload.kt | 8 +++++--- .../channel/onebot/tools/OneBotUploadTest.kt | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) 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 index ca24ade0..17cbe4a9 100644 --- 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 @@ -1,7 +1,9 @@ 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 @@ -303,7 +305,7 @@ internal suspend fun withFiles( is FloorCheck.Allowed -> check.canonical } val size = try { - Files.size(allowed) + withContext(Dispatchers.IO) { Files.size(allowed) } } catch (e: Exception) { currentCoroutineContext().ensureActive() return Prepared.Refused("The action '${row.name}' could not read '$given': ${e.message}") @@ -323,7 +325,7 @@ internal suspend fun withFiles( return@forEach } val bytes = try { - Files.readAllBytes(allowed) + withContext(Dispatchers.IO) { Files.readAllBytes(allowed) } } catch (e: Exception) { currentCoroutineContext().ensureActive() return Prepared.Refused("The action '${row.name}' could not read '$given': ${e.message}") 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 index 5a7863d0..32401072 100644 --- 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 @@ -1,5 +1,7 @@ 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 @@ -51,7 +53,7 @@ internal suspend fun streamFile( path: Path, name: String, send: suspend (JsonObject) -> OneBotResult, -): String? { +): String? = withContext(Dispatchers.IO) { val size = Files.size(path) val digest = digest(path) val total = ((size + STREAM_CHUNK - 1) / STREAM_CHUNK).toInt() @@ -73,7 +75,7 @@ internal suspend fun streamFile( put("filename", name) put("file_retention", STREAM_RETENTION_MS) } - if (send(call) !is OneBotResult.Ok) return null + if (send(call) !is OneBotResult.Ok) return@withContext null index++ } } @@ -84,7 +86,7 @@ internal suspend fun streamFile( put("file_retention", STREAM_RETENTION_MS) }, ) - return (completed as? OneBotResult.Ok)?.data?.reference() + (completed as? OneBotResult.Ok)?.data?.reference() } /** 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 index a83b93b1..be4d965f 100644 --- 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 @@ -1,6 +1,9 @@ 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 @@ -16,6 +19,7 @@ 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 @@ -141,6 +145,18 @@ class OneBotUploadTest { 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") }) From edb1c9c3925bf0780890c69a3c0a8c3aefc26264 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 21:45:40 +0800 Subject: [PATCH 32/36] onebot: Say what a call that ends without an answer may have done A call that changes something can be ended while its request is on its way, and nothing is then known of whether it was carried out. A model that takes such a call for one that did nothing sends the same thing again, which is how one file reached a group twice. The tool that changes something says so now, and says to look at what the chat or the group holds before calling again. The tool that only reads says no such thing, since a reading changes nothing. --- .../channel/onebot/tools/OneBotActionTool.kt | 3 +- .../onebot/tools/OneBotActDescriptionTest.kt | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActDescriptionTest.kt 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 index 17cbe4a9..f738750d 100644 --- 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 @@ -61,7 +61,8 @@ internal class OneBotActTool(private val apis: OneBotApis, private val floor: Ha override val definition: ToolDefinition = ToolDefinition( name = "onebot.act", description = describe( - "Changes something of a QQ chat this bot acts in", + "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(), 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)) + } +} From 2e54ab125a47fd155c3f214770a3cd2cb26349fc Mon Sep 17 00:00:00 2001 From: Yos-X Date: Mon, 12 Oct 2026 21:46:37 +0800 Subject: [PATCH 33/36] onebot: Let a file of a group be checked and taken away A file that reached a group could only be sent: nothing asked what a group holds, and nothing took a file away, so a file that was sent there twice had to be taken away by hand. The actions an implementation offers for a group's files are in the table now, on the side each belongs to: what a group holds is read by the tool that reads, and a file is taken away by the tool that changes. Taking a file away is given what listing a group's files gives, which is why both halves are here: the id a file is known by and the part it is in come from the listing, and a deletion that cannot be given them is of no use. The actions that move, rename, forward or make folders of a group's files are not part of this, nor are the ones that delete a folder. --- .../channel/onebot/tools/OneBotActions.kt | 41 +++++++++++++- .../onebot/tools/OneBotGroupFilesTest.kt | 53 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotGroupFilesTest.kt 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 index 8b3b9342..0a29fdd6 100644 --- 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 @@ -148,6 +148,20 @@ internal object OneBotActions { ) 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.", @@ -286,8 +300,33 @@ internal object OneBotActions { 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 + changing + val rows: List = reading + fileReading + changing private val byName: Map = rows.associateBy(Row::name) 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") + } + } +} From d9b9dd6e31e6ac3b20f97e12ab151db4b4192ceb Mon Sep 17 00:00:00 2001 From: Yos-X Date: Tue, 13 Oct 2026 17:21:57 +0800 Subject: [PATCH 34/36] agent: Hand the test of background calls the message its turn answers The test builds a tool call and hands it to the executor, which takes the message the turn answers as well. It passes none, since a call of a background job in that test answers no message of a chat. That parameter is what lets a tool act on the message a turn is about. The test says nothing about it, so it passes nothing rather than a message no chat sent. --- .../org/foedusprogramme/alexandrite/agent/job/JobsTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1c406b6c..2b2b628c 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 @@ -79,7 +79,7 @@ class JobsTest { ): ToolCallEnd { val executor = ToolExecutor(AdmittingGate, settings("{}"), Workspaces(hostPaths(null), settings("{}")), this) 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 From 16a2654d016b029a884c2117ef3eb438f376be3c Mon Sep 17 00:00:00 2001 From: Yos-X Date: Tue, 13 Oct 2026 17:36:16 +0800 Subject: [PATCH 35/36] app, agent: Wrap the lines that grew past the length a line may be The set of text packs a checkout holds grew by the id of the new channel, and the line naming them is longer than a line may be: the names go one to a line now. The context a tool runs in is built with the message its turn answers and, since the merge, with what a sub-agent run takes as well, and that call is longer than a line may be: the call goes on the line after. The check of the app and of the agent was not run when those lines were written, since the check was run for the channel alone. That is what the failing build said. --- .../org/foedusprogramme/alexandrite/app/AppTextsTest.kt | 7 ++++++- .../foedusprogramme/alexandrite/agent/tool/ToolExecutor.kt | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) 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 80790949..789d6e79 100644 --- a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/AppTextsTest.kt +++ b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/AppTextsTest.kt @@ -81,7 +81,12 @@ class AppTextsTest { val packs = AppTexts.packs(classLoader) assertEquals( - setOf("alexandrite-agent", "alexandrite-channel-telegram", "alexandrite-channel-onebot", "alexandrite-commands"), + setOf( + "alexandrite-agent", + "alexandrite-channel-telegram", + "alexandrite-channel-onebot", + "alexandrite-commands", + ), packs.plugins, ) for (plugin in packs.plugins) assertEquals(listOf(chinese), assertTextsComplete(plugin, packs, classLoader)) 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 728016ba..76aa27a9 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 @@ -104,7 +104,8 @@ internal class ToolExecutor( calls.starting(call.id) calls.running(call.id) val delegating = calls as? DelegatingTurn - val context = AgentToolContext(turn, call.id, workspaces, admission.clearance, taint, trigger, delegating) + val context = + AgentToolContext(turn, call.id, workspaces, admission.clearance, taint, trigger, delegating) val result = if (tool is InTurnTool) { inTurn(turn, call, tool, arguments, context) } else { From bd70e0929941b40742e5fe9de053c8496284595a Mon Sep 17 00:00:00 2001 From: Yos-X Date: Tue, 13 Oct 2026 17:50:37 +0800 Subject: [PATCH 36/36] app, testkit: Expect the ids the framing names and the trigger the context carries The framing names the id of the message a message answers, so the quote of a bot's own line about an approval carries one, and the test that reads that quote expects it now. The test keeps the message it reads in the same place a test already keeps the one it presses, so both are there when the framing is read. The context of a call carries the message its turn answers, and the testkit's context is one: a declaration the sample of the testkit's public API had never named, which the test of that sample said. Both are what two checks of this branch reported, and both were left behind when those declarations were added. --- .../alexandrite/app/TelegramPermissionTest.kt | 7 ++++--- .../foedusprogramme/alexandrite/testkit/PublicApiTest.kt | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) 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 a65ba1f9..f6e4c7e5 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 (unverified)\n> Noted.\n") + assertContains(framing("Really?"), "\nQuoting: Alexandrite Test Bot (unverified) [id ${noted.id}]\n> Noted.\n") assertEquals("a quote that its channel does not vouch for", afterAnswer?.reason) } 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 2cdf8a5e..15ce2828 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 @@ -262,6 +262,7 @@ class PublicApiTest { val defined = recordingTool(testToolDefinition("notes.list", ToolRisk.READ_ONLY)) { _, _ -> ToolResult("") } return listOf( result, tool.definition, tool.calls, tool.cancelled, call.arguments, call.turn, call.call, checked, + context.trigger, context.turn, context.call, context.workspace, context.touchedPaths, context.openWorkspace(), defined, context.clearance, context.untrustedReasons, )