diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b5361d83..2d70c498 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,6 +11,8 @@ dependencies { implementation(project(":libraries:commands")) implementation(project(":libraries:channels:telegram")) implementation(project(":libraries:channel-common")) + implementation(project(":libraries:channels:onebot")) + implementation(project(":libraries:channels:qqbot")) implementation(project(":libraries:providers:openai-compatible")) implementation(project(":libraries:providers:openai-responses")) implementation(project(":libraries:providers:deepseek")) diff --git a/app/src/dist/config/alexandrite.example.json b/app/src/dist/config/alexandrite.example.json index dd3258a4..e4e6055b 100644 --- a/app/src/dist/config/alexandrite.example.json +++ b/app/src/dist/config/alexandrite.example.json @@ -5,5 +5,37 @@ "shutdownGraceSeconds": 15, "startTimeoutSeconds": 30, "plugins": [] + }, + "channels": { + "onebot": { + "enabled": false, + "settings": { + "eventQueueCapacity": 1024, + "rateLimitIntervalMillis": 500 + }, + "instances": { + "main": { + "transport": "ws_reverse", + "listenHost": "127.0.0.1", + "listenPort": 8080, + "admins": [], + "approvalMinutes": 10 + } + } + }, + "qqbot": { + "enabled": false, + "instances": { + "main": { + "appId": "10000", + "appSecret": "replace-with-the-app-secret", + "apiBase": "https://api.bot.qq.com", + "intents": 33554432, + "streaming": true, + "approvalMinutes": 10, + "admins": [] + } + } + } } } diff --git a/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-onebot/zh-CN.properties b/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-onebot/zh-CN.properties new file mode 100644 index 00000000..5e6ae092 --- /dev/null +++ b/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-onebot/zh-CN.properties @@ -0,0 +1,85 @@ +approval.title=需要批准(#{id}) +approval.agent=智能体:{agent} +approval.chat=聊天:{chat} +approval.requester=发起人:{requester} +approval.reason=原因:{reason} +approval.tool=工具:{tool}({risk}) +approval.file=文件:{path} +approval.directory=目录:{directory} +approval.program=程序:{program} +approval.program_unknown=未找到 +approval.opaque=不透明:变石看不到这个程序运行了什么。 +approval.arguments=参数: +approval.answer=回复本条消息,发送 /approve 或 /deny。 +approval.always_rule=永久允许将授予:{rule} +approval.outcome_allowed=批准 {id} 已由 {name} 允许。 +approval.outcome_allowed_always=批准 {id} 已由 {name} 永久允许,同时授予 {rule}。 +approval.outcome_denied=批准 {id} 已由 {name} 拒绝。 +approval.outcome_expired=批准 {id} 已过期,无人应答。 +approval.outcome_withdrawn=批准 {id} 已撤回,不再等待应答。 +approval.admins_only=只有本实例的管理员可以应答。 +approval.not_pending=该请求已不再等待应答。 +approval.nothing_waits=本聊天没有等待中的批准请求。 +approval.list_title=本聊天等待中的批准请求: +approval.list_entry=- {id} {tool} {reason} +approval.list_hint=回复其中一条消息,发送 /approve 或 /deny。 +approval.several=这没有指出唯一的请求:{ids}。请写出所需那一个的完整 id。 +refused.admin=只有本实例的管理员可以使用 /{command}。 +refused.admin_here=只有本实例的管理员可以在本聊天使用 /{command}。 +pairing.code=本聊天尚未与变石配对。请把配对码 {code} 交给运维方,该码 {minutes} 分钟内有效。 +activity.verb.read.present=正在读取 +activity.verb.read.past=读取了 +activity.verb.list.present=正在列出 +activity.verb.list.past=列出了 +activity.verb.search.present=正在进行 +activity.verb.search.past=进行了 +activity.verb.edit.present=正在编辑 +activity.verb.edit.past=编辑了 +activity.verb.delete.present=正在删除 +activity.verb.delete.past=删除了 +activity.verb.move.present=正在移动 +activity.verb.move.past=移动了 +activity.verb.execute.present=正在运行 +activity.verb.execute.past=运行了 +activity.verb.fetch.present=正在获取 +activity.verb.fetch.past=获取了 +activity.verb.think.present=正在思考 +activity.verb.think.past=思考了 +activity.verb.other.present=正在使用 +activity.verb.other.past=使用了 +activity.noun.read.one={count} 个文件 +activity.noun.read.many={count} 个文件 +activity.noun.list.one={count} 个目录 +activity.noun.list.many={count} 个目录 +activity.noun.search.one={count} 次搜索 +activity.noun.search.many={count} 次搜索 +activity.noun.edit.one={count} 个文件 +activity.noun.edit.many={count} 个文件 +activity.noun.delete.one={count} 个文件 +activity.noun.delete.many={count} 个文件 +activity.noun.move.one={count} 个文件 +activity.noun.move.many={count} 个文件 +activity.noun.execute.one={count} 条命令 +activity.noun.execute.many={count} 条命令 +activity.noun.fetch.one={count} 个网页 +activity.noun.fetch.many={count} 个网页 +activity.noun.think.one={count} 步 +activity.noun.think.many={count} 步 +activity.noun.other.one={count} 个工具 +activity.noun.other.many={count} 个工具 +activity.verb.send.present=正在发送 +activity.verb.send.past=发送了 +activity.noun.send.one={count} 个文件 +activity.noun.send.many={count} 个文件 +activity.summary.not_run=未执行:{noun} +activity.omitted.one=较早的 {count} 项已省略 +activity.omitted.many=较早的 {count} 项已省略 +activity.call.waiting_approval=等待批准:{tool} {title} +activity.call.not_run=未执行:{tool} {title} +activity.call.backgrounded=已转入后台:{tool} {title} +activity.call.exit=退出码 {code} +activity.call.failed=失败 +activity.call.diff=+{added} -{removed} +activity.delegate.present=正在委派给 {agent} +activity.delegate.past=已委派给 {agent} +activity.call.by_agent={agent} · {line} \ No newline at end of file diff --git a/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-qqbot/zh-CN.properties b/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-qqbot/zh-CN.properties new file mode 100644 index 00000000..551d83b4 --- /dev/null +++ b/app/src/main/resources/alexandrite/app-i18n/alexandrite-channel-qqbot/zh-CN.properties @@ -0,0 +1,15 @@ +approval.title=需要批准(#{id}) +approval.tool=工具:{tool}({risk}) +approval.reason=原因:{reason} +approval.arguments=参数: +approval.allow=允许 +approval.deny=拒绝 +approval.always=始终允许:{rule} +approval.allowed=已允许(#{id}) +approval.denied=已拒绝(#{id}) +approval.expired=审批 #{id} 已过期 +approval.admins_only=只有管理员可以处理这条审批。 +approval.nothing_waiting=没有等待中的这条审批。 +approval.unreachable=没能把这条审批发给任何管理员。 +refused.admin=只有本实例的管理员可以使用 /{command}。 +refused.admin_here=只有本实例的管理员可以在本聊天使用 /{command}。 diff --git a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/AppTextsTest.kt b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/AppTextsTest.kt index 887a8062..4b57f30f 100644 --- a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/AppTextsTest.kt +++ b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/AppTextsTest.kt @@ -86,6 +86,8 @@ class AppTextsTest { "alexandrite-automation-heartbeat", "alexandrite-automation-schedule", "alexandrite-channel-telegram", + "alexandrite-channel-onebot", + "alexandrite-channel-qqbot", "alexandrite-commands", ), packs.plugins, diff --git a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/TelegramPermissionTest.kt b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/TelegramPermissionTest.kt index a1e9d8f5..38cc1b24 100644 --- a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/TelegramPermissionTest.kt +++ b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/TelegramPermissionTest.kt @@ -121,6 +121,7 @@ class TelegramPermissionTest { app.model.reply { toolCall("notes.write", """{"text": "Buy milk."}""") }.reply { text("Noted.") } .reply { text("It ran.") } .reply { text("It did.") } + lateinit var noted: FakeBotMessage lateinit var asked: FakeBotMessage var afterRequest: ConversationTaint? = null var afterAnswer: ConversationTaint? = null @@ -129,7 +130,7 @@ class TelegramPermissionTest { app.send(app.ada, app.adaChat, "Note that I need milk.") asked = app.fake.await(WAIT) { botMessages(ADA).firstOrNull { it.buttons.isNotEmpty() } } app.fake.press(app.ada, asked, "Allow") - val noted = app.message(app.adaChat, "Noted.") + noted = app.message(app.adaChat, "Noted.") idle() val conversation = get().current(app.key(app.adaChat)).id app.fake.send(app.ada, app.adaChat, "Did it run?", replyTo = asked.id) @@ -145,11 +146,11 @@ class TelegramPermissionTest { val id = Regex("#([a-z0-9]+)").find(asked.text)!!.groupValues[1] assertContains( framing("Did it run?"), - "\nQuoting: Alexandrite Test Bot (verified)\n" + + "\nQuoting: Alexandrite Test Bot (verified) [id ${asked.id}]\n" + "> Alexandrite's approval request $id for notes.write (allowed by Ada Lovelace (@ada_test))\n", ) assertNull(afterRequest) - assertContains(framing("Really?"), "\nQuoting: Alexandrite Test Bot (verified)\n> Noted.\n") + assertContains(framing("Really?"), "\nQuoting: Alexandrite Test Bot (verified) [id ${noted.id}]\n> Noted.\n") assertNull(afterAnswer) } 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/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/prompt/MessageFraming.kt b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/prompt/MessageFraming.kt index f1add254..c5937424 100644 --- a/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/prompt/MessageFraming.kt +++ b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/prompt/MessageFraming.kt @@ -51,6 +51,9 @@ internal fun messageFraming(message: IncomingMessage, zone: ZoneId, linked: Bool appendLine() appendLine("Operator: ${if (sender.isAdmin) "yes" else "no"}") appendLine("Received: ${TIME.format(message.receivedAt.atZone(zone))}") + // The id of this message, which is what a tool that acts on it needs and what a person can be given to trace it by: + // a prompt naming no id left a call about the message just written with nothing to name it with. + appendLine("Message id: ${oneLine(message.ref.id)}") append("Chat: ").append(message.chatInfo.kind) message.chatInfo.title?.let { append(" \"").append(oneLine(it)).append('"') } if (linked) append(" on ").append(message.chat.instance) @@ -58,7 +61,10 @@ internal fun messageFraming(message: IncomingMessage, zone: ZoneId, linked: Bool message.forwarded?.let { appendLine("Forwarded from: ${oneLine(it.name ?: UNKNOWN)} (${it.kind})") } message.quote?.let { quote -> val trust = if (quote.trust.live) "verified" else "unverified" - appendLine("Quoting: ${oneLine(quote.senderName ?: UNKNOWN)} ($trust)") + // The id of the message that was quoted, so that a call can name it: what a message answers is reported as an id + // and nothing else, and a person quoting a message is asking about that one. + val quoted = quote.target?.let { " [id ${oneLine(it.id)}]" }.orEmpty() + appendLine("Quoting: ${oneLine(quote.senderName ?: UNKNOWN)} ($trust)$quoted") lines(quote.text).forEach { appendLine(if (it.isEmpty()) ">" else "> $it") } } message.facts.forEach { appendLine("${factLabel(it.label)}: ${oneLine(it.value)}") } diff --git a/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/AgentToolContext.kt b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/AgentToolContext.kt index 95152c22..8b3d5c7f 100644 --- a/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/AgentToolContext.kt +++ b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/AgentToolContext.kt @@ -7,6 +7,7 @@ import org.foedusprogramme.alexandrite.agent.directory.ConversationDirectories import org.foedusprogramme.alexandrite.agent.permission.Principal import org.foedusprogramme.alexandrite.agent.permission.TurnTaint import org.foedusprogramme.alexandrite.agent.workspace.Workspaces +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId import org.foedusprogramme.alexandrite.sdk.chat.TurnInfo import org.foedusprogramme.alexandrite.sdk.chat.TurnKind @@ -28,6 +29,8 @@ internal class AgentToolContext( private val directories: ConversationDirectories, clearance: Clearance?, val taint: TurnTaint, + /** The message of the channel this turn answers, which a tool that acts on it is given. */ + trigger: ChannelMessageRef?, /** What a sub-agent run that the call starts takes from its turn, null outside a turn. */ val delegating: DelegatingTurn? = null, /** Whether the call's review asked for it to run as a background job from its start. */ @@ -72,6 +75,8 @@ internal class AgentToolContext( override val workspace: Path? get() = workspaces.paths[turn.agent] + override val trigger: ChannelMessageRef? = trigger + override val conversationDirectory: Path get() = directories.path(turn) diff --git a/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutor.kt b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutor.kt index 8bd35c41..37bd066d 100644 --- a/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutor.kt +++ b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutor.kt @@ -27,6 +27,7 @@ import org.foedusprogramme.alexandrite.agent.prompt.Escaper import org.foedusprogramme.alexandrite.agent.workspace.Workspaces import org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus import org.foedusprogramme.alexandrite.sdk.channel.TurnEvent +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef import org.foedusprogramme.alexandrite.sdk.chat.TurnInfo import org.foedusprogramme.alexandrite.sdk.di.Singleton import org.foedusprogramme.alexandrite.sdk.tool.ReviewPreview @@ -88,6 +89,7 @@ internal class ToolExecutor( callable: Set, taint: TurnTaint, calls: TurnCalls, + trigger: ChannelMessageRef?, ): ToolCallEnd { val tool = tools[call.name] ?: return ended(call, "No tool named '${call.name}' is offered.", NotRunReason.UNKNOWN_TOOL, calls) @@ -97,7 +99,7 @@ internal class ToolExecutor( val arguments = call.parseArguments() ?: return ended(call, "The arguments are not a JSON object.", NotRunReason.INVALID_ARGUMENTS, calls) val limit = tool.definition.timeout?.let { maxOf(timeout, it + DECLARED_TIMEOUT_GRACE) } ?: timeout - val reviewing = AgentToolContext(turn, call.id, workspaces, directories, null, taint) + val reviewing = AgentToolContext(turn, call.id, workspaces, directories, null, taint, trigger) val admission = try { gate.admit(GateCall(turn, call, tool, arguments, limit, taint, reviewing, calls)) } finally { @@ -120,6 +122,7 @@ internal class ToolExecutor( directories, admission.clearance, taint, + trigger, delegating, startsInBackground = admission.background && tool !is InTurnTool, granting = { grants.grant(turn, tool) }, diff --git a/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/turn/AgentTurnRunner.kt b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/turn/AgentTurnRunner.kt index 1bfadc00..29ed4288 100644 --- a/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/turn/AgentTurnRunner.kt +++ b/libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/turn/AgentTurnRunner.kt @@ -816,8 +816,16 @@ internal class AgentTurnRunner( for (call in calls) { launch { slots.withPermit { - val end = - executor.execute(turn, call, tools.offered, timeout, tools.callable, taint, tracked) + val end = executor.execute( + turn, + call, + tools.offered, + timeout, + tools.callable, + taint, + tracked, + plan.trigger, + ) reads.reason(turn, call.name, end.touched)?.let { taint.mark(it) } taint.record() val found = projects.inspect(turn, end.touched, budget) diff --git a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/job/JobsTest.kt b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/job/JobsTest.kt index bc44769b..e7da5ce1 100644 --- a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/job/JobsTest.kt +++ b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/job/JobsTest.kt @@ -98,7 +98,7 @@ class JobsTest { { _, _ -> null }, ) val name = tool.definition.name - return executor.execute(turn, call, mapOf(name to tool), timeout, setOf(name), taint, DirectCalls) + return executor.execute(turn, call, mapOf(name to tool), timeout, setOf(name), taint, DirectCalls, null) } private suspend fun recorded(): Set = JobRecords(records.of("alexandrite-agent")).all().keys diff --git a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/permission/PermissionStatusToolTest.kt b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/permission/PermissionStatusToolTest.kt index 0d26a0c8..dc8d1e9c 100644 --- a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/permission/PermissionStatusToolTest.kt +++ b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/permission/PermissionStatusToolTest.kt @@ -86,6 +86,7 @@ class PermissionStatusToolTest { ConversationDirectories(ConventionalAgentPlaces(hostPaths(null)), settings("{}")), null, taint, + null, ) @Test diff --git a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutorTest.kt b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutorTest.kt index c26856ba..59555e6f 100644 --- a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutorTest.kt +++ b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutorTest.kt @@ -126,7 +126,7 @@ class ToolExecutorTest { private suspend fun ToolExecutor.executed(tool: Tool, timeout: Duration = 120.seconds): ToolCallEnd { val name = tool.definition.name - return execute(turn, call, mapOf(name to tool), timeout, setOf(name), taint, DirectCalls) + return execute(turn, call, mapOf(name to tool), timeout, setOf(name), taint, DirectCalls, null) } private fun ToolResultEntry.text(): String = content.joinToString("") { (it as TextPart).text } @@ -158,7 +158,7 @@ class ToolExecutorTest { val edits = ToolCallPart(ToolCallId("call-1"), "fs.edit", "{}") executor( gate, - ).execute(turn, edits, mapOf("fs.edit" to edit), 120.seconds, setOf("fs.edit"), taint, calls) + ).execute(turn, edits, mapOf("fs.edit" to edit), 120.seconds, setOf("fs.edit"), taint, calls, null) ended.single() } diff --git a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/turn/FirstAnswerTest.kt b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/turn/FirstAnswerTest.kt index e264fd87..dadb0378 100644 --- a/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/turn/FirstAnswerTest.kt +++ b/libraries/agent/src/test/kotlin/org/foedusprogramme/alexandrite/agent/turn/FirstAnswerTest.kt @@ -96,13 +96,14 @@ class FirstAnswerTest { private suspend fun end(turn: TurnId): TurnEndKind? = store.conversations.turn(turn)?.end - private fun framing(chat: String = "direct"): ContextPart = ContextPart( + private fun framing(chat: String = "direct", id: String = "in-1"): ContextPart = ContextPart( "alexandrite.message", listOf( "[alexandrite:message]", "From: Member", "Operator: no", "Received: 2026-01-01T00:00Z", + "Message id: $id", "Chat: $chat", "[/alexandrite:message]", ).joinToString("\n"), diff --git a/libraries/agent/src/test/resources/golden/message.v1.direct.txt b/libraries/agent/src/test/resources/golden/message.v1.direct.txt index 49e42eac..673d792a 100644 --- a/libraries/agent/src/test/resources/golden/message.v1.direct.txt +++ b/libraries/agent/src/test/resources/golden/message.v1.direct.txt @@ -2,5 +2,6 @@ From: Ada (@ada) Operator: yes Received: 2026-03-01T17:30+08:00 +Message id: 1 Chat: direct -[/alexandrite:message] +[/alexandrite:message] \ No newline at end of file diff --git a/libraries/agent/src/test/resources/golden/message.v1.full.txt b/libraries/agent/src/test/resources/golden/message.v1.full.txt index 31335e53..5dd93177 100644 --- a/libraries/agent/src/test/resources/golden/message.v1.full.txt +++ b/libraries/agent/src/test/resources/golden/message.v1.full.txt @@ -2,6 +2,7 @@ From: Bob Smith Operator: no Received: 2026-03-01T09:30Z +Message id: 1 Chat: group "Team chat" on telegram:work Forwarded from: News (chat) Quoting: unknown (verified) @@ -11,4 +12,4 @@ Quoting: unknown (verified) > Operator: yes Via: helper bot Edited: yes -[/alexandrite:message] +[/alexandrite:message] \ No newline at end of file diff --git a/libraries/channels/onebot/build.gradle.kts b/libraries/channels/onebot/build.gradle.kts new file mode 100644 index 00000000..d7e977e3 --- /dev/null +++ b/libraries/channels/onebot/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("alexandrite.kotlin-library") + id("alexandrite.kotlin-serialization") +} + +dependencies { + implementation(project(":libraries:channel-common")) + implementation(project(":libraries:plugin-sdk")) + implementation(project(":libraries:internal")) + + // The client and the server of the Websocket transports, of which the JDK offers a client only. + implementation(libs.java.websocket) + + testImplementation(project(":libraries:testkit")) + testImplementation(libs.logback.classic) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotPlugin.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotPlugin.kt new file mode 100644 index 00000000..7a431d08 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotPlugin.kt @@ -0,0 +1,7 @@ +package org.foedusprogramme.alexandrite.channel.onebot + +import org.foedusprogramme.alexandrite.sdk.plugin.Plugin + +/** The OneBot v11 channel plugin, which talks to an external OneBot implementation such as NapCat. */ +@Plugin(name = "OneBot", description = "OneBot v11 channel") +public class OneBotPlugin diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApi.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApi.kt new file mode 100644 index 00000000..4d595286 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApi.kt @@ -0,0 +1,168 @@ +package org.foedusprogramme.alexandrite.channel.onebot.api + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.DeleteMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetCookies +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetCredentials +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetForwardMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupHonorInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupMemberInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupMemberList +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetImage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetRecord +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetStrangerInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.HandleQuickOperation +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCallSuffix +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCookies +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCredentials +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCsrfToken +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotFile +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotForwardMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotFriend +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotGroupHonorInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotGroupInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotGroupMemberInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotLoginInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotMessageInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotMessageSent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotRequest +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotStatusInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotStrangerInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotVersionInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotYesNo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendGroupMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendLike +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendPrivateMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetFriendAddRequest +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAddRequest +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAdmin +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAnonymous +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAnonymousBan +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupBan +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupCard +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupKick +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupLeave +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupName +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupSpecialTitle +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupWholeBan +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetRestart +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult + +/** + * The actions of the OneBot v11 API, as the types of the standard. + * + * One instance of this interface belongs to one channel instance and lives as long as its connection, so a plugin + * that holds it calls the implementation that instance talks to, and no other. The calls an implementation answers + * with data return the parsed type; the rest answer with nothing, and a call this version does not model goes + * through [raw]. + * + * Every call returns a [OneBotResult], which keeps the difference between an implementation that refused the call, a + * peer that could not be reached and a call that worked. + */ +public interface OneBotApi { + // Messages + + public suspend fun sendPrivateMsg(request: SendPrivateMessage): OneBotResult + + public suspend fun sendGroupMsg(request: SendGroupMessage): OneBotResult + + public suspend fun sendMsg(request: SendMessage): OneBotResult + + public suspend fun deleteMsg(request: DeleteMessage): OneBotResult + + public suspend fun getMsg(request: GetMessage): OneBotResult + + public suspend fun getForwardMsg(request: GetForwardMessage): OneBotResult + + // Groups and friends + + public suspend fun sendLike(request: SendLike): OneBotResult + + public suspend fun setGroupKick(request: SetGroupKick): OneBotResult + + public suspend fun setGroupBan(request: SetGroupBan): OneBotResult + + public suspend fun setGroupAnonymousBan(request: SetGroupAnonymousBan): OneBotResult + + public suspend fun setGroupWholeBan(request: SetGroupWholeBan): OneBotResult + + public suspend fun setGroupAdmin(request: SetGroupAdmin): OneBotResult + + public suspend fun setGroupAnonymous(request: SetGroupAnonymous): OneBotResult + + public suspend fun setGroupCard(request: SetGroupCard): OneBotResult + + public suspend fun setGroupName(request: SetGroupName): OneBotResult + + public suspend fun setGroupLeave(request: SetGroupLeave): OneBotResult + + public suspend fun setGroupSpecialTitle(request: SetGroupSpecialTitle): OneBotResult + + public suspend fun setFriendAddRequest(request: SetFriendAddRequest): OneBotResult + + public suspend fun setGroupAddRequest(request: SetGroupAddRequest): OneBotResult + + public suspend fun getLoginInfo(): OneBotResult + + public suspend fun getStrangerInfo(request: GetStrangerInfo): OneBotResult + + public suspend fun getFriendList(): OneBotResult> + + public suspend fun getGroupInfo(request: GetGroupInfo): OneBotResult + + public suspend fun getGroupList(): OneBotResult> + + public suspend fun getGroupMemberInfo(request: GetGroupMemberInfo): OneBotResult + + public suspend fun getGroupMemberList(request: GetGroupMemberList): OneBotResult> + + public suspend fun getGroupHonorInfo(request: GetGroupHonorInfo): OneBotResult + + // The account and its files + + public suspend fun getCookies(request: GetCookies): OneBotResult + + public suspend fun getCsrfToken(): OneBotResult + + public suspend fun getCredentials(request: GetCredentials): OneBotResult + + public suspend fun getRecord(request: GetRecord): OneBotResult + + public suspend fun getImage(request: GetImage): OneBotResult + + public suspend fun canSendImage(): OneBotResult + + public suspend fun canSendRecord(): OneBotResult + + // The implementation itself + + public suspend fun getStatus(): OneBotResult + + public suspend fun getVersionInfo(): OneBotResult + + public suspend fun setRestart(request: SetRestart): OneBotResult + + public suspend fun cleanCache(): OneBotResult + + public suspend fun handleQuickOperation(request: HandleQuickOperation): OneBotResult + + /** Calls an action of the implementation that this version does not model, with its answer untouched. */ + public suspend fun raw(action: String, params: JsonObject = JsonObject(emptyMap())): OneBotResult + + /** + * Calls one action through this version's own request type, under [suffix]. + * + * The typed methods above call the plain shape of an action. This is the way to a derived one, such as + * `send_group_msg_async` or `send_group_msg_rate_limited`, without giving up the request type or the answer's + * `raw` object. + */ + public suspend fun call( + action: String, + request: OneBotRequest, + suffix: OneBotCallSuffix = OneBotCallSuffix.NONE, + ): OneBotResult +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApiImpl.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApiImpl.kt new file mode 100644 index 00000000..556cceb1 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/api/OneBotApiImpl.kt @@ -0,0 +1,231 @@ +package org.foedusprogramme.alexandrite.channel.onebot.api + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotLink +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.DeleteMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetCookies +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetCredentials +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetForwardMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupHonorInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupMemberInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetGroupMemberList +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetImage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetRecord +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetStrangerInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.HandleQuickOperation +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCallSuffix +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCookies +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCredentials +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotCsrfToken +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotFile +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotForwardMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotFriend +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotGroupHonorInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotGroupInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotGroupMemberInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotLoginInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotMessageInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotMessageSent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotNoParameters +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotRequest +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotStatusInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotStrangerInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotVersionInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotYesNo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendGroupMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendLike +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendPrivateMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetFriendAddRequest +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAddRequest +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAdmin +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAnonymous +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupAnonymousBan +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupBan +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupCard +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupKick +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupLeave +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupName +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupSpecialTitle +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetGroupWholeBan +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SetRestart +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotWire +import org.foedusprogramme.alexandrite.sdk.di.Binds +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped + +/** + * The actions of one connection, sent over whichever transport the instance configured. + * + * The answer of an action that returns data is read into the type of the standard; an action this version models as + * returning nothing answers with [Unit] once the implementation accepted it. An answer whose data does not fit the + * type is reported as a malformed answer rather than being cast. + */ +@ChannelInstanceScoped +@Binds(OneBotApi::class) +internal class OneBotApiImpl(private val link: OneBotLink) : OneBotApi { + override suspend fun sendPrivateMsg(request: SendPrivateMessage): OneBotResult = + action("send_private_msg", request, OneBotMessageSent.serializer()) + + override suspend fun sendGroupMsg(request: SendGroupMessage): OneBotResult = + action("send_group_msg", request, OneBotMessageSent.serializer()) + + override suspend fun sendMsg(request: SendMessage): OneBotResult = + action("send_msg", request, OneBotMessageSent.serializer()) + + override suspend fun deleteMsg(request: DeleteMessage): OneBotResult = nothing("delete_msg", request) + + override suspend fun getMsg(request: GetMessage): OneBotResult = + action("get_msg", request, OneBotMessageInfo.serializer()) + + override suspend fun getForwardMsg(request: GetForwardMessage): OneBotResult = + action("get_forward_msg", request, OneBotForwardMessage.serializer()) + + override suspend fun sendLike(request: SendLike): OneBotResult = nothing("send_like", request) + + override suspend fun setGroupKick(request: SetGroupKick): OneBotResult = nothing("set_group_kick", request) + + override suspend fun setGroupBan(request: SetGroupBan): OneBotResult = nothing("set_group_ban", request) + + override suspend fun setGroupAnonymousBan(request: SetGroupAnonymousBan): OneBotResult = + nothing("set_group_anonymous_ban", request) + + override suspend fun setGroupWholeBan(request: SetGroupWholeBan): OneBotResult = + nothing("set_group_whole_ban", request) + + override suspend fun setGroupAdmin(request: SetGroupAdmin): OneBotResult = nothing("set_group_admin", request) + + override suspend fun setGroupAnonymous(request: SetGroupAnonymous): OneBotResult = + nothing("set_group_anonymous", request) + + override suspend fun setGroupCard(request: SetGroupCard): OneBotResult = nothing("set_group_card", request) + + override suspend fun setGroupName(request: SetGroupName): OneBotResult = nothing("set_group_name", request) + + override suspend fun setGroupLeave(request: SetGroupLeave): OneBotResult = nothing("set_group_leave", request) + + override suspend fun setGroupSpecialTitle(request: SetGroupSpecialTitle): OneBotResult = + nothing("set_group_special_title", request) + + override suspend fun setFriendAddRequest(request: SetFriendAddRequest): OneBotResult = + nothing("set_friend_add_request", request) + + override suspend fun setGroupAddRequest(request: SetGroupAddRequest): OneBotResult = + nothing("set_group_add_request", request) + + override suspend fun getLoginInfo(): OneBotResult = + action("get_login_info", OneBotNoParameters.GET_LOGIN_INFO, OneBotLoginInfo.serializer()) + + override suspend fun getStrangerInfo(request: GetStrangerInfo): OneBotResult = + action("get_stranger_info", request, OneBotStrangerInfo.serializer()) + + override suspend fun getFriendList(): OneBotResult> = + action("get_friend_list", OneBotNoParameters.GET_FRIEND_LIST, ListSerializer(OneBotFriend.serializer())) + + override suspend fun getGroupInfo(request: GetGroupInfo): OneBotResult = + action("get_group_info", request, OneBotGroupInfo.serializer()) + + override suspend fun getGroupList(): OneBotResult> = + action("get_group_list", OneBotNoParameters.GET_GROUP_LIST, ListSerializer(OneBotGroupInfo.serializer())) + + override suspend fun getGroupMemberInfo(request: GetGroupMemberInfo): OneBotResult = + action("get_group_member_info", request, OneBotGroupMemberInfo.serializer()) + + override suspend fun getGroupMemberList(request: GetGroupMemberList): OneBotResult> = + action("get_group_member_list", request, ListSerializer(OneBotGroupMemberInfo.serializer())) + + override suspend fun getGroupHonorInfo(request: GetGroupHonorInfo): OneBotResult = + action("get_group_honor_info", request, OneBotGroupHonorInfo.serializer()) + + override suspend fun getCookies(request: GetCookies): OneBotResult = + action("get_cookies", request, OneBotCookies.serializer()) + + override suspend fun getCsrfToken(): OneBotResult = + action("get_csrf_token", OneBotNoParameters.GET_CSRF_TOKEN, OneBotCsrfToken.serializer()) + + override suspend fun getCredentials(request: GetCredentials): OneBotResult = + action("get_credentials", request, OneBotCredentials.serializer()) + + override suspend fun getRecord(request: GetRecord): OneBotResult = + action("get_record", request, OneBotFile.serializer()) + + override suspend fun getImage(request: GetImage): OneBotResult = + action("get_image", request, OneBotFile.serializer()) + + override suspend fun canSendImage(): OneBotResult = + action("can_send_image", OneBotNoParameters.CAN_SEND_IMAGE, OneBotYesNo.serializer()) + + override suspend fun canSendRecord(): OneBotResult = + action("can_send_record", OneBotNoParameters.CAN_SEND_RECORD, OneBotYesNo.serializer()) + + override suspend fun getStatus(): OneBotResult = + action("get_status", OneBotNoParameters.GET_STATUS, OneBotStatusInfo.serializer()) + + override suspend fun getVersionInfo(): OneBotResult = + action("get_version_info", OneBotNoParameters.GET_VERSION_INFO, OneBotVersionInfo.serializer()) + + override suspend fun setRestart(request: SetRestart): OneBotResult = nothing("set_restart", request) + + override suspend fun cleanCache(): OneBotResult = nothing("clean_cache", OneBotNoParameters.CLEAN_CACHE) + + override suspend fun handleQuickOperation(request: HandleQuickOperation): OneBotResult = + nothing(".handle_quick_operation", request) + + override suspend fun raw(action: String, params: JsonObject): OneBotResult = link.call(action, params) + + override suspend fun call( + action: String, + request: OneBotRequest, + suffix: OneBotCallSuffix, + ): OneBotResult = link.call(action + suffix.suffix, request.toJson()) + + /** Runs an action whose data is one object of the standard. */ + private suspend fun action( + name: String, + request: OneBotRequest, + deserializer: DeserializationStrategy, + ): OneBotResult { + val answer = link.call(name, request.toJson()) + val data = when (answer) { + is OneBotResult.Ok -> answer.data + is OneBotResult.Async -> return answer + is OneBotResult.Failed -> return answer + is OneBotResult.Unreachable -> return answer + is OneBotResult.Malformed -> return answer + } + return try { + OneBotResult.Ok( + OneBotWire.decodeFromJsonElement(deserializer, data), + answer.retcode, + answer.echo, + answer.raw, + ) + } catch (e: Exception) { + // The answer goes along, so that a caller who meets an implementation whose data this version misreads + // can read the field itself instead of rebuilding the call to see it. + OneBotResult.Malformed( + "The data of $name does not fit its type: ${e.message}", + answer.echo, + answer.raw, + ) + } + } + + /** Runs an action of the standard that answers with nothing. */ + private suspend fun nothing(name: String, request: OneBotRequest): OneBotResult = + link.call(name, request.toJson()).asUnit() + + /** Turns an answer into one that carries no data. */ + private fun OneBotResult.asUnit(): OneBotResult = when (this) { + is OneBotResult.Ok -> OneBotResult.Ok(Unit, retcode, echo, raw) + is OneBotResult.Async -> OneBotResult.Async(retcode, echo, raw) + is OneBotResult.Failed -> this + is OneBotResult.Unreachable -> this + is OneBotResult.Malformed -> this + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/auth/OneBotAuth.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/auth/OneBotAuth.kt new file mode 100644 index 00000000..e140772c --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/auth/OneBotAuth.kt @@ -0,0 +1,116 @@ +package org.foedusprogramme.alexandrite.channel.onebot.auth + +import org.foedusprogramme.alexandrite.sdk.config.Secret +import java.security.MessageDigest +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * The authentication of the OneBot standard. + * + * A call carries its token either as a bearer header or as a query parameter, and a report carries an HMAC-SHA1 of + * its body under the shared secret. Neither the token nor the secret ever reaches a message: a transport masks what + * it logs with [redact], and the values only leave [Value] where a request or a comparison needs them. + */ +public object OneBotAuth { + /** The header a call carries its token in. */ + public const val AUTHORIZATION: String = "Authorization" + + /** The query parameter a call may carry its token in instead. */ + public const val TOKEN_QUERY: String = "access_token" + + /** The header a report carries its signature in. */ + public const val SIGNATURE: String = "X-Signature" + + /** The header a report names the account it is about in. */ + public const val SELF_ID: String = "X-Self-ID" + + /** The header a reverse WebSocket connection names its role in. */ + public const val CLIENT_ROLE: String = "X-Client-Role" + + /** The prefix of a signature value. */ + public const val SIGNATURE_PREFIX: String = "sha1=" + + /** The value of the [AUTHORIZATION] header for [token]. */ + public fun bearer(token: Value): String = "Bearer ${token.reveal()}" + + /** The value of the [SIGNATURE] header for [body] under [secret]. */ + public fun signature(secret: Value, body: String): String = + SIGNATURE_PREFIX + hmacSha1(secret.reveal().toByteArray(Charsets.UTF_8), body.toByteArray(Charsets.UTF_8)) + + /** Whether [signature] is the [SIGNATURE] header of [body] under [secret], whatever its case. */ + public fun matchesSignature(secret: Value, body: String, signature: String?): Boolean { + val value = signature?.trim()?.lowercase() ?: return false + val presented = value.removePrefix(SIGNATURE_PREFIX) + val expected = hmacSha1(secret.reveal().toByteArray(Charsets.UTF_8), body.toByteArray(Charsets.UTF_8)) + return MessageDigest.isEqual( + expected.lowercase().toByteArray(Charsets.UTF_8), + presented.toByteArray(Charsets.UTF_8), + ) + } + + /** The token of [authorization], which may be a bearer header or the token itself, null when it holds none. */ + public fun tokenOf(authorization: String?): String? { + val value = authorization?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val bearer = value.substringBefore(' ').lowercase() + return if (bearer == "bearer") value.substringAfter(' ').trim().ifEmpty { null } else value + } + + /** Whether [token] matches the token [presented] carries. */ + public fun matchesToken(token: Value, presented: String?): Boolean { + val expected = token.reveal() + return presented != null && MessageDigest.isEqual( + expected.toByteArray(Charsets.UTF_8), + presented.toByteArray(Charsets.UTF_8), + ) + } + + /** [uri] with the token of [token] as its query parameter, unchanged when this instance has none. */ + public fun withToken(uri: String, token: Value?): String { + if (token == null) return uri + val separator = if ('?' in uri) '&' else '?' + return "$uri$separator$TOKEN_QUERY=${urlEncode(token.reveal())}" + } + + /** + * [text] with every value of [values] replaced, so that a message carries no token and no secret. + * + * Every value that is not empty is replaced, whatever its length: an access token and a signing key have no + * minimum length, and a short one is no less a secret than a long one. + */ + public fun redact(text: String, values: Collection): String = values.asSequence() + .map { it.reveal() } + .filter { it.isNotEmpty() } + .sortedByDescending { it.length } + .fold(text) { masked, secret -> masked.replace(secret, MASKED) } + + private const val MASKED = "***" + + private fun hmacSha1(key: ByteArray, message: ByteArray): String { + val mac = Mac.getInstance("HmacSHA1") + mac.init(SecretKeySpec(key, "HmacSHA1")) + return mac.doFinal(message).joinToString("") { "%02x".format(it) } + } + + private fun urlEncode(text: String): String = java.net.URLEncoder.encode(text, Charsets.UTF_8).replace("+", "%20") +} + +/** One value of the config a transport must keep out of its logs, such as an access token or a signing key. */ +@JvmInline +public value class Value(private val secret: Secret) { + /** The value itself, for the one place that needs it. */ + public fun reveal(): String = secret.reveal() + + override fun toString(): String = secret.toString() + + public companion object { + /** A value that masks [secret]. */ + public fun of(secret: Secret): Value = Value(secret) + + /** The value of [secret], null when the instance has none. */ + public fun orNull(secret: Secret?): Value? = secret?.let(::Value) + + /** A value that masks [text], for a caller that holds the string of a token itself. */ + public fun secretOf(text: String): Value = Value(Secret(text)) + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/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/OneBotActivities.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotActivities.kt new file mode 100644 index 00000000..4a67dc74 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotActivities.kt @@ -0,0 +1,31 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import java.util.concurrent.ConcurrentHashMap + +/** + * What each chat has been told of the turns working in it, by chat. + * + * A turn that works on tools is not the only one speaking to a chat: a run moved to the background opens a reply of + * its own for the same chat, and the runtime hands each reply only its own turn's activity. A chat whose messages + * cannot be added to would then hear the same work twice, so the words of a stretch of steps are kept here, by the + * chat the turns speak to, and every reply of that chat tells what is waiting once. + */ +@Singleton +internal class OneBotActivities { + private val byChat = ConcurrentHashMap() + + /** What waits to be told in [chat], which a chat is told of once however many turns are working in it. */ + fun of(chat: ChatAddress): OneBotProgress = byChat.computeIfAbsent(chat) { OneBotProgress() } + + /** + * Forgets [chat] where it holds nothing to tell and no call still running. + * + * A turn that ends asks for this, so a chat that holds nothing is not kept for as long as the channel runs. A chat + * another turn is still working in holds something, and is left as it is. + */ + fun forget(chat: ChatAddress) { + byChat.computeIfPresent(chat) { _, progress -> if (progress.idle()) null else progress } + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApis.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApis.kt new file mode 100644 index 00000000..59e7f46e --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApis.kt @@ -0,0 +1,34 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.onebot.api.OneBotApi +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import java.util.concurrent.ConcurrentHashMap + +/** + * The API of every running OneBot instance, by the instance that serves a chat. + * + * A plugin that acts on a chat names the instance the chat belongs to, and an action of the standard is called on the + * API of that instance. The API itself belongs to one instance of one channel, because each holds its own connection + * and its own account, so a tool that runs once for the whole runtime reads the one it needs from here. + * + * An instance is here while it runs and gone once it stops, so a caller that finds none knows the channel is not + * serving that chat rather than that it never could. + */ +@Singleton +internal class OneBotApis { + private val apis = ConcurrentHashMap() + + /** Records that [instance] is served by [api]. */ + fun register(instance: ChannelInstanceId, api: OneBotApi) { + apis[instance] = api + } + + /** Forgets [instance], which stopped. */ + fun forget(instance: ChannelInstanceId) { + apis.remove(instance) + } + + /** The API of [instance], null when no such instance is running. */ + fun of(instance: ChannelInstanceId): OneBotApi? = apis[instance] +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApprovals.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApprovals.kt new file mode 100644 index 00000000..2f303c1c --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotApprovals.kt @@ -0,0 +1,458 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.onebot.i18n.TextKeys +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotChats +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalAnswer +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalRequest +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatSettings +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.chat.LanguageTag +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.foedusprogramme.alexandrite.sdk.i18n.Texts +import org.foedusprogramme.alexandrite.sdk.tool.ReviewPreview +import org.slf4j.LoggerFactory +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.minutes + +/** + * Shows approval requests as messages an admin answers by replying to them, and takes the first answer within the + * configured time. + * + * The message that carries a request is what names it: the implementation reports what a message answers as an id, so + * the id of the message this desk sent is matched against that and nothing else. Several requests can wait in one chat + * without being confused, because each was carried by a message of its own. A request is also answered by writing its + * id, which is what a client that cannot reply with needs; an id that matches several requests is never guessed at. + * + * A chat other than the one a request was shown in cannot answer it, and a request that two admins answer at once is + * answered by the first of them. + */ +@ChannelInstanceScoped +internal class OneBotApprovals( + private val instance: ChannelInstance, + private val config: OneBotInstanceConfig, + private val sender: OneBotSender, + private val texts: Texts, + /** The language of each chat, null while the runtime has no agent to ask. */ + private val settings: ChatSettings?, +) : Lifecycle { + private val pending = ConcurrentHashMap() + private val work = SupervisorJob(instance.scope.coroutineContext[Job]) + private val scope = CoroutineScope(instance.scope.coroutineContext + work) + + /** The answer of the first admin who answers [request], else how the request ended. */ + suspend fun ask(request: ApprovalRequest): ApprovalAnswer { + val chats = recipients(request) + if (chats.isEmpty()) return UNREACHABLE + val waiting = Pending(request) + if (pending.putIfAbsent(request.id, waiting) != null) { + return ApprovalAnswer.Unavailable("another request has the id ${request.id}") + } + try { + // A chat nobody could be shown in is no reach, and what the platform said is the reason a call was refused + // rather than that no administrator exists. + val why = chats.mapNotNull { post(waiting, it, languageOf(waiting.request, it)) }.firstOrNull() + if (waiting.carried.isEmpty()) { + logger.warn("{} could not show approval {} in any chat: {}", instance.id, request.id, why) + return if (why == + null + ) { + UNREACHABLE + } else { + ApprovalAnswer.Unavailable("the platform took no message of it: $why") + } + } + logger.info( + "{} asks for approval {} of {} in turn {} in {} chat(s)", + instance.id, + request.id, + request.tool, + request.turn.id, + waiting.carried.size, + ) + withTimeoutOrNull(config.approvalMinutes.minutes) { waiting.ending.await() } + waiting.ending.complete(Ending.Expired) + val ending = waiting.ending.await() + conclude(waiting, ending) + return ending.answer + } catch (e: CancellationException) { + waiting.ending.complete(Ending.Withdrawn) + conclude(waiting, Ending.Withdrawn) + throw e + } finally { + pending.remove(request.id) + } + } + + /** Whether [name] is a command this desk answers. */ + fun answers(name: String): Boolean = name == APPROVE || name == DENY + + /** + * Takes the approval command [command] that [by] wrote in [event], and the text to reply with, null when the reply + * would say nothing. + */ + suspend fun answer(event: OneBotEvent.Message, command: OneBotCommands.Parsed, by: ChatUser): String? { + val chat = OneBotChats.addressOf(instance.id, event) + if (!by.isAdmin) return text(TextKeys.APPROVAL_ADMINS_ONLY, language(chat)) + val words = command.arguments.split(' ').filter { it.isNotBlank() } + val always = words.any { it.equals(ALWAYS, ignoreCase = true) } + val code = words.firstOrNull { !it.equals(ALWAYS, ignoreCase = true) } + return when (val resolution = resolve(chat, event, code)) { + Resolution.None -> text(TextKeys.APPROVAL_NOTHING_WAITS, language(chat)) + is Resolution.Listing -> listing(resolution.requests, language(chat)) + is Resolution.Ambiguous -> several(resolution.ids, language(chat)) + is Resolution.One -> decide(resolution.id, by, command.name == APPROVE, always, language(chat)) + } + } + + override suspend fun onDrain() { + work.children.toList().joinAll() + } + + override fun onStop() { + pending.values.forEach { it.ending.complete(Ending.Answered(ApprovalAnswer.Unavailable("the bot stopped"))) } + } + + /** The chats [request] is shown in: the chat of an admin's own turn, else the direct chats of the admins. */ + private fun recipients(request: ApprovalRequest): List { + val turn = request.turn + return if (turn.actorIsAdmin) { + listOf(turn.chat) + } else { + config.admins.distinct().map { OneBotChats.privateChat(instance.id, UserId(it)) } + } + } + + /** Sends the message with [waiting]'s request to [chat], and says what went wrong when it was not shown. */ + private suspend fun post(waiting: Pending, chat: ChatAddress, language: LanguageTag?): String? { + val text = render(waiting.request, language) + // An approval is worth more than one try: an implementation that times out on a message often takes the next + // one, and a request nobody was shown refuses the call for a reason that is nobody's. What the platform said + // is kept, so that a request that reached no chat is not answered as though no admin could be reached. + var why: String? = null + for (attempt in 1..APPROVAL_TRIES) { + val delivery = try { + sender.send(chat, OutboundMessage.builder(text, MessageKind.REPLY).build()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + why = e.message + logger.warn( + "{} could not show approval {} in chat {} (try {}): {}", + instance.id, + waiting.request.id, + chat, + attempt, + e.message, + ) + null + } + val delivered = delivery as? Delivery.Delivered + if (delivered != null) { + val carried = waiting.carried.getOrPut(chat) { ConcurrentHashMap.newKeySet() } + delivered.messages.forEach { carried += it.id } + return null + } + if (delivery != null) { + why = delivery.toString() + logger.warn( + "{} could not show approval {} in chat {} (try {}): {}", + instance.id, + waiting.request.id, + chat, + attempt, + delivery, + ) + } + if (attempt < APPROVAL_TRIES) delay(APPROVAL_RETRY_MILLIS) + } + return why ?: "the platform took no message of it" + } + + /** What the command of [chat] names, read from the message it replied to and else from its id. */ + private fun resolve(chat: ChatAddress, event: OneBotEvent.Message, code: String?): Resolution { + val replied = event.message.segments.filterIsInstance().firstOrNull()?.id?.value + if (replied != null) { + val held = pending.values.filter { replied in (it.carried[chat] ?: emptySet()) } + return when (held.size) { + 1 -> Resolution.One(held.single().request.id) + 0 -> Resolution.None + else -> Resolution.Ambiguous(held.map { it.request.id }) + } + } + val waiting = pending.values.filter { chat in it.carried.keys } + if (code == + null + ) { + return if (waiting.isEmpty()) Resolution.None else Resolution.Listing(waiting.map { it.request }) + } + // An id written out is matched exactly first: a request whose id is the whole of what was written is the one + // meant, even when the id of another request starts with the same letters. + waiting.firstOrNull { it.request.id == code }?.let { return Resolution.One(it.request.id) } + val matched = waiting.filter { it.request.id.startsWith(code) } + return when (matched.size) { + 1 -> Resolution.One(matched.single().request.id) + 0 -> Resolution.None + else -> Resolution.Ambiguous(matched.map { it.request.id }) + } + } + + /** + * Answers the request [id], and what to reply with, null when nothing is said. + * + * An answer that is taken is said by the outcome of the request, which goes to every chat it was shown in, so + * nothing is said in the chat the command was written in as well: that would say the same thing twice. Only an + * answer that could not be taken is said here, because nothing else reports it. + */ + private fun decide(id: String, by: ChatUser, allowed: Boolean, always: Boolean, language: LanguageTag?): String? { + val waiting = pending[id] ?: return text(TextKeys.APPROVAL_NOT_PENDING, language) + val request = waiting.request + val answer = if (allowed) { + ApprovalAnswer.Allowed(by, always && request.always != null) + } else { + ApprovalAnswer.Denied(by) + } + if (!waiting.ending.complete(Ending.Answered(answer))) { + return text(TextKeys.APPROVAL_NOT_PENDING, language) + } + return null + } + + /** The requests that wait in the chat the command was written in. */ + private fun listing(requests: List, language: LanguageTag?): String { + val entries = requests.map { + text( + TextKeys.APPROVAL_LIST_ENTRY, + language, + "id" to it.id, + "tool" to it.tool, + "reason" to it.reason, + ) + } + return listOf(text(TextKeys.APPROVAL_LIST_TITLE, language)) + .plus(entries) + .plus(text(TextKeys.APPROVAL_LIST_HINT, language)) + .joinToString("\n") + } + + /** The ids that a written id matched, which name no one request. */ + private fun several(ids: List, language: LanguageTag?): String = + text(TextKeys.APPROVAL_SEVERAL, language, "ids" to ids.joinToString(", ")) + + /** Says how [waiting] ended, in each chat that was shown it. */ + private fun conclude(waiting: Pending, ending: Ending) { + val request = waiting.request + logger.info("{}: approval {} of turn {} {}", instance.id, request.id, request.turn.id, ending.logged) + // The standard has no way to change a message, so how a request ended is said in a message of its own rather + // than written into the one that carried it. + val chats = waiting.carried.keys.toList() + scope.launch { + for (chat in chats) { + val line = outcome(request, ending, language(chat)) ?: continue + try { + sender.send(chat, OutboundMessage.builder(line, MessageKind.REPLY).build()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn( + "{} could not say how approval {} ended in {}: {}", + instance.id, + request.id, + chat, + e.message, + ) + } + } + } + } + + /** The line that tells how [request] ended in [language], null for no line. */ + private fun outcome(request: ApprovalRequest, ending: Ending, language: LanguageTag?): String? = when (ending) { + Ending.Expired -> text(TextKeys.APPROVAL_OUTCOME_EXPIRED, language, "id" to request.id) + + Ending.Withdrawn -> text(TextKeys.APPROVAL_OUTCOME_WITHDRAWN, language, "id" to request.id) + + is Ending.Answered -> when (val answer = ending.answer) { + is ApprovalAnswer.Allowed -> if (answer.always) { + text( + TextKeys.APPROVAL_OUTCOME_ALLOWED_ALWAYS, + language, + "id" to request.id, + "name" to named(answer.by), + "rule" to request.always?.display.orEmpty(), + ) + } else { + text( + TextKeys.APPROVAL_OUTCOME_ALLOWED, + language, + "id" to request.id, + "name" to named(answer.by), + ) + } + + is ApprovalAnswer.Denied -> text( + TextKeys.APPROVAL_OUTCOME_DENIED, + language, + "id" to request.id, + "name" to named(answer.by), + ) + + else -> null + } + } + + /** [request] as the message an approver reads in [language]. */ + private fun render(request: ApprovalRequest, language: LanguageTag?): String = buildString { + val turn = request.turn + append(text(TextKeys.APPROVAL_TITLE, language, "id" to request.id)).append('\n') + append(text(TextKeys.APPROVAL_AGENT, language, "agent" to turn.agent)).append('\n') + append(text(TextKeys.APPROVAL_CHAT, language, "chat" to turn.chat)).append('\n') + turn.actor?.let { + append(text(TextKeys.APPROVAL_REQUESTER, language, "requester" to it.displayName)).append('\n') + } + append(text(TextKeys.APPROVAL_REASON, language, "reason" to request.reason)).append('\n') + append( + text( + TextKeys.APPROVAL_TOOL, + language, + "tool" to request.tool, + "risk" to request.risk.name.lowercase(), + ), + ).append("\n\n") + append(body(request, language)) + append("\n\n").append(text(TextKeys.APPROVAL_ANSWER, language)) + request.always?.let { + append(' ').append(text(TextKeys.APPROVAL_ALWAYS_RULE, language, "rule" to it.display)) + } + } + + /** What [request] is about in [language]: its preview, else its arguments. */ + private fun body(request: ApprovalRequest, language: LanguageTag?): String = when (val preview = request.preview) { + is ReviewPreview.Diff -> + text(TextKeys.APPROVAL_FILE, language, "path" to preview.path) + "\n\n" + preview.unified + + is ReviewPreview.Command -> buildString { + append(text(TextKeys.APPROVAL_DIRECTORY, language, "directory" to preview.cwd)).append('\n') + append( + text( + TextKeys.APPROVAL_PROGRAM, + language, + "program" to (preview.executable ?: text(TextKeys.APPROVAL_PROGRAM_UNKNOWN, language)), + ), + ) + if (preview.opaque) append(' ').append(text(TextKeys.APPROVAL_OPAQUE, language)) + append("\n\n").append(commandLine(preview.argv)) + } + + is ReviewPreview.Text -> preview.text + + else -> text(TextKeys.APPROVAL_ARGUMENTS, language) + "\n" + request.arguments + } + + /** One request on its way, with the messages that carry it by the chat they are in. */ + private class Pending(val request: ApprovalRequest) { + val ending = CompletableDeferred() + + /** The ids of the messages that carry the request, by the chat each was sent to. */ + val carried = ConcurrentHashMap>() + } + + /** What a command names. */ + private sealed interface Resolution { + data object None : Resolution + + class One(val id: String) : Resolution + + class Ambiguous(val ids: List) : Resolution + + class Listing(val requests: List) : Resolution + } + + private sealed interface Ending { + val answer: ApprovalAnswer + val logged: String + + class Answered(override val answer: ApprovalAnswer) : Ending { + override val logged: String + get() = when (answer) { + is ApprovalAnswer.Allowed -> "is allowed by ${answer.by.address}" + is ApprovalAnswer.Denied -> "is denied by ${answer.by.address}" + else -> "ends: $answer" + } + } + + data object Expired : Ending { + override val answer: ApprovalAnswer = ApprovalAnswer.Expired + override val logged: String = "expired" + } + + data object Withdrawn : Ending { + override val answer: ApprovalAnswer = ApprovalAnswer.Unavailable("the request was withdrawn") + override val logged: String = "is withdrawn" + } + } + + private companion object { + const val APPROVE = "approve" + const val DENY = "deny" + const val ALWAYS = "always" + + val UNREACHABLE = ApprovalAnswer.Unavailable("no administrator can be reached") + + /** How often the message that carries a request is tried, and how long between two tries. */ + const val APPROVAL_TRIES: Int = 3 + + const val APPROVAL_RETRY_MILLIS: Long = 1_000 + } + + /** [key] in [language], with [args] put where they are named. */ + private fun text(key: String, language: LanguageTag?, vararg args: Pair): String = + texts.text(key, language, *args) + + /** The language of [chat], null when the runtime has no agent to ask and the host's language applies. */ + private suspend fun language(chat: ChatAddress): LanguageTag? = try { + settings?.language(chat) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.debug("The language of chat {} is unknown: {}", chat, e.toString()) + null + } + + /** The language [request] is shown in [chat] with: the chat of its own turn keeps the language of that turn. */ + private suspend fun languageOf(request: ApprovalRequest, chat: ChatAddress): LanguageTag? = + if (chat == request.turn.chat) request.turn.language else language(chat) +} + +/** [user]'s name as an approver sees it. */ +private fun named(user: ChatUser): String = user.displayName + user.username?.let { " (@$it)" }.orEmpty() + +/** [argv] as one command line, each word that a shell would split or expand in single quotes. */ +private fun commandLine(argv: List): String = argv.joinToString(" ") { word -> + val plain = word.isNotEmpty() && word.all { it.isLetterOrDigit() || it in PLAIN } + if (plain) word else "'${word.replace("'", "'\\''")}'" +} + +/** The characters a shell takes as part of a word. */ +private const val PLAIN = "-_./=:,+@%" + +private val logger = LoggerFactory.getLogger(OneBotApprovals::class.java) diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotChannel.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotChannel.kt new file mode 100644 index 00000000..baed1f12 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotChannel.kt @@ -0,0 +1,276 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.foedusprogramme.alexandrite.channel.onebot.api.OneBotApi +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalAnswer +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalRequest +import org.foedusprogramme.alexandrite.sdk.channel.Channel +import org.foedusprogramme.alexandrite.sdk.channel.ChannelCapabilities +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +import org.foedusprogramme.alexandrite.sdk.channel.DelegatedRun +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.Markup +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.channel.ReplyActivity +import org.foedusprogramme.alexandrite.sdk.channel.ReplyEnd +import org.foedusprogramme.alexandrite.sdk.channel.ReplyRequest +import org.foedusprogramme.alexandrite.sdk.channel.ReplySink +import org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus +import org.foedusprogramme.alexandrite.sdk.channel.TurnEvent +import org.foedusprogramme.alexandrite.sdk.channel.rebuild +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.i18n.Texts +import org.foedusprogramme.alexandrite.sdk.transcript.AssistantEntry +import org.foedusprogramme.alexandrite.sdk.transcript.TextPart +import org.foedusprogramme.alexandrite.sdk.turn.TurnSubmitter +import org.slf4j.LoggerFactory +import kotlinx.coroutines.channels.Channel as Signal + +/** + * The OneBot front end of one channel instance. + * + * A message the implementation reported becomes an [IncomingMessage] and goes to the agent through the + * [TurnSubmitter], and what the agent answers goes back as segments over the actions of the standard. Which + * transport carries either is [OneBotLink]'s business, not this class's. + * + * What the model says while it works reaches a chat as whole messages, one per request of the turn: the hook of + * [OneBotWordsHook] hands each response to the reply of its turn as the response ends, because a message of this + * platform cannot be added to after it is sent. + */ +@ChannelInstanceScoped +@Contribute(Channel::class, name = "onebot") +internal class OneBotChannel( + private val instance: ChannelInstance, + private val config: OneBotInstanceConfig, + private val sender: OneBotSender, + /** The words this instance says to people. */ + private val texts: Texts, + + /** The approvals this instance asks its admins about. */ + private val approvals: OneBotApprovals, + /** The chats this instance serves, which decides whether it speaks unprompted. */ + private val gate: OneBotAccessGate, + /** The agent, null while the runtime has none. */ + private val submitter: TurnSubmitter?, + /** The replies of this instance's turns, which the words of each response are handed to. */ + private val replies: OneBotReplyRegistry, + /** What waits to be told in each conversation, which every reply of that conversation shares. */ + private val activities: OneBotActivities, + private val rules: OneBotCommandRules, +) : Channel { + override suspend fun capabilities(chat: ChatAddress): ChannelCapabilities = OneBotMessages.capabilities( + proactive = gate.isPaired(chat), + progress = config.progress, + streaming = config.streaming, + ) + + override suspend fun partsNeeded(chat: ChatAddress, text: String, markup: Markup): Int = + if (text.isEmpty()) 0 else 1 + + override suspend fun openReply(request: ReplyRequest): ReplySink = + Sink(this, request).also { replies.open(request.turn.id, it) } + + override suspend fun send(chat: ChatAddress, message: OutboundMessage): Delivery = sender.send(chat, message) + + override suspend fun askApproval(request: ApprovalRequest): ApprovalAnswer = approvals.ask(request) + + override fun mayRun(chat: ChatAddress, issuer: ChatUser, command: String, withArguments: Boolean): Boolean = + rules.mayRun(chat, issuer.isAdmin, command, withArguments) + + /** + * The reply of one turn, which delivers the words of each of the turn's requests and the final message. + * + * A message of this platform cannot be added to later, so [showResponse] sends a response whole, once, as the hook + * hands it over. [preview] keeps nothing: the growing text a channel that can change its messages shows is not what + * this one says. + */ + private class Sink(private val channel: OneBotChannel, private val request: ReplyRequest) : + ReplySink, + OneBotWords { + // The steps of every turn working in this chat wait here, so a chat hears them once. + private val progress = channel.activities.of(request.turn.chat) + + override suspend fun detachActivity(run: DelegatedRun): ReplyActivity? { + if (!channel.config.progress) return null + return Detached(run) + } + + /** The activity of a background run, told as the notices OneBot already uses for a working turn. */ + private inner class Detached(private val run: DelegatedRun) : ReplyActivity { + override suspend fun event(event: TurnEvent) { + val started = event as? TurnEvent.ToolCallStarted ?: return + if (started.run != run.run) return + post( + startedMessage( + ProgressCall(started.tool, started.activity, started.title, started.agent, started.run), + channel.texts, + request.turn.language, + ), + ) + } + + override suspend fun close(status: ToolCallStatus) = Unit + } + + /** What the sender beside the turn is to say next, under [lock] because an ending reply writes it too. */ + private val lock = Any() + private var waiting: String? = null + + /** Woken whenever something waits to be said. */ + private val wake = Signal(Signal.CONFLATED) + + /** The sender working beside the turn, null until something waits to be said. */ + private var sender: Job? = null + + override suspend fun preview(segment: Int, text: String) { + if (!channel.config.streaming) return + // The previews of a growing segment are frequent and are never sent, so only a reader of the debug log + // is told of them. + logger.debug( + "The preview of segment {} ({} characters) is not sent; the response is sent as it ends", + segment, + text.length, + ) + } + + override suspend fun showResponse(message: AssistantEntry) { + val said = message.parts.filterIsInstance().joinToString("\n\n") { it.text } + if (said.isBlank()) return + logger.info( + "The response of {} ({} characters) goes to {}", + request.turn.id, + said.length, + request.turn.chat, + ) + // The model writes Markdown, and a message of this platform shows none: the channel renders it as the + // plain text a chat reads, as it does for the answer that ends a turn. + say(said, Markup.MARKDOWN) + } + + override suspend fun event(event: TurnEvent) { + if (!channel.config.progress) return + when (event) { + is TurnEvent.ToolCallStarted -> { + val call = progress.started( + event.id, + event.activity, + event.agent, + tool = event.tool, + title = event.title, + run = event.run, + ) ?: return + logger.info("The first call of {} is told: {}", request.turn.id, event.tool) + post(startedMessage(call, channel.texts, request.turn.language)) + } + + is TurnEvent.ToolCallUpdated -> { + if (event.status !in ENDED) return + progress.ended(event.id, event.status, event.content, event.run) + logger.debug("The call {} ended, and {} steps wait to be told", event.id, progress.counted()) + val tally = progress.take() + if (tally == null) { + logger.debug("What ended is held back until the interval passes") + return + } + post(summaryMessage(tally, channel.texts, request.turn.language)) + } + + else -> return + } + } + + /** + * Hands [said] to the sender beside the turn, which this returns from at once. + * + * A turn waits for every event it is handed, and sending here would hold it for one call of the platform per + * message about its own work. The sender takes what waits and sends it beside the turn instead, so nothing the + * turn does waits on a message about it. + */ + private fun post(said: String) { + synchronized(lock) { waiting = said } + if (sender == null) { + sender = channel.instance.scope.launch(CoroutineName("onebot activity of ${request.turn.id}")) { + while (true) { + wake.receive() + while (true) { + val next = synchronized(lock) { waiting?.also { waiting = null } } ?: break + try { + say(next) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn( + "An activity of {} did not reach {}: {}", + request.turn.id, + request.turn.chat, + e.toString(), + ) + } + } + } + } + } + wake.trySend(Unit) + } + + /** Sends what the sender holds, and stops it, so that nothing of it is still on its way. */ + private suspend fun settle() { + val last = synchronized(lock) { waiting?.also { waiting = null } } + sender?.let { + it.cancel() + it.join() + } + sender = null + if (last != null) say(last) + } + + /** Sends [said] to the chat of this turn as a message of its own, in [markup]. */ + private suspend fun say(said: String, markup: Markup = Markup.PLAIN) { + val message = OutboundMessage.builder(said, MessageKind.REPLY).markup(markup).build() + channel.sender.send(request.turn.chat, message) + } + + override suspend fun complete(message: OutboundMessage): Delivery { + logger.info( + "The turn of {} ended: its {} waiting steps are told before the answer, and its reply closes", + request.turn.chat, + progress.counted(), + ) + settle() + progress.flush()?.let { say(summaryMessage(it, channel.texts, request.turn.language)) } + channel.replies.close(request.turn.id) + channel.activities.forget(request.turn.chat) + return channel.sender.send(request.turn.chat, withTrigger(message)) + } + + /** [message] as the answer to the message this turn answers, so the chat shows it as a quote reply. */ + private fun withTrigger(message: OutboundMessage): OutboundMessage { + val trigger = request.trigger ?: return message + if (message.replyTo != null) return message + return message.rebuild { replyTo(trigger) } + } + + override suspend fun abandon(end: ReplyEnd) { + logger.info( + "The turn of {} was abandoned: its {} waiting steps are told, and its reply closes", + request.turn.chat, + progress.counted(), + ) + settle() + progress.flush()?.let { say(summaryMessage(it, channel.texts, request.turn.language)) } + channel.replies.close(request.turn.id) + channel.activities.forget(request.turn.chat) + } + } +} + +private val logger = LoggerFactory.getLogger(OneBotChannel::class.java) diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRules.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRules.kt new file mode 100644 index 00000000..b4d54f05 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRules.kt @@ -0,0 +1,99 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.onebot.i18n.TextKeys +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.turn.AgentControl +import org.foedusprogramme.alexandrite.sdk.turn.CommandHandler +import org.foedusprogramme.alexandrite.sdk.turn.CommandInvocation +import java.util.Locale + +/** Who may run a command. */ +internal enum class Who { + ANYONE, + + /** Anyone in a direct chat, admins elsewhere. */ + DIRECT, + + /** Anyone in a direct chat linked with no other chat, admins elsewhere. */ + UNLINKED_DIRECT, + + ADMIN, +} + +/** Who may run a command: [bare] without arguments, [withArguments] with them. */ +internal class CommandRule(val bare: Who, val withArguments: Who = bare) + +/** + * Who may run each command that a handler claims, after [MATRIX]: admins alone for a claimed command it does not list. + * + * Nothing else checks this. The agent's control takes the operator of a change as a parameter and does not judge it, + * so a channel that does not ask this question lets anyone who can write in a chat change the agent's model, its + * language, and the agent a chat speaks to. + * + * The rules are the same ones the Telegram channel applies, and this is a copy of them rather than the same code: they + * belong with the rest of what every channel shares, and they are here until that is decided. + */ +@Singleton +internal class OneBotCommandRules(handlers: List, private val control: AgentControl?) { + private val claimed = handlers.flatMap { it.commands }.mapTo(HashSet()) { it.name.lowercase(Locale.ROOT) } + + /** The key of the refusal of [invocation] in a chat of [kind], null when its issuer may run it. */ + fun refusal(invocation: CommandInvocation, kind: ChatKind): String? { + val name = invocation.name.lowercase(Locale.ROOT) + if (invocation.issuer.isAdmin || name !in claimed) return null + val rule = MATRIX[name] ?: ADMIN_ONLY + val direct = kind == ChatKind.DIRECT + return when (if (invocation.arguments.isBlank()) rule.bare else rule.withArguments) { + Who.ANYONE -> null + Who.DIRECT -> TextKeys.REFUSED_ADMIN_HERE.takeUnless { direct } + Who.UNLINKED_DIRECT -> TextKeys.REFUSED_ADMIN_HERE.takeUnless { direct && !linked(invocation.chat) } + Who.ADMIN -> TextKeys.REFUSED_ADMIN + } + } + + /** Whether an admin, or a member when [admin] is false, may run [command] in [chat]. */ + fun mayRun(chat: ChatAddress, admin: Boolean, command: String, withArguments: Boolean): Boolean { + val name = command.lowercase(Locale.ROOT) + if (admin || name !in claimed) return true + val rule = MATRIX[name] ?: ADMIN_ONLY + val direct = !OneBotMessages.isGroup(chat) + return when (if (withArguments) rule.withArguments else rule.bare) { + Who.ANYONE -> true + Who.DIRECT -> direct + Who.UNLINKED_DIRECT -> direct && !linked(chat) + Who.ADMIN -> false + } + } + + private fun linked(chat: ChatAddress): Boolean = control?.links().orEmpty().any { group -> + (group.members + group.anchor).any { it.parent == chat.parent } + } + + internal companion object { + /** The rules of the default commands. */ + val MATRIX: Map = mapOf( + "help" to CommandRule(Who.ANYONE), + "settings" to CommandRule(Who.ANYONE), + "context" to CommandRule(Who.ANYONE), + "profiles" to CommandRule(Who.ANYONE), + "agent" to CommandRule(Who.ANYONE, Who.DIRECT), + "reasoning" to CommandRule(Who.ANYONE, Who.UNLINKED_DIRECT), + "language" to CommandRule(Who.ANYONE, Who.UNLINKED_DIRECT), + "model" to CommandRule(Who.ANYONE, Who.ADMIN), + "mode" to CommandRule(Who.ANYONE, Who.ADMIN), + "jobs" to CommandRule(Who.ANYONE, Who.ADMIN), + "new" to CommandRule(Who.UNLINKED_DIRECT), + "compact" to CommandRule(Who.UNLINKED_DIRECT), + "cancel" to CommandRule(Who.DIRECT), + "trust" to CommandRule(Who.ADMIN), + "untrust" to CommandRule(Who.ADMIN), + "allow" to CommandRule(Who.ADMIN), + "streaming" to CommandRule(Who.ANYONE, Who.DIRECT), + ) + + private val ADMIN_ONLY = CommandRule(Who.ADMIN) + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotInbox.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotInbox.kt new file mode 100644 index 00000000..f0ec57a2 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotInbox.kt @@ -0,0 +1,318 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.foedusprogramme.alexandrite.channel.onebot.api.OneBotApi +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotChats +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotNotices +import org.foedusprogramme.alexandrite.channel.onebot.mapping.QuotedMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.GetMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotMessageInfo +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotWire +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +import org.foedusprogramme.alexandrite.sdk.channel.ChannelPoints +import org.foedusprogramme.alexandrite.sdk.channel.IncomingMessage +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.foedusprogramme.alexandrite.sdk.hook.Hooks +import org.foedusprogramme.alexandrite.sdk.i18n.Texts +import org.foedusprogramme.alexandrite.sdk.turn.Admission +import org.foedusprogramme.alexandrite.sdk.turn.Capacity +import org.foedusprogramme.alexandrite.sdk.turn.CommandInvocation +import org.foedusprogramme.alexandrite.sdk.turn.Submission +import org.foedusprogramme.alexandrite.sdk.turn.TurnSubmitter +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * What the implementation reports, taken into the agent. + * + * A message the bot is addressed by, and a command wherever it appears, becomes a submission that the agent queues as + * a turn of its chat. Addressed means what it means to every channel of this project: a direct chat always, and a + * group when the message names the bot or answers one of its messages. Everything else a group says is not a turn, + * which is what keeps the traffic of a group out of the agent. + * + * Every other event, a notice, a request or a heartbeat, is read and dropped here: it is not a turn, and this version + * has no way to hand one to another plugin. The connection can carry it, so the way out is a decision about the shape + * of that interface rather than about this class, and until it is taken a plugin that needs a notice or a request goes + * through [OneBotApi] for the state it wants. An instance whose runtime has no agent accepts the events and hands them + * nowhere, which is reported once so that the silence is visible rather than puzzling. + */ +@ChannelInstanceScoped +internal class OneBotInbox( + private val instance: ChannelInstance, + private val config: OneBotInstanceConfig, + private val link: OneBotLink, + /** What this instance sends, which answers an approval command. */ + private val sender: OneBotSender, + /** The approvals this instance asks its admins about. */ + private val approvals: OneBotApprovals, + /** The messages this instance sent, which tell a reply to the bot from a reply to somebody else. */ + private val sent: OneBotSentMessages, + /** What this instance read not long ago, which is what a message answering one says it answered. */ + private val recent: OneBotRecentMessages, + /** Who may run each command, which nothing else asks. */ + private val rules: OneBotCommandRules, + /** The chats this instance serves, and the code a chat it does not serve is offered. */ + private val gate: OneBotAccessGate, + /** The texts of this channel for people. */ + private val texts: Texts, + /** What reads the bytes of an image or a voice message the implementation reported. */ + private val media: OneBotMedia, + /** Where a report that is not a message is published for the plugins that subscribe to it. */ + private val hooks: Hooks, + /** The API of this instance, which a tool of the runtime reads to act on a chat of it. */ + private val api: OneBotApi, + /** Where this instance is served from, for whoever acts on its chats. */ + private val apis: OneBotApis, + /** The agent, null while the runtime has none. */ + private val submitter: TurnSubmitter?, +) : Lifecycle { + private var reading: Job? = null + + override suspend fun onStart() { + // The instance is served from here on, whatever else it does: a tool that acts on a chat of it finds its API + // whether or not the runtime has an agent to take its messages. + apis.register(instance.id, api) + if (submitter == null) { + logger.warn( + "{}: the runtime has no agent, so the messages of channel instance {} reach nobody", + PLUGIN, + instance.id, + ) + return + } + reading = instance.scope.launch { read() } + } + + override suspend fun onClose() { + reading?.cancel() + reading = null + apis.forget(instance.id) + } + + private suspend fun read() { + link.events.collect { event -> + // A report of anything but a message is published for whoever cares: it is not a turn, and nothing else + // would carry it to a plugin. It is published whether or not the chat is served, because a chat that is not + // served yet is where somebody asks to join. + if (event !is OneBotEvent.Message) { + OneBotNotices.of(instance.id, event, event.selfId.value)?.let { hooks.fire(ChannelPoints.NOTICE, it) } + return@collect + } + val chat = OneBotChats.addressOf(instance.id, event) + // Every message is kept as it arrives, whether or not it is meant for the bot: what a later message answers + // is usually one of these, and the one it answers need not have been meant for the bot at all. + recent.remember(chat, event.messageId.value, OneBotMessages.quotable(instance.id, event)) + // A command is run wherever it is written; anything else has to be meant for the bot, or a group's talk + // would each become a turn of the agent. + val command = OneBotCommands.of(event) + // A reply names the message it answers, and after a restart the sent ids are gone; reading the message + // back tells whether the bot wrote it, so a restart does not make its old messages unrecognizable. + val answered = quoted(chat, event) + val addressed = addressed(event, answered) + if (command == null && !addressed) { + logger.debug("{}: a message in {} names nobody of this instance, and is no command", instance.id, chat) + return@collect + } + // A message that only names the bot says nothing to answer: the Telegram channel ignores one, and so does + // this one, unless it also carries media, quotes a message, or asks for a command. + if (command == null && !saysSomething(event)) { + logger.debug("{}: a message in {} names the bot and says nothing; no turn is asked", instance.id, chat) + return@collect + } + // Nothing of a chat that is not paired passes, commands included: a bot that was invited somewhere answers + // and obeys nobody until somebody says it may. + val asked = OneBotMessages.senderOf(instance.id, event, config.admins) + val kind = if (event is OneBotEvent.Message.Private) ChatKind.DIRECT else ChatKind.GROUP + val ref = ChannelMessageRef(chat, event.messageId.value) + if (!gate.admit(chat, kind, null, ref, asked, addressed)) return@collect + // An approval is answered here rather than by the agent: the command registry of the runtime knows nothing + // of it, and a command it does not claim is refused to everyone but an operator. + if (command != null && approvals.answers(command.name)) { + val answer = approvals.answer(event, command, asked) + if (answer != null) { + sender.send(chat, said(answer)) + } + return@collect + } + val message = OneBotMessages.incoming(instance.id, event, config.admins, answered, media) + if (command != null) { + // Who may run a command is the channel's question to ask: the agent's control takes the operator of a + // change as a parameter and does not judge it. + val invocation = invocationOf(command, message) + val refusal = rules.refusal(invocation, message.chatInfo.kind) + if (refusal != null) { + sender.send(chat, said(text(refusal, "command" to invocation.name.lowercase()))) + return@collect + } + } + val submission = if (command == null) Submission.Message(message) else commandOf(command, message) + logger.info( + "{}: {} in {} from {} becomes a turn for the agent", + instance.id, + if (command == null) "a message" else command.name, + chat, + asked.address, + ) + report(message, submitter?.submit(submission)) + } + } + + /** Whether [event] carries anything but the mention that addressed the bot: text, media, or a quote. */ + private fun saysSomething(event: OneBotEvent.Message): Boolean = event.message.segments.any { segment -> + when (segment) { + is OneBotSegment.Text -> segment.text.isNotBlank() + is OneBotSegment.Image, is OneBotSegment.Record, is OneBotSegment.Video, is OneBotSegment.File -> true + is OneBotSegment.Reply -> true + else -> false + } + } + + /** + * What [event] answers, as the channel read it, null when it answers nothing. + * + * What the channel kept is read first, which is what a chat normally answers. Only a message older than what is + * kept is read back from the implementation, and that read can fail: a message may be withdrawn, or unknown to the + * implementation, and a chat goes on whether or not what a message quoted can be read. + */ + private suspend fun quoted(chat: ChatAddress, event: OneBotEvent.Message): QuotedMessage? { + val id = event.message.segments.filterIsInstance().firstOrNull()?.id ?: return null + recent.find(chat, id.value)?.let { return it } + val result = try { + link.call("get_msg", GetMessage(id).toJson()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.debug("{} cannot read what message {} answers: {}", instance.id, event.messageId, e.toString()) + return null + } + val data = (result as? OneBotResult.Ok)?.data ?: return null + return try { + val info = OneBotWire.decodeFromJsonElement(OneBotMessageInfo.serializer(), data) + OneBotMessages.quotable(instance.id, info, event.selfId.value).also { recent.remember(chat, id.value, it) } + } catch (e: Exception) { + logger.debug("{} cannot read the report about message {}: {}", instance.id, id, e.toString()) + null + } + } + + /** The message that says [text] as an answer to a command rather than as the answer of a turn. */ + private fun said(text: String): OutboundMessage = OutboundMessage.builder(text, MessageKind.REPLY).build() + + /** [text] in the host's language, with [args] put where they are named. */ + private fun text(key: String, vararg args: Pair): String = texts.text(key, null, *args) + + /** The invocation [command] makes in [message]. */ + private fun invocationOf(command: OneBotCommands.Parsed, message: IncomingMessage): CommandInvocation = + CommandInvocation.builder(message.chat, command.name, message.sender) + .arguments(command.arguments) + .trigger(message.ref) + .build() + + /** The submission of [command] in [message], which an operator's commands pass the queue with. */ + private fun commandOf(command: OneBotCommands.Parsed, message: IncomingMessage): Submission.Command = + Submission.Command( + invocation = invocationOf(command, message), + message = message, + capacity = if (message.sender.isAdmin) Capacity.EXEMPT else Capacity.COUNTED, + ) + + /** Whether the bot of this instance is addressed by [event], where [answered] is what it answered. */ + private fun addressed(event: OneBotEvent.Message, answered: QuotedMessage?): Boolean = OneBotCommands.addressed( + event = event, + isDirect = event is OneBotEvent.Message.Private, + namesTheBot = { selfId -> event.message.segments.any { it is OneBotSegment.At && it.names(selfId) } }, + answersTheBot = { id -> sent.holds(id) || (answered?.authorIsBot == true) }, + ) + + /** + * Reports a submission the agent refused. + * + * A message the agent refuses never becomes a turn, so it is said here rather than dropped in silence: the reason + * is what tells an operator that the chat is not linked, which nothing else reports. + */ + private fun report(message: IncomingMessage, admission: Admission?) { + if (admission !is Admission.Refused) return + logger.warn( + "{}: instance {} took a message of {} no further: {}", + PLUGIN, + instance.id, + message.chat, + admission.reason, + ) + } + + private companion object { + const val PLUGIN = "alexandrite-channel-onebot" + + val logger: Logger = LoggerFactory.getLogger(OneBotInbox::class.java) + } +} + +/** + * The commands of a message, and whether the bot is addressed by one. + * + * A command starts a message and is named after a `/`, which is how every channel of this project spells one: the + * prefix is configurable nowhere yet, so it is not configurable here either. The name is written as one is on the + * platform the other channel serves: ASCII letters, digits and underscores, one to 32 of them, optionally followed by + * `@target`. Text that starts with a slash but holds no such name is not a command, so a group message like + * `/随便说点什么` is not heard by the bot unless it names the bot otherwise, as it is not on that platform. + */ +internal object OneBotCommands { + /** The prefix a command is named after. */ + const val PREFIX = "/" + + /** A command token: the prefix, a name, and optionally the target a name belongs to. */ + private val TOKEN = Regex(PREFIX + "([A-Za-z0-9_]{1,32})(?:@[A-Za-z0-9_]{1,32})?") + + /** The command [event] carries, null when its text names none. */ + fun of(event: OneBotEvent.Message): Parsed? { + val text = textOf(event).trimStart() + val token = text.takeWhile { !it.isWhitespace() } + val match = TOKEN.matchEntire(token) ?: return null + return Parsed(match.groupValues[1], text.removePrefix(token).trim()) + } + + /** + * Whether [event] is a message the bot is addressed by. + * + * A direct chat is the bot's. A group is the bot's when the message names it or answers one of its messages, and + * what it answers is told by [answersTheBot] rather than by the message, which names the id it answers and not + * the author of it. + */ + fun addressed( + event: OneBotEvent.Message, + isDirect: Boolean, + namesTheBot: (String) -> Boolean, + answersTheBot: (String) -> Boolean, + ): Boolean { + if (isDirect) return true + val selfId = event.selfId.value + if (namesTheBot(selfId)) return true + val answered = event.message.segments.filterIsInstance().firstOrNull() ?: return false + return answersTheBot(answered.id.value) + } + + /** The text of [event], which is every text segment of it and nothing else. */ + private fun textOf(event: OneBotEvent.Message): String = + event.message.segments.filterIsInstance().joinToString("") { it.text } + + /** The parts of a command: its [name] and its [arguments], which may be empty. */ + internal class Parsed(val name: String, val arguments: String) +} + +/** Whether [this] mention names [selfId], or names everyone. */ +private fun OneBotSegment.At.names(selfId: String): Boolean = isAll || userId == selfId diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotLink.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotLink.kt new file mode 100644 index 00000000..c926008e --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotLink.kt @@ -0,0 +1,129 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotConfig +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotTransport +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailure +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailureKind +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotForwardWebSocket +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotHttpApi +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotHttpPostReceiver +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotReverseWebSocket +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotSettings +import org.foedusprogramme.alexandrite.channel.onebot.transport.envelopeResult +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle + +/** + * The connection of one channel instance, whichever transport its config chose. + * + * This is the one place where the plugin picks a transport and opens it, so that the channel above it calls an + * action without knowing whether an HTTP request, a Websocket or a listener carries it. The instance scope owns it, + * so the container starts it when the instance opens and closes it when the instance stops. + */ +@ChannelInstanceScoped +internal class OneBotLink(private val config: OneBotConfig, private val instanceConfig: OneBotInstanceConfig) : + Lifecycle { + private val settings = OneBotSettings.of(config, instanceConfig) + + private val http: OneBotHttpApi? = if (instanceConfig.kind == OneBotTransport.HTTP) { + OneBotHttpApi( + endpoint = settings.requireEndpoint().trimEnd('/'), + token = settings.token, + connectTimeoutMillis = settings.connectTimeoutMillis, + firstByteTimeoutMillis = settings.firstByteTimeoutMillis, + idleTimeoutMillis = settings.idleTimeoutMillis, + ) + } else { + null + } + + private val receiver: OneBotHttpPostReceiver? = if (instanceConfig.kind == OneBotTransport.HTTP_POST) { + OneBotHttpPostReceiver(settings) + } else { + null + } + + private val forward: OneBotForwardWebSocket? = if (instanceConfig.kind == OneBotTransport.WS) { + OneBotForwardWebSocket(settings) + } else { + null + } + + private val reverse: OneBotReverseWebSocket? = if (instanceConfig.kind == OneBotTransport.WS_REVERSE) { + OneBotReverseWebSocket(settings) + } else { + null + } + + /** The events of the implementation, from whichever transport reports them. */ + internal val events: Flow = when { + receiver != null -> receiver.events + forward != null -> forward.events + reverse != null -> reverse.events + else -> emptyFlow() + } + + /** + * Runs one call of [action], with the suffix the caller chose already applied. + * + * [waitMillis] asks the socket transports to wait longer than the instance allows for a call that takes longer, + * such as one carrying a file. The HTTP transports are given their timeouts when their client is built, so a + * caller's wait is not honoured there and the instance's own `firstByteTimeoutMillis` governs them. + */ + internal suspend fun call( + action: String, + params: JsonObject, + waitMillis: Long? = null, + ): OneBotResult = when { + http != null -> http.call(action, params) + + forward != null -> overSocket { forward.send(action, params, waitMillis) } + + reverse != null -> overSocket { reverse.send(action, params, waitMillis) } + + else -> OneBotResult.Unreachable( + OneBotFailure( + OneBotFailureKind.HTTP_STATUS, + "A transport of '${instanceConfig.transport}' only takes reports; it has no endpoint to call.", + ), + ) + } + + /** + * Runs one call over a socket, as a result rather than as a throw. + * + * A socket that is gone throws where an HTTP answer would have been a result, so a channel that sends a reply + * would fail the turn instead of reporting a delivery that did not happen. A caller that gave up is not a failure + * of the transport and is left to travel as it is. + */ + private suspend fun overSocket(send: suspend () -> JsonObject): OneBotResult = try { + envelopeResult(send()) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + OneBotResult.Unreachable(OneBotFailure(OneBotFailureKind.CONNECTION, e.message ?: "the call failed")) + } + + /** The port a listener took, which a test reads and a log may name. */ + internal val listenerPort: Int? get() = receiver?.port ?: reverse?.port + + override suspend fun onStart() { + forward?.start() + reverse?.start() + receiver?.start() + } + + override fun onStop() { + http?.close() + receiver?.close() + forward?.close() + reverse?.close() + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/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/channel/OneBotProgress.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgress.kt new file mode 100644 index 00000000..d27401a5 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgress.kt @@ -0,0 +1,361 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.onebot.i18n.TextKeys +import org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus +import org.foedusprogramme.alexandrite.sdk.channel.TurnEvent +import org.foedusprogramme.alexandrite.sdk.chat.AgentId +import org.foedusprogramme.alexandrite.sdk.chat.LanguageTag +import org.foedusprogramme.alexandrite.sdk.chat.RunId +import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId +import org.foedusprogramme.alexandrite.sdk.i18n.Texts +import org.foedusprogramme.alexandrite.sdk.tool.ToolActivity + +/** The statuses a call has ended with, after which it is spoken of in the past tense. */ +internal val ENDED: Set = + setOf(ToolCallStatus.COMPLETED, ToolCallStatus.FAILED, ToolCallStatus.BACKGROUNDED) + +/** + * The statuses a call is worth a line of its own for: the ones a summary line cannot say. + * + * A chat whose messages cannot be added to is told of its work in messages of its own, so a line per call would say + * what the summary above it already said, once per window. What a summary cannot say, and so is told, is that a call + * failed, was moved to the background, waits for an approval or did not run at all. + */ +private val NOTABLE: Set = + setOf(ToolCallStatus.FAILED, ToolCallStatus.BACKGROUNDED, ToolCallStatus.WAITING_APPROVAL, ToolCallStatus.NOT_RUN) + +/** The tool of a call that runs a sub-agent, whose line names the run rather than what it did. */ +private const val DELEGATE_TOOL: String = "agent.delegate" + +/** The characters one message about a stretch of steps may hold before its oldest lines are left out. */ +internal const val MAX_ACTIVITY_CHARS: Int = 1500 + +/** What separates the work of one agent from the work of another in the summary line of a message. */ +private const val AGENT_SEPARATOR: String = " / " + +/** What separates two kinds of work of the same agent in the summary line of a message. */ +private const val KIND_SEPARATOR: String = " · " + +/** + * One call of a turn, as much of it as a chat is told: what it is, how it is doing, and what it left behind. + * + * [run] is the sub-agent run a call belongs to, which a delegation is named by so that a chat tells two of them apart. + */ +internal class ProgressCall( + val tool: String, + val activity: ToolActivity, + val title: String, + val agent: AgentId?, + var run: RunId? = null, +) { + /** How the call is doing, which the update of the call sets. */ + var status: ToolCallStatus = ToolCallStatus.PENDING + + /** What the call left behind, which the update of the call sets. */ + var content: TurnEvent.Content? = null + + /** Whether the call delegates its task to a sub-agent, whose run its title names. */ + val delegates: Boolean get() = tool == DELEGATE_TOOL + + /** Whether the line of this call belongs to a sub-agent rather than to the turn itself. */ + val by: AgentId? get() = agent +} + +/** How many calls of each kind a conversation ended since the last message about it. */ +internal class ProgressTally(val steps: List) + +/** + * What a conversation has done since the last thing said about it. + * + * A chat whose messages cannot be rewritten is told of the work in messages of its own, each a line that sums up the + * kinds of call in it and a line for each call, the newest kept and the oldest left out when the message would be too + * long. A call is told as it starts and again as it ends, at most once per interval. + */ +internal class OneBotProgress( + private val intervalMillis: Long = DEFAULT_INTERVAL_MS, + private val now: () -> Long = System::currentTimeMillis, +) { + private var toldAt: Long? = null + + /** How long this conversation waits now, which grows while it keeps working and starts over when it falls quiet. */ + private var interval: Long = intervalMillis + private var opened = false + private val calls = HashMap() + private val ended = ArrayList() + + /** + * Notes that the call [id] of [activity] started, taken by [agent], which is null for a call of the turn itself. + * + * The call is returned when it is the first of its conversation, which is the one told at once, and null after. + */ + fun started( + id: ToolCallId, + activity: ToolActivity, + agent: AgentId?, + tool: String = "", + title: String = "", + run: RunId? = null, + ): ProgressCall? { + val call = ProgressCall(tool, activity, title, agent, run) + calls[id] = call + if (opened) return null + opened = true + toldAt = now() + return call + } + + /** Notes that the call [id] ended with [status] and [content], and keeps it to be told. */ + fun ended( + id: ToolCallId, + status: ToolCallStatus = ToolCallStatus.COMPLETED, + content: TurnEvent.Content? = null, + run: RunId? = null, + ) { + val call = calls.remove(id) ?: return + call.status = status + call.content = content + call.run = run ?: call.run + ended += call + } + + /** + * What waits to be told whether or not the interval has passed, for a reply that is ending. + * + * Nothing waits once the reply is over, so its last steps are told rather than kept for a window that will not + * come. + */ + fun flush(): ProgressTally? { + if (ended.isEmpty()) return null + toldAt = now() + return ProgressTally(ended.toList()).also { ended.clear() } + } + + /** What waits to be told, when it is worth telling: null while nothing waits or the interval has not passed. */ + fun take(): ProgressTally? { + if (ended.isEmpty()) return null + val told = toldAt + val at = now() + if (told != null && at - told < interval) return null + // A conversation that keeps working is told less often, up to a ceiling; one that fell quiet starts over. A + // burst of sub-agents then does not answer with a message every few seconds, and a slow turn is not left + // silent for a minute. + interval = if (told != null && at - told > interval * 2) { + DEFAULT_INTERVAL_MS + } else { + minOf(interval * 2, DEFAULT_MAX_INTERVAL_MS) + } + toldAt = at + return ProgressTally(ended.toList()).also { ended.clear() } + } + + /** How many calls ended and wait to be told. */ + fun counted(): Int = ended.size + + /** + * Whether this conversation holds nothing to tell and no call still running. + * + * Only the pacing of what is told is kept besides, so a conversation that holds nothing may be forgotten rather + * than kept for as long as the channel runs. + */ + fun idle(): Boolean = calls.isEmpty() && ended.isEmpty() + + internal companion object { + /** How long a conversation works quietly before what it has ended since is told again. */ + const val DEFAULT_INTERVAL_MS: Long = 8_000 + + /** The longest a conversation that keeps working waits between two messages about it. */ + const val DEFAULT_MAX_INTERVAL_MS: Long = 60_000 + } +} + +/** What one call says as it starts: the present tense of what the channel has words for, and the call's own line. */ +internal fun startedMessage(call: ProgressCall, texts: Texts, language: LanguageTag?): String = + activityMessage(listOf(call), texts, language) + +/** + * What the calls of [tally] say once they ended: one line that sums up their kinds, then a line for each call. + * + * Lines the message cannot hold are left out from the oldest, which the line above them counts. + */ +internal fun summaryMessage(tally: ProgressTally, texts: Texts, language: LanguageTag?): String = + activityMessage(tally.steps, texts, language) + +/** [calls] as one message: their summary line, then their own lines. */ +internal fun activityMessage(calls: List, texts: Texts, language: LanguageTag?): String { + val summary = summaryLine(calls, texts, language) + val lines = calls + // A delegation has no line of its own: the summary already names the run it stands for and says whether it + // still runs, so a line about the call would only say the same thing again in the words of the tool. + .filter { it.status in NOTABLE && !it.delegates } + .mapNotNull { callLine(it, texts, language) } + .filter { it.isNotBlank() } + if (lines.isEmpty()) return summary + val room = MAX_ACTIVITY_CHARS - summary.length - 1 + var used = 0 + var kept = 0 + for (line in lines.asReversed()) { + val next = used + line.length + if (kept > 0) 1 else 0 + val omitted = lines.size - kept - 1 + val notice = if (omitted > 0) omittedNotice(omitted, texts, language).length + 1 else 0 + if (next + notice > room) break + used = next + kept++ + } + val omitted = lines.size - kept + val shown = lines.takeLast(kept) + val details = (if (omitted > 0) listOf(omittedNotice(omitted, texts, language)) + shown else shown) + .joinToString("\n") + return "$summary\n$details" +} + +/** + * The kinds of [calls] in the order each first appears, each delegation alone and each agent apart. + * + * A chat reads who did what rather than one count of everything a turn and its sub-agents did together, so the calls + * of a sub-agent are counted on their own. + */ +private fun groupedByKind(calls: List): Collection> { + val byKind = LinkedHashMap, MutableList>() + for (call in calls) { + val kind: Any = if (call.delegates) call else call.activity + byKind.getOrPut(call.by to kind) { mutableListOf() } += call + } + return byKind.values +} + +/** + * One phrase per kind of [calls], the work of each agent kept together, as `使用了 2 个工具 · 读取了 3 个文件`. + * + * The kinds of one agent are joined by a middle dot and the work of another agent stands beside them after a slash, + * so that a chat reads where the turn's own work ends and a sub-agent's begins. + */ +private fun summaryLine(calls: List, texts: Texts, language: LanguageTag?): String { + val byAgent = LinkedHashMap>>() + for (group in groupedByKind(calls)) byAgent.getOrPut(group.first().by) { mutableListOf() } += group + return byAgent.entries.joinToString(AGENT_SEPARATOR) { (agent, kinds) -> + val said = kinds.joinToString(KIND_SEPARATOR) { group -> + val first = group.first() + if (first.delegates) delegation(first, texts, language) else counted(group, texts, language) + } + // A delegation already names its run, where the turn's own calls need no name: only a sub-agent's other calls + // are named, so that a chat reads whose they were. + if (agent == null) { + said + } else { + texts.text(TextKeys.ACTIVITY_CALL_BY_AGENT, language, "agent" to agent.value, "line" to said) + } + } +} + +/** What a kind of [group] says: its verb in the tense of the group, and how many of it there were. */ +private fun counted(group: List, texts: Texts, language: LanguageTag?): String { + val activity = group.first().activity + val ran = group.filterNot { it.status == ToolCallStatus.NOT_RUN } + if (ran.isEmpty()) { + val noun = noun(activity, group.size, texts, language) + return texts.text(TextKeys.ACTIVITY_SUMMARY_NOT_RUN, language, "noun" to noun) + } + val past = ran.all { it.status in ENDED } + val verb = texts.text("activity.verb.${kind(activity)}.${if (past) "past" else "present"}", language) + return "$verb ${noun(activity, ran.size, texts, language)}" +} + +/** The delegation [call] stood for, named by the run it belongs to rather than by what it did. */ +private fun delegation(call: ProgressCall, texts: Texts, language: LanguageTag?): String { + val key = if (call.status in ENDED) TextKeys.ACTIVITY_DELEGATE_PAST else TextKeys.ACTIVITY_DELEGATE_PRESENT + return texts.text(key, language, "agent" to named(call)) +} + +/** What [call] is named by: its title, with the run beside it where the title does not already name that run. */ +private fun named(call: ProgressCall): String { + val run = call.run ?: return call.title + return if (call.title.contains(run.value)) call.title else "${call.title} (${run.value})" +} + +/** What one call did to something, as the words of its kind and the count of it. */ +private fun noun(activity: ToolActivity, count: Int, texts: Texts, language: LanguageTag?): String = + texts.text("activity.noun.${kind(activity)}.${if (count == 1) "one" else "many"}", language, "count" to count) + +/** One call's own line: what it is, and what became of it. */ +private fun callLine(call: ProgressCall, texts: Texts, language: LanguageTag?): String { + val title = call.title.take(MAX_TITLE_CHARS) + val line = when (call.status) { + ToolCallStatus.WAITING_APPROVAL -> + texts.text(TextKeys.ACTIVITY_CALL_WAITING_APPROVAL, language, "tool" to call.tool, "title" to title) + + ToolCallStatus.NOT_RUN -> + texts.text(TextKeys.ACTIVITY_CALL_NOT_RUN, language, "tool" to call.tool, "title" to title) + + ToolCallStatus.BACKGROUNDED -> + texts.text(TextKeys.ACTIVITY_CALL_BACKGROUNDED, language, "tool" to call.tool, "title" to title) + + else -> title + suffix(call, texts, language) + } + val agent = call.by ?: return line + return texts.text(TextKeys.ACTIVITY_CALL_BY_AGENT, language, "agent" to agent.value, "line" to line) +} + +/** What an ended call's line adds after its title: an exit code, a diff's counts, or that it failed. */ +private fun suffix(call: ProgressCall, texts: Texts, language: LanguageTag?): String { + when (val content = call.content) { + is TurnEvent.Content.Output -> EXIT_CODE.find(content.tail)?.groupValues?.get(1)?.let { + return " — " + texts.text(TextKeys.ACTIVITY_CALL_EXIT, language, "code" to it) + } + + is TurnEvent.Content.Diff -> lineCounts(content.unified)?.let { (added, removed) -> + return " (" + texts.text(TextKeys.ACTIVITY_CALL_DIFF, language, "added" to added, "removed" to removed) + + ")" + } + + else -> Unit + } + return if (call.status == ToolCallStatus.FAILED) " — " + texts.text(TextKeys.ACTIVITY_CALL_FAILED, language) else "" +} + +/** The line that counts the [omitted] calls a message could not hold. */ +private fun omittedNotice(omitted: Int, texts: Texts, language: LanguageTag?): String = texts.text( + if (omitted == + 1 + ) { + TextKeys.ACTIVITY_OMITTED_ONE + } else { + TextKeys.ACTIVITY_OMITTED_MANY + }, + language, + "count" to omitted, +) + +/** The added and removed line counts of the unified diff [text], null when it changes nothing. */ +private fun lineCounts(text: String): Pair? { + var added = 0 + var removed = 0 + for (line in text.lineSequence()) { + when { + line.startsWith("+++") || line.startsWith("---") -> Unit + line.startsWith("+") -> added++ + line.startsWith("-") -> removed++ + } + } + return (added to removed).takeIf { added > 0 || removed > 0 } +} + +/** The kind of [activity] the channel has words for; a kind this version does not know is work with tools. */ +private fun kind(activity: ToolActivity): String = when (activity) { + ToolActivity.READ -> "read" + ToolActivity.LIST -> "list" + ToolActivity.SEARCH -> "search" + ToolActivity.EDIT -> "edit" + ToolActivity.DELETE -> "delete" + ToolActivity.MOVE -> "move" + ToolActivity.EXECUTE -> "execute" + ToolActivity.FETCH -> "fetch" + ToolActivity.SEND -> "send" + ToolActivity.THINK -> "think" + else -> "other" +} + +/** The characters of a call's title a message shows. */ +private const val MAX_TITLE_CHARS: Int = 80 + +/** The exit code the tail of a call's output names. */ +private val EXIT_CODE = Regex("""Exit code: (-?\d+)""") diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotRecentMessages.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotRecentMessages.kt new file mode 100644 index 00000000..a89130fe --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotRecentMessages.kt @@ -0,0 +1,49 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.onebot.mapping.QuotedMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped + +/** + * The messages this instance read not long ago, so that a message answering one says what it answered. + * + * A report names the message it answers and says nothing about it, and reading it back from the implementation is a + * call for every answer. What the channel read as it arrived is kept instead, which is enough for what a chat normally + * answers: something said not long before. A message older than what is kept is read back from the implementation, and + * one it no longer knows is answered with its name alone. + * + * What is kept is bounded by the chats and by the messages in each, because a chat that runs for long says far more + * than any answer reaches back to. + */ +@ChannelInstanceScoped +internal class OneBotRecentMessages { + /** The messages of each chat, oldest first, with the chats least recently asked for forgotten first. */ + private val chats = object : LinkedHashMap>(16, 0.75f, true) { + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry>, + ) = size > MAX_CHATS + } + + /** Keeps [message] as what the message [id] of [chat] holds. */ + fun remember(chat: ChatAddress, id: String, message: QuotedMessage) { + if (id.isEmpty()) return + synchronized(chats) { + val held = chats.getOrPut(chat) { LinkedHashMap() } + held[id] = message + while (held.size > MAX_PER_CHAT) { + held.remove(held.keys.first()) + } + } + } + + /** What the message [id] of [chat] holds, null when it was not read or is no longer kept. */ + fun find(chat: ChatAddress, id: String): QuotedMessage? = synchronized(chats) { chats[chat]?.get(id) } + + private companion object { + /** How many chats' messages are kept. */ + const val MAX_CHATS = 256 + + /** How many messages of one chat are kept. */ + const val MAX_PER_CHAT = 200 + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSender.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSender.kt new file mode 100644 index 00000000..f7a4da45 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSender.kt @@ -0,0 +1,206 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages.MAX_FILE_BYTES +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendGroupMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendPrivateMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegmentCodec +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.DeliveryFailure +import org.foedusprogramme.alexandrite.sdk.channel.OutboundFile +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.slf4j.LoggerFactory +import java.util.Base64 + +/** + * What this instance sends, and the record of what it sent. + * + * Every delivery goes through here: the answer of a turn, the notice of a refusal, and the message that shows an + * approval request. The ids that come back are remembered as they arrive, because what answers the bot is told from + * them and from nothing else. + */ +@ChannelInstanceScoped +internal class OneBotSender(private val link: OneBotLink, private val sent: OneBotSentMessages) { + private val logger = LoggerFactory.getLogger(OneBotSender::class.java) + + /** + * The action that sends a message to [chat], null when this instance serves no such chat. + * + * A temporary session of a group is a private chat whose thread names the sender, so it is asked for first: a reply + * to it goes to the sender and not into the group, which is what its address means. + */ + fun action(chat: ChatAddress): Pair? = when { + OneBotMessages.privateTemporary(chat) != null -> "send_private_msg" to chat + OneBotMessages.isGroup(chat) && OneBotMessages.group(chat) != null -> "send_group_msg" to chat + OneBotMessages.privateUser(chat) != null -> "send_private_msg" to chat + else -> null + } + + /** The parameters that send [message] to [chat], null when this instance serves no such chat. */ + fun params(chat: ChatAddress, message: OutboundMessage): JsonObject? { + val temporary = OneBotMessages.privateTemporary(chat) + val user = OneBotMessages.privateUser(chat) + val group = OneBotMessages.group(chat) + val segments = OneBotMessages.outgoing(chat, message) + return when { + temporary != null -> SendPrivateMessage(temporary, segments).toJson() + group != null -> SendGroupMessage(group, segments).toJson() + user != null -> SendPrivateMessage(user, segments).toJson() + else -> null + } + } + + /** + * Sends [message] to [chat], remembering the ids of what it delivered. + * + * A message that carries files goes as its files alone: this platform shows each file's own name and size, so a + * line of text beside them says nothing the file does not. A file past [MAX_FILE_BYTES] is refused before any of + * them is sent, so a chat is never told half of what a turn meant to say. + */ + suspend fun send(chat: ChatAddress, message: OutboundMessage): Delivery { + message.files.firstOrNull { it.size > MAX_FILE_BYTES }?.let { file -> + return Delivery.NotDelivered( + DeliveryFailure.UNKNOWN, + "The file holds ${file.size} bytes, more than the $MAX_FILE_BYTES bytes a chat of this platform takes.", + ) + } + val target = action(chat) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + var delivery: Delivery? = null + if (message.files.isEmpty() && message.text.isNotBlank()) { + val params = params(chat, message) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + delivery = remember(OneBotMessages.delivery(link.call(target.first, params), chat)) + log(message.kind.toString(), chat, delivery, said(message.text)) + } + for (file in message.files) { + val (action, params) = upload(chat, file) ?: continue + // An upload is not a message, so the implementation names no message of it. The answer is matched to this + // call rather than to the chat and is not remembered: what an answer is matched against is the id of a + // message a chat can answer, and an upload names none. + delivery = try { + OneBotMessages.delivery(link.call(action, params, UPLOAD_WAIT_MILLIS), chat) + } catch (e: CancellationException) { + // The wait asked for ran out while the platform was still taking the file in, and an implementation + // goes on taking it: the file may arrive after the turn was told it did not, so a reader of this is + // told to look at the chat before sending it again. A turn that gave up itself travels as it is. + if (!currentCoroutineContext().isActive) throw e + Delivery.NotDelivered( + DeliveryFailure.UNKNOWN, + "The platform did not answer in time about '${file.name}', which it may still be taking in; " + + "look at the chat before sending it again.", + ) + } + if (delivery is Delivery.Delivered) { + logger.info("Uploaded {} of {} bytes to {}", file.name, file.size, chat) + } else { + val failure = delivery as? Delivery.NotDelivered + logger.warn( + "Could not upload {} to {}: {}: {}", + file.name, + chat, + failure?.kind, + failure?.detail.orEmpty(), + ) + } + } + return delivery ?: Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + } + + /** + * Logs what became of [delivery] of [what] to [chat], which [about] describes where it arrived. + * + * What came back is written as it is: a delivery that did not happen is logged as a failure with the reason the + * platform gave, never as a send, which is what a reader of the log took for one. + */ + private fun log(what: String, chat: ChatAddress, delivery: Delivery, about: String) { + if (delivery is Delivery.Delivered) { + val ids = delivery.messages.joinToString { it.id } + logger.info("Sent {} to {} as [{}]: {}", what, chat, ids, about) + } else { + val failure = delivery as? Delivery.NotDelivered + logger.warn( + "Could not send {} to {}: {}: {}", + what, + chat, + failure?.kind, + failure?.detail.orEmpty(), + ) + } + } + + /** + * The action and parameters that upload [file] to [chat], null when this instance serves no such chat. + * + * The bytes go as `base64://`, which is how a file the implementation cannot reach itself is handed over, and the + * name is what the chat shows. A temporary session of a group uploads to the sender, which is whom its address + * means, as its replies do. + */ + private fun upload(chat: ChatAddress, file: OutboundFile): Pair? { + val carried = BYTES_PREFIX + Base64.getEncoder().encodeToString(file.bytes()) + val temporary = OneBotMessages.privateTemporary(chat) + val group = OneBotMessages.group(chat) + val user = OneBotMessages.privateUser(chat) + return when { + group != null -> "upload_group_file" to buildJsonObject { + put("group_id", OneBotSegmentCodec.number(group.value)) + put("file", carried) + put("name", file.name) + } + + temporary != null -> "upload_private_file" to buildJsonObject { + put("user_id", OneBotSegmentCodec.number(temporary.value)) + put("file", carried) + put("name", file.name) + } + + user != null -> "upload_private_file" to buildJsonObject { + put("user_id", OneBotSegmentCodec.number(user.value)) + put("file", carried) + put("name", file.name) + } + + else -> null + } + } + + /** [text] as a log line shows it: one line, cut short, so that a long answer cannot flood the console. */ + private fun said(text: String): String { + val one = text.replace("\n", " ") + return if (one.length <= SAID_LIMIT) one else one.take(SAID_LIMIT) + "…" + } + + /** Remembers the ids of what [delivery] delivered, which is what a reply is matched against. */ + private fun remember(delivery: Delivery): Delivery { + (delivery as? Delivery.Delivered)?.messages?.forEach { sent.remember(it.id) } + return delivery + } +} + +/** How much of a message a log line shows. */ +private const val SAID_LIMIT: Int = 120 + +/** + * What the platform is told a file's bytes are, which is how it is handed one it cannot reach itself. + * + * A file of a chat is uploaded by the implementation rather than read from this machine, so its bytes travel in the + * call. The tools of this channel hand one over the same way. + */ +private const val BYTES_PREFIX: String = "base64://" + +/** + * How long an upload of a file waits for the platform's answer. + * + * An implementation takes in a large file before it answers, which is longer than the instance's own timeout of a call + * allows: a file of thirty megabytes has taken about a minute. A call that gives up sooner is answered as a failure + * while the upload goes on, so the file arrives and the turn is told it did not. + */ +private const val UPLOAD_WAIT_MILLIS: Long = 10 * 60 * 1000 diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSentMessages.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSentMessages.kt new file mode 100644 index 00000000..494d2532 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotSentMessages.kt @@ -0,0 +1,36 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped + +/** + * The messages this instance sent, so that one answering the bot can be told from one answering somebody else. + * + * A reported message carries the id it answers and no author of it, and the only other way to learn that author is a + * call to the implementation for every answer in every group. What this instance sent is what it can answer for, so + * the ids of its own messages are what a reply is matched against. + * + * The ids are kept in the order they were sent and only so many of them, because a chat that runs for long sends far + * more messages than any answer can reach back to. + */ +@ChannelInstanceScoped +internal class OneBotSentMessages { + private val sent = ArrayDeque() + + /** Records that this instance sent the message [id]. */ + fun remember(id: String) { + if (id.isEmpty()) return + synchronized(sent) { + sent.remove(id) + sent.addLast(id) + while (sent.size > KEPT) sent.removeFirst() + } + } + + /** Whether [id] is a message this instance sent. */ + fun holds(id: String): Boolean = synchronized(sent) { id in sent } + + private companion object { + /** How many of the messages this instance sent are remembered. */ + const val KEPT = 512 + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotWords.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotWords.kt new file mode 100644 index 00000000..59b62fb1 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotWords.kt @@ -0,0 +1,68 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.TurnId +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.hook.Hook +import org.foedusprogramme.alexandrite.sdk.hook.HookDecision +import org.foedusprogramme.alexandrite.sdk.hook.InterceptorHook +import org.foedusprogramme.alexandrite.sdk.hook.InterceptorPoint +import org.foedusprogramme.alexandrite.sdk.transcript.AssistantEntry +import org.foedusprogramme.alexandrite.sdk.transcript.ToolCallPart +import org.foedusprogramme.alexandrite.sdk.turn.ModelReply +import org.foedusprogramme.alexandrite.sdk.turn.TurnPoints +import java.util.concurrent.ConcurrentHashMap + +/** The reply of a turn, as the words of a model's request reach it. */ +internal interface OneBotWords { + /** Shows [message], the response of one request of this turn. */ + suspend fun showResponse(message: AssistantEntry) +} + +/** + * The replies of the turns of this instance that are open, by turn. + * + * A reply opens when the agent dequeues a turn and closes as the turn ends, so the words of a response reach the reply + * of its own turn and no other. A turn whose reply is not here is one this instance does not speak for, such as a + * delegated turn whose reply goes to the agent that asked for it. + */ +@Singleton +internal class OneBotReplyRegistry { + private val open = ConcurrentHashMap() + + /** Remembers [words] as the reply of [turn]. */ + fun open(turn: TurnId, words: OneBotWords) { + open[turn] = words + } + + /** Forgets the reply of [turn], which nothing speaks for afterwards. */ + fun close(turn: TurnId) { + open.remove(turn) + } + + /** The reply of [turn], null when this instance speaks for no such turn. */ + fun of(turn: TurnId): OneBotWords? = open[turn] +} + +/** + * Tells the reply of a turn the words of each of its requests, whole and once, as the request ends. + * + * This is what the platform needs where a message cannot be added to later: a channel that can change its messages + * shows a growing preview and has no use for this point, while a chat of this one is told each response in full at the + * moment the model finished saying it. + */ +@Singleton +@Contribute(Hook::class) +internal class OneBotWordsHook(private val replies: OneBotReplyRegistry) : InterceptorHook { + override val point: InterceptorPoint get() = TurnPoints.LLM_RESPONSE + + override suspend fun intercept(payload: ModelReply): HookDecision { + // A turn this instance speaks for has a reply here; a delegated turn has none, and its words go to its caller. + val words = replies.of(payload.turn.id) ?: return HookDecision.Continue + // A response that calls no tool is the answer of the turn, which the message that ends it carries: telling it + // here as well would say it twice. + if (payload.response.message.parts.none { it is ToolCallPart }) return HookDecision.Continue + words.showResponse(payload.response.message) + return HookDecision.Continue + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/config/OneBotConfig.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/config/OneBotConfig.kt new file mode 100644 index 00000000..b42c4711 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/config/OneBotConfig.kt @@ -0,0 +1,174 @@ +package org.foedusprogramme.alexandrite.channel.onebot.config + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.config.ConfigSection +import org.foedusprogramme.alexandrite.sdk.config.Secret +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped + +/** How an instance talks to its OneBot implementation. */ +public enum class OneBotTransport { + /** The implementation serves the API on the endpoint, and the client calls it. */ + HTTP, + + /** The implementation serves the API on the endpoint and reports events to the receiver. */ + HTTP_POST, + + /** The implementation serves the API and pushes events on the endpoint. */ + WS, + + /** The implementation connects to the receiver for the API and the events. */ + WS_REVERSE, + ; + + /** Whether the client listens for the implementation instead of calling it. */ + public val isReceiver: Boolean get() = this == HTTP_POST || this == WS_REVERSE + + public companion object { + /** The transport [name] spells, null when it names none. */ + public fun of(name: String): OneBotTransport? = when (name.lowercase()) { + "http" -> HTTP + "http_post" -> HTTP_POST + "ws", "websocket" -> WS + "ws_reverse", "ws-reverse" -> WS_REVERSE + else -> null + } + } +} + +/** + * Where the settings of the plugin sit below its config root, apart from the root its channel instances use. + * + * The paths of the two sections of this plugin have to differ, because a channel plugin's instances read the root of + * their own config under `instances` and a section at the root would collide with them. + */ +internal const val ONEBOT_SETTINGS_PATH: String = "settings" + +/** + * The settings of the OneBot plugin itself, below its config root. + * + * What describes one connection lives in [OneBotInstanceConfig] instead, because a connection is an instance of the + * channel and each instance has its own. + */ +@ConfigSection(path = ONEBOT_SETTINGS_PATH) +@Serializable +public class OneBotConfig( + /** How many events wait for the plugin to read them before the newest are dropped. */ + @SerialName("eventQueueCapacity") public val eventQueueCapacity: Int = DEFAULT_EVENT_QUEUE, +) { + init { + require(eventQueueCapacity > 0) { "An event queue holds at least one event, was $eventQueueCapacity." } + } + + public companion object { + public const val DEFAULT_EVENT_QUEUE: Int = 1024 + } +} + +/** + * One connection to an OneBot implementation, below `instances` of the plugin's config root. + * + * A transport is either what the client calls or what it listens on. [HTTP] and [WS] use [endpoint]; [HTTP_POST] + * and [WS_REVERSE] use [listenHost] and [listenPort]. + */ +@ConfigSection +@ChannelInstanceScoped +@Serializable +public class OneBotInstanceConfig( + /** How this instance reaches its implementation. */ + public val transport: String, + /** The URL of [OneBotTransport.HTTP] and [OneBotTransport.WS], such as `http://127.0.0.1:3000`. */ + public val endpoint: String? = null, + /** The address [OneBotTransport.HTTP_POST] and [OneBotTransport.WS_REVERSE] listen on. */ + @SerialName("listenHost") public val listenHost: String = DEFAULT_HOST, + /** The port [OneBotTransport.HTTP_POST] and [OneBotTransport.WS_REVERSE] listen on. */ + @SerialName("listenPort") public val listenPort: Int = 0, + /** The path [OneBotTransport.HTTP_POST] accepts reports on. */ + public val path: String = "/", + /** The token the implementation checks, null when it checks none. */ + @SerialName("accessToken") public val accessToken: Secret? = null, + /** The key the implementation signs its reports with, null when it signs none. */ + public val secret: Secret? = null, + /** The account the implementation reports for, null when the plugin does not check it. */ + @SerialName("selfId") public val selfId: String? = null, + /** How long reaching the implementation may take. */ + @SerialName("connectTimeoutMillis") public val connectTimeoutMillis: Long = DEFAULT_CONNECT_TIMEOUT, + /** How long the first byte of an answer may take. */ + @SerialName("firstByteTimeoutMillis") public val firstByteTimeoutMillis: Long = DEFAULT_FIRST_BYTE_TIMEOUT, + /** How long a call may stay silent before it is cut off. */ + @SerialName("idleTimeoutMillis") public val idleTimeoutMillis: Long = DEFAULT_IDLE_TIMEOUT, + /** How long to wait before connecting again, in milliseconds. */ + @SerialName("reconnectIntervalMillis") public val reconnectIntervalMillis: Long = DEFAULT_RECONNECT, + /** The accounts that may operate this instance, which the channel marks as admins. */ + public val admins: Set = emptySet(), + /** The chats this instance serves, by their key, such as `group:987654` or `private:12345678`. */ + public val allowedChats: List = emptyList(), + /** How long an approval request waits for an admin's answer, in minutes. */ + @SerialName("approvalMinutes") public val approvalMinutes: Int = DEFAULT_APPROVAL_MINUTES, + /** Whether this instance tells a chat what a turn is doing while it works. */ + public val progress: Boolean = true, + /** Whether this instance shows what the model says while a turn works on tools. */ + public val streaming: Boolean = true, +) { + /** How this instance reaches its implementation. */ + public val kind: OneBotTransport = OneBotTransport.of(transport) ?: throw IllegalArgumentException( + "transport must be one of http, http_post, ws, ws_reverse, was '$transport'", + ) + + init { + require(connectTimeoutMillis > 0) { "A connect timeout is positive, was $connectTimeoutMillis." } + require(firstByteTimeoutMillis > 0) { "A first byte timeout is positive, was $firstByteTimeoutMillis." } + require(idleTimeoutMillis > 0) { "An idle timeout is positive, was $idleTimeoutMillis." } + require(reconnectIntervalMillis > 0) { "A reconnect interval is positive, was $reconnectIntervalMillis." } + require(approvalMinutes > 0) { "An approval waits at least a minute, was $approvalMinutes." } + if (kind.isReceiver) { + // Port 0 lets the operating system pick one, which a test and a host that only needs to bind use; the + // caller reads the port it took from the transport. + require(listenPort in 0..MAX_PORT) { + "transport '$transport' listens on a port between 0 and $MAX_PORT, was $listenPort" + } + require(path.startsWith("/")) { "A path starts with '/', was '$path'." } + require(endpoint == null) { + "transport '$transport' listens for its implementation and takes no endpoint, was '$endpoint'" + } + } else { + require(endpoint != null) { "transport '$transport' needs the endpoint of its implementation" } + val uri = try { + java.net.URI(endpoint) + } catch (e: java.net.URISyntaxException) { + throw IllegalArgumentException("endpoint '$endpoint' is no URL: ${e.message}", e) + } + val schemes = if (kind == OneBotTransport.WS) setOf("ws", "wss") else setOf("http", "https") + require(uri.scheme in schemes) { + "transport '$transport' takes an endpoint of ${schemes.joinToString(" or ")}, was '$endpoint'" + } + require(uri.host != null) { "endpoint '$endpoint' names no host" } + } + if (selfId != null) { + require(selfId.isNotEmpty() && selfId.all(Char::isDigit)) { + "selfId is the number the implementation reports, was '$selfId'" + } + } + } + + public companion object { + public const val DEFAULT_HOST: String = "127.0.0.1" + + public const val DEFAULT_CONNECT_TIMEOUT: Long = 10_000L + + public const val DEFAULT_FIRST_BYTE_TIMEOUT: Long = 30_000L + + public const val DEFAULT_IDLE_TIMEOUT: Long = 60_000L + + public const val DEFAULT_RECONNECT: Long = 3_000L + + public const val DEFAULT_APPROVAL_MINUTES: Int = 10 + + public const val MAX_PORT: Int = 65_535 + } +} + +/** The config path of [key] in the config of [instance], of the instance itself when [key] is null. */ +internal fun configPath(instance: ChannelInstanceId, key: String? = null): String = + listOfNotNull("channels.onebot.instances.${instance.name}", key).joinToString(".") diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/i18n/TextKeys.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/i18n/TextKeys.kt new file mode 100644 index 00000000..0a855218 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/i18n/TextKeys.kt @@ -0,0 +1,150 @@ +package org.foedusprogramme.alexandrite.channel.onebot.i18n + +/** The keys of the OneBot channel's texts for people. */ +internal object TextKeys { + /** Takes `{id}`. */ + const val APPROVAL_TITLE: String = "approval.title" + + /** Takes `{agent}`. */ + const val APPROVAL_AGENT: String = "approval.agent" + + /** Takes `{chat}`. */ + const val APPROVAL_CHAT: String = "approval.chat" + + /** Takes `{requester}`. */ + const val APPROVAL_REQUESTER: String = "approval.requester" + + /** Takes `{reason}`. */ + const val APPROVAL_REASON: String = "approval.reason" + + /** Takes `{tool}` and `{risk}`. */ + const val APPROVAL_TOOL: String = "approval.tool" + + /** Takes `{path}`. */ + const val APPROVAL_FILE: String = "approval.file" + + /** Takes `{directory}`. */ + const val APPROVAL_DIRECTORY: String = "approval.directory" + + /** Takes `{program}`. */ + const val APPROVAL_PROGRAM: String = "approval.program" + + const val APPROVAL_PROGRAM_UNKNOWN: String = "approval.program_unknown" + const val APPROVAL_OPAQUE: String = "approval.opaque" + const val APPROVAL_ARGUMENTS: String = "approval.arguments" + + /** How an approver answers, which this channel takes as a command rather than as a press. */ + const val APPROVAL_ANSWER: String = "approval.answer" + + /** Takes `{rule}`. */ + const val APPROVAL_ALWAYS_RULE: String = "approval.always_rule" + + /** Takes `{id}` and `{name}`. */ + const val APPROVAL_OUTCOME_ALLOWED: String = "approval.outcome_allowed" + + /** Takes `{id}`, `{name}` and `{rule}`. */ + const val APPROVAL_OUTCOME_ALLOWED_ALWAYS: String = "approval.outcome_allowed_always" + + /** Takes `{id}` and `{name}`. */ + const val APPROVAL_OUTCOME_DENIED: String = "approval.outcome_denied" + + /** Takes `{id}`. */ + const val APPROVAL_OUTCOME_EXPIRED: String = "approval.outcome_expired" + + /** Takes `{id}`. */ + const val APPROVAL_OUTCOME_WITHDRAWN: String = "approval.outcome_withdrawn" + + const val APPROVAL_ADMINS_ONLY: String = "approval.admins_only" + const val APPROVAL_NOT_PENDING: String = "approval.not_pending" + const val APPROVAL_NOTHING_WAITS: String = "approval.nothing_waits" + const val APPROVAL_LIST_TITLE: String = "approval.list_title" + + /** Takes `{id}`, `{tool}` and `{reason}`. */ + const val APPROVAL_LIST_ENTRY: String = "approval.list_entry" + + const val APPROVAL_LIST_HINT: String = "approval.list_hint" + + /** Takes `{ids}`. */ + const val APPROVAL_SEVERAL: String = "approval.several" + + /** Takes `{code}` and `{minutes}`. */ + const val PAIRING_CODE: String = "pairing.code" + + /** Takes `{command}`. */ + const val REFUSED_ADMIN: String = "refused.admin" + + /** Takes `{command}`. */ + const val REFUSED_ADMIN_HERE: String = "refused.admin_here" + + /** What a step of work did and what it did it to, as the kinds of step the standard names. */ + const val ACTIVITY_VERB_READ_PRESENT: String = "activity.verb.read.present" + const val ACTIVITY_VERB_READ_PAST: String = "activity.verb.read.past" + const val ACTIVITY_VERB_LIST_PRESENT: String = "activity.verb.list.present" + const val ACTIVITY_VERB_LIST_PAST: String = "activity.verb.list.past" + const val ACTIVITY_VERB_SEARCH_PRESENT: String = "activity.verb.search.present" + const val ACTIVITY_VERB_SEARCH_PAST: String = "activity.verb.search.past" + const val ACTIVITY_VERB_EDIT_PRESENT: String = "activity.verb.edit.present" + const val ACTIVITY_VERB_EDIT_PAST: String = "activity.verb.edit.past" + const val ACTIVITY_VERB_DELETE_PRESENT: String = "activity.verb.delete.present" + const val ACTIVITY_VERB_DELETE_PAST: String = "activity.verb.delete.past" + const val ACTIVITY_VERB_MOVE_PRESENT: String = "activity.verb.move.present" + const val ACTIVITY_VERB_MOVE_PAST: String = "activity.verb.move.past" + const val ACTIVITY_VERB_EXECUTE_PRESENT: String = "activity.verb.execute.present" + const val ACTIVITY_VERB_EXECUTE_PAST: String = "activity.verb.execute.past" + const val ACTIVITY_VERB_FETCH_PRESENT: String = "activity.verb.fetch.present" + const val ACTIVITY_VERB_FETCH_PAST: String = "activity.verb.fetch.past" + const val ACTIVITY_VERB_THINK_PRESENT: String = "activity.verb.think.present" + const val ACTIVITY_VERB_THINK_PAST: String = "activity.verb.think.past" + const val ACTIVITY_VERB_OTHER_PRESENT: String = "activity.verb.other.present" + const val ACTIVITY_VERB_OTHER_PAST: String = "activity.verb.other.past" + const val ACTIVITY_NOUN_READ_ONE: String = "activity.noun.read.one" + const val ACTIVITY_NOUN_READ_MANY: String = "activity.noun.read.many" + const val ACTIVITY_NOUN_LIST_ONE: String = "activity.noun.list.one" + const val ACTIVITY_NOUN_LIST_MANY: String = "activity.noun.list.many" + const val ACTIVITY_NOUN_SEARCH_ONE: String = "activity.noun.search.one" + const val ACTIVITY_NOUN_SEARCH_MANY: String = "activity.noun.search.many" + const val ACTIVITY_NOUN_EDIT_ONE: String = "activity.noun.edit.one" + const val ACTIVITY_NOUN_EDIT_MANY: String = "activity.noun.edit.many" + const val ACTIVITY_NOUN_DELETE_ONE: String = "activity.noun.delete.one" + const val ACTIVITY_NOUN_DELETE_MANY: String = "activity.noun.delete.many" + const val ACTIVITY_NOUN_MOVE_ONE: String = "activity.noun.move.one" + const val ACTIVITY_NOUN_MOVE_MANY: String = "activity.noun.move.many" + const val ACTIVITY_NOUN_EXECUTE_ONE: String = "activity.noun.execute.one" + const val ACTIVITY_NOUN_EXECUTE_MANY: String = "activity.noun.execute.many" + const val ACTIVITY_NOUN_FETCH_ONE: String = "activity.noun.fetch.one" + const val ACTIVITY_NOUN_FETCH_MANY: String = "activity.noun.fetch.many" + const val ACTIVITY_NOUN_THINK_ONE: String = "activity.noun.think.one" + const val ACTIVITY_NOUN_THINK_MANY: String = "activity.noun.think.many" + const val ACTIVITY_NOUN_OTHER_ONE: String = "activity.noun.other.one" + const val ACTIVITY_NOUN_OTHER_MANY: String = "activity.noun.other.many" + const val ACTIVITY_VERB_SEND_PRESENT: String = "activity.verb.send.present" + const val ACTIVITY_VERB_SEND_PAST: String = "activity.verb.send.past" + const val ACTIVITY_NOUN_SEND_ONE: String = "activity.noun.send.one" + const val ACTIVITY_NOUN_SEND_MANY: String = "activity.noun.send.many" + + /** Takes `{noun}`. */ + const val ACTIVITY_SUMMARY_NOT_RUN: String = "activity.summary.not_run" + + /** Takes `{count}`. */ + const val ACTIVITY_OMITTED_ONE: String = "activity.omitted.one" + const val ACTIVITY_OMITTED_MANY: String = "activity.omitted.many" + + /** Takes `{tool}` and `{title}`. */ + const val ACTIVITY_CALL_WAITING_APPROVAL: String = "activity.call.waiting_approval" + const val ACTIVITY_CALL_NOT_RUN: String = "activity.call.not_run" + const val ACTIVITY_CALL_BACKGROUNDED: String = "activity.call.backgrounded" + + /** Takes `{code}`. */ + const val ACTIVITY_CALL_EXIT: String = "activity.call.exit" + + const val ACTIVITY_CALL_FAILED: String = "activity.call.failed" + + /** Takes `{added}` and `{removed}`. */ + const val ACTIVITY_CALL_DIFF: String = "activity.call.diff" + + /** Takes `{agent}`. */ + const val ACTIVITY_DELEGATE_PRESENT: String = "activity.delegate.present" + const val ACTIVITY_DELEGATE_PAST: String = "activity.delegate.past" + + const val ACTIVITY_CALL_BY_AGENT: String = "activity.call.by_agent" +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdown.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdown.kt new file mode 100644 index 00000000..4e4b1023 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdown.kt @@ -0,0 +1,252 @@ +package org.foedusprogramme.alexandrite.channel.onebot.mapping + +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment + +/** + * Renders Markdown as the plain text a chat of this platform reads, in the segments a message takes. + * + * A message of this platform has no formatting of its own, so a marker of the formatting would arrive as the character + * it is written with. What the marker means is kept as words instead: emphasis keeps its text, a heading keeps its + * title, a code block keeps its lines and a link keeps its address beside its text. A list keeps its marker, which a + * chat already reads as one, and an image becomes an image segment. + */ +internal object OneBotMarkdown { + /** [markdown] as text segments and image segments, in the order it writes them. */ + fun segments(markdown: String): List = Renderer().also { it.render(markdown) }.segments() +} + +/** What a chat is told of one message, built as its lines are read. */ +private class Renderer { + private val text = StringBuilder() + private val images = mutableListOf() + + fun render(markdown: String) { + val lines = markdown.replace("\r\n", "\n").replace('\r', '\n').split('\n') + var at = 0 + while (at < lines.size) { + at = block(lines, at) + if (at < lines.size) text.append('\n') + } + } + + fun segments(): List { + val segments = mutableListOf() + var at = 0 + for (match in IMAGE_MARKER_IN_TEXT.findAll(text)) { + if (match.range.first > at) segments += OneBotSegment.Text(text.substring(at, match.range.first)) + images.getOrNull(match.groupValues[1].toInt())?.let { segments += OneBotSegment.Image(file = it) } + at = match.range.last + 1 + } + if (at < text.length) segments += OneBotSegment.Text(text.substring(at)) + return segments + } + + /** Reads the block that starts at line [at] and returns the line after it. */ + private fun block(lines: List, at: Int): Int { + val line = lines[at] + fenceOpening(line)?.let { return fence(lines, at, it) } + if (startsTable(lines, at)) return table(lines, at) + if (RULE.matches(line)) { + text.append(RULE_TEXT) + return at + 1 + } + HEADING.matchEntire(line)?.let { heading -> + inline(heading.groupValues[1]) + return at + 1 + } + QUOTE.matchEntire(line)?.let { quote -> + inline(quote.groupValues[1]) + return at + 1 + } + BULLET.matchEntire(line)?.let { item -> + text.append(item.groupValues[1]) + inline(item.groupValues[2]) + return at + 1 + } + ORDERED.matchEntire(line)?.let { item -> + text.append(item.groupValues[1]) + inline(item.groupValues[2]) + return at + 1 + } + inline(line.trimEnd()) + return at + 1 + } + + /** The lines of the fence that starts at [at], without the fence and what follows its opening. */ + private fun fence(lines: List, at: Int, fence: Fence): Int { + var end = at + 1 + while (end < lines.size && !fence.closedBy(lines[end])) end++ + text.append(lines.subList(at + 1, end).joinToString("\n") { fence.unindented(it) }) + return if (end < lines.size) end + 1 else end + } + + /** The rows of the table that starts at [at], without the row that only says how its cells line up. */ + private fun table(lines: List, at: Int): Int { + var end = at + 2 + while (end < lines.size && lines[end].isNotBlank() && '|' in lines[end]) end++ + text.append((listOf(lines[at]) + lines.subList(at + 2, end)).joinToString("\n") { cells(it) }) + return end + } + + /** The cells of one row of a table, which a chat reads apart by the pipe between them. */ + private fun cells(row: String): String = row.trim().trim('|').split('|').joinToString(" | ") { it.trim() } + + /** Appends [source] as a chat reads it: without the markers of its formatting and with its images kept apart. */ + private fun inline(source: String) { + val code = mutableListOf() + var work = protectCode(source.trimEnd(), code) + work = IMAGE.replace(work) { match -> + images += match.groupValues[2] + "$IMAGE_MARKER${images.lastIndex}$MARKER_END" + } + work = LINK.replace(work) { match -> + val label = match.groupValues[1].trim() + val url = match.groupValues[2] + when { + label.isEmpty() -> url + label == url -> url + else -> "$label ($url)" + } + } + work = stripEmphasis(work) + work = ESCAPED.replace(work) { it.value.substring(1) } + text.append(restoreCode(work, code)) + } + + /** [source] with each code span replaced by a marker, whose text is put back by [restoreCode]. */ + private fun protectCode(source: String, code: MutableList): String { + val out = StringBuilder() + var at = 0 + while (at < source.length) { + val open = source.indexOf('`', at) + if (open < 0) { + out.append(source, at, source.length) + break + } + val length = run(source, open, '`') + val closing = closingRun(source, open + length, length) + if (closing < 0) { + out.append(source, at, open + length) + at = open + length + continue + } + out.append(source, at, open) + code += codeText(source.substring(open + length, closing)) + out.append(CODE_MARKER).append(code.lastIndex).append(MARKER_END) + at = closing + length + } + return out.toString() + } + + /** The index of the run of [length] backticks that closes the span from [from] on, -1 when it never closes. */ + private fun closingRun(source: String, from: Int, length: Int): Int { + var search = from + while (search < source.length) { + val closing = source.indexOf('`', search) + if (closing < 0) return -1 + val closingLength = run(source, closing, '`') + if (closingLength == length) return closing + search = closing + closingLength + } + return -1 + } + + /** [source] with what emphasis and strikethrough wrap kept and their markers left out. */ + private fun stripEmphasis(source: String): String { + var result = source + var previous = "" + while (result != previous) { + previous = result + result = EMPHASIS.replace(result) { it.groupValues[2] } + result = UNDERSCORE_EMPHASIS.replace(result) { it.groupValues[1] } + } + return result + } + + /** [source] with the text kept by [protectCode] put back. */ + private fun restoreCode(source: String, code: List): String = + CODE_MARKER_IN_TEXT.replace(source) { code.getOrElse(it.groupValues[1].toInt()) { "" } } +} + +/** An opening fence of a code block: how far it is indented and what it is written with. */ +private class Fence(private val indent: Int, private val marker: Char, private val length: Int) { + /** Whether [line] is the fence that closes this one. */ + fun closedBy(line: String): Boolean { + val trimmed = line.trim() + return trimmed.length >= length && trimmed.all { it == marker } + } + + /** [line] without the indentation of the opening fence. */ + fun unindented(line: String): String { + var start = 0 + while (start < indent && start < line.length && (line[start] == ' ' || line[start] == '\t')) start++ + return line.substring(start) + } +} + +/** The fence that opens the code block [line] starts, null when the line starts none. */ +private fun fenceOpening(line: String): Fence? { + val match = FENCE.matchEntire(line) ?: return null + val (indent, marker, info) = match.destructured + // A backtick fence's info string may not hold a backtick, which the code of a span would write there. + if (marker[0] == '`' && '`' in info) return null + return Fence(indent.length, marker[0], marker.length) +} + +/** Whether the row that lines a table's cells up follows the row at [at]. */ +private fun startsTable(lines: List, at: Int): Boolean = + at + 1 < lines.size && '|' in lines[at] && '|' in lines[at + 1] && DELIMITER_ROW.matches(lines[at + 1]) + +/** How many of [char] start at [at]. */ +private fun run(source: String, at: Int, char: Char): Int { + var end = at + while (end < source.length && source[end] == char) end++ + return end - at +} + +/** The text of a code span: line breaks as spaces, without one space on each side. */ +private fun codeText(raw: String): String { + val text = raw.replace('\n', ' ') + val padded = text.length >= 2 && text.first() == ' ' && text.last() == ' ' && text.isNotBlank() + return if (padded) text.substring(1, text.length - 1) else text +} + +/** What a line that only says how a table's cells line up is written with. */ +private const val RULE_TEXT = "———" + +/** What a marker in the text being built starts with, and what ends it: not a character a message holds. */ +private const val IMAGE_MARKER = "\u0000I" +private const val CODE_MARKER = "\u0000C" +private const val MARKER_END = "\u0000" + +private val IMAGE_MARKER_IN_TEXT = Regex("\u0000I(\\d+)\u0000") +private val CODE_MARKER_IN_TEXT = Regex("\u0000C(\\d+)\u0000") + +/** Emphasis and strikethrough, which keep what they wrap and lose the marker that wraps it. */ +private val EMPHASIS = Regex("""(?[ \t]?)+(.*)$""") + +private val BULLET = Regex("""^([ \t]*[-*+][ \t]+)(.*)$""") + +private val ORDERED = Regex("""^([ \t]*\d{1,9}[.)][ \t]+)(.*)$""") + +private val DELIMITER_ROW = Regex("""^[ \t]*\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$""") + +/** An image, which becomes a segment of its own rather than text a chat cannot see. */ +private val IMAGE = Regex("""(?` and a group is `group:`, the two ids the standard reports. A + * temporary session, which is a private message that came from inside a group, keeps the group as the chat and the + * sender as its thread, so that a reply goes back to the sender rather than to the group. + */ +public object OneBotChats { + /** The prefix of a private chat. */ + public const val PRIVATE: String = "private" + + /** The prefix of a group chat. */ + public const val GROUP: String = "group" + + /** The chat [event] happened in. */ + public fun addressOf(instance: ChannelInstanceId, event: OneBotEvent.Message): ChatAddress = when (event) { + is OneBotEvent.Message.Private -> if (event.subType == OneBotEvent.Message.GROUP_TEMPORARY) { + ChatAddress(instance, tempChat(event), thread = event.userId.value) + } else { + ChatAddress(instance, "$PRIVATE:${event.userId}") + } + + is OneBotEvent.Message.Group -> ChatAddress(instance, "$GROUP:${event.groupId}") + } + + /** The user of [id] in this instance. */ + public fun user(instance: ChannelInstanceId, id: UserId): UserAddress = UserAddress(instance, id.value) + + /** The chat `private:`. */ + public fun privateChat(instance: ChannelInstanceId, id: UserId): ChatAddress = + ChatAddress(instance, "$PRIVATE:${id.value}") + + /** The chat `group:`. */ + public fun groupChat(instance: ChannelInstanceId, id: GroupId): ChatAddress = + ChatAddress(instance, "$GROUP:${id.value}") + + /** The group a temporary session came from, as the implementation reported it, or a chat of the sender alone. */ + private fun tempChat(event: OneBotEvent.Message.Private): String { + val group = (event.raw["group_id"] as? JsonPrimitive)?.content + return if (group.isNullOrEmpty()) "$PRIVATE:temporary" else "$GROUP:$group" + } +} + +/** + * The messages of an OneBot instance, in both directions. + * + * A segment the SDK has no shape for is not dropped: it becomes a [DisplayFact] on the way in, so the model still + * reads that it was there, and every message keeps the raw event, so a consumer that needs the original sees it. + */ +public object OneBotMessages { + /** What an OneBot channel can do, as this module reports it. */ + // The kinds of turn event a chat is told of while a turn works. + private val TOOL_EVENTS: Set = + setOf(TurnEventKind.TOOL_CALL_STARTED, TurnEventKind.TOOL_CALL_UPDATED) + + public fun capabilities( + proactive: Boolean, + /** Whether this instance tells a chat what a turn is doing while it works. */ + progress: Boolean = true, + /** Whether this instance shows what the model says while a turn works on tools. */ + streaming: Boolean = true, + ): ChannelCapabilities = ChannelCapabilities.builder() + .markups(setOf(Markup.PLAIN, Markup.MARKDOWN)) + .streaming(streaming) + .finalReplacesPreview(false) + .proactive(proactive) + .events(if (progress) TOOL_EVENTS else emptySet()) + .maxPartsPerReply(null) + .files(true) + .maxFileBytes(MAX_FILE_BYTES) + .build() + +/** + * The bytes one file of a chat may hold, which is the ten gigabytes this platform takes. + * + * A larger one is refused rather than sent, the way the telegram channel refuses one past what a bot of its own + * platform may send. + */ + internal const val MAX_FILE_BYTES: Long = 10L * 1024 * 1024 * 1024 + + /** + * The message [event] is, where [fetched] is what the implementation reported about the message it answers. + * + * A report says which message is answered and nothing about it, so what it says is read from the implementation + * before this is called; without it the quote names the message that is answered and carries no text. + */ + public fun incoming( + instance: ChannelInstanceId, + event: OneBotEvent.Message, + admins: Set = emptySet(), + quoted: QuotedMessage? = null, + /** How the bytes of a reported image, voice or video are read, null when they cannot be. */ + reader: MediaReader? = null, + ): IncomingMessage { + val chat = OneBotChats.addressOf(instance, event) + val segments = event.message.segments + return IncomingMessage.builder( + ref = ChannelMessageRef(chat, event.messageId.value), + sender = sender(instance, event, admins), + chatInfo = chatInfo(event), + receivedAt = Instant.ofEpochSecond(event.time), + forwarded = null, + ) + .text(segments.filterIsInstance().joinToString("") { it.text }) + // The quoted message's own media too, since a person who quotes a picture and asks about it is asking + // about that picture: without this the quote reaches the model as the marker for an image and no image. + .media(media(segments, reader) + media(quoted?.segments.orEmpty(), reader)) + .quote(quote(event, chat, quoted)) + .facts(facts(event, segments)) + .build() + } + + /** The message that sends [message] in [chat]. */ + public fun outgoing(chat: ChatAddress, message: OutboundMessage): OneBotMessage { + val segments = when (message.markup.id) { + Markup.MARKDOWN.id -> OneBotMarkdown.segments(message.text) + else -> listOf(OneBotSegment.Text(message.text)) + } + // A reply names a message of the implementation by its numeric id. A reference this version made up because + // no id came back, `sent` or `async`, names no message there, so a message that answers one is sent without + // the reply segment rather than failing before it is sent. + val reply = message.replyTo + ?.id + ?.takeIf { it.toLongOrNull() != null } + ?.let { OneBotSegment.Reply(MessageId(it)) } + return OneBotMessage.ArrayValue(if (reply == null) segments else listOf(reply) + segments) + } + + /** Whether [chat] is a group, which decides the action a message is sent with. */ + public fun isGroup(chat: ChatAddress): Boolean = chat.chat.startsWith(OneBotChats.GROUP + ":") + + /** + * The user a temporary session belongs to, null when [chat] is no such session. + * + * A temporary session is a message of a group sent privately, and its thread is the user who sent it. A reply has + * to go there rather than into the group the message came through. + */ + public fun privateTemporary(chat: ChatAddress): UserId? = chat.thread + ?.takeIf { isGroup(chat) && it.isNotEmpty() } + ?.let(::UserId) + + /** The user a private chat belongs to, null when [chat] is no private chat. */ + public fun privateUser(chat: ChatAddress): UserId? = chat.chat + .takeIf { it.startsWith(OneBotChats.PRIVATE + ":") } + ?.removePrefix(OneBotChats.PRIVATE + ":") + ?.takeIf { it.isNotEmpty() } + ?.let(::UserId) + + /** The group a group chat belongs to, null when [chat] is no group chat. */ + public fun group(chat: ChatAddress): GroupId? = chat.chat + .takeIf { it.startsWith(OneBotChats.GROUP + ":") } + ?.removePrefix(OneBotChats.GROUP + ":") + ?.takeIf { it.isNotEmpty() } + ?.let(::GroupId) + + /** What a call that ended in [result] means for the delivery of a message to [chat]. */ + public fun delivery(result: OneBotResult, chat: ChatAddress): Delivery = when (result) { + is OneBotResult.Ok -> { + // The data of the answer of a send is either the object that names the message or the message itself, + // which is what an implementation that answers with the identifier alone sends. + val data = (result.data as? JsonObject)?.get("data") as? JsonObject ?: result.data + // The `echo` of an answer is the key this client sent to match it to its call, not an id the + // implementation gave the message, so an answer that names no message is a delivery this version cannot + // name rather than one named after a correlation key. + val id = + ((data as? JsonObject)?.get("message_id") as? JsonPrimitive)?.content + ?: "sent" + Delivery.Delivered(listOf(ChannelMessageRef(chat, id))) + } + + is OneBotResult.Async -> Delivery.Delivered(listOf(ChannelMessageRef(chat, "async"))) + + is OneBotResult.Failed -> Delivery.NotDelivered(failureKind(result.retcode), result.message) + + is OneBotResult.Unreachable -> Delivery.NotDelivered( + when { + // Whether the same call may succeed again is the one thing a caller can act on, and the transport + // already answers it: a 5xx is retryable where a 4xx is not. + result.failure.retryable -> DeliveryFailure.TRANSIENT + + result.failure.kind == OneBotFailureKind.AUTHENTICATION -> DeliveryFailure.FORBIDDEN + + else -> DeliveryFailure.UNKNOWN + }, + result.failure.message, + ) + + is OneBotResult.Malformed -> Delivery.NotDelivered(DeliveryFailure.UNKNOWN, result.detail) + } + + /** The failure an implementation's [retcode] means, as far as this version can tell. */ + private fun failureKind(retcode: Int): DeliveryFailure = when (retcode) { + RATE_LIMITED -> DeliveryFailure.RATE_LIMITED + FORBIDDEN, NOT_ALLOWED -> DeliveryFailure.FORBIDDEN + TOO_LONG -> DeliveryFailure.TOO_LONG + NOT_FOUND -> DeliveryFailure.CHAT_GONE + else -> DeliveryFailure.UNKNOWN + } + + /** Who sent [event], as an operator of the instance when the report or the list of [admins] says so. */ + public fun senderOf(instance: ChannelInstanceId, event: OneBotEvent.Message, admins: Set): ChatUser = + sender(instance, event, admins) + + private fun sender(instance: ChannelInstanceId, event: OneBotEvent.Message, admins: Set): ChatUser { + val reported = event.sender + val id = event.userId.value + val card = reported.card?.takeIf { it.isNotEmpty() } + return ChatUser( + address = OneBotChats.user(instance, event.userId), + displayName = card ?: reported.nickname ?: id, + username = null, + isBot = reported.userId == event.selfId.value, + isAdmin = id in admins || (event is OneBotEvent.Message.Group && reported.isAdmin), + ) + } + + private fun chatInfo(event: OneBotEvent.Message): ChatInfo = when (event) { + is OneBotEvent.Message.Private -> ChatInfo(ChatKind.DIRECT, null, null) + + // A group message carries no name of the group, and the card of whoever spoke is the name of that member, so + // putting it here would label the chat with the last person who said something. + is OneBotEvent.Message.Group -> ChatInfo(ChatKind.GROUP, null, null) + } + + private fun media(segments: List, reader: MediaReader?): List = + segments.mapNotNull { segment -> + val attachment = when (segment) { + is OneBotSegment.Image -> MediaKind.IMAGE to (segment.url ?: segment.file) + is OneBotSegment.Record -> MediaKind.AUDIO to (segment.url ?: segment.file) + is OneBotSegment.Video -> MediaKind.VIDEO to (segment.url ?: segment.file) + else -> return@mapNotNull null + } + val (kind, reference) = attachment + if (reference.isNullOrEmpty()) return@mapNotNull null + val content = reader?.content(segment) ?: unread(reference) + MediaAttachment.builder(kind, mediaType(kind), content) + .name(reference.substringAfterLast('/').takeIf { it.isNotEmpty() }) + .build() + } + + /** + * The bytes of [reference], which this reader does not read. + * + * A segment is named whether or not its bytes can be read, so that the agent knows the message carried one: a + * read that throws is what tells it the file could not be loaded, where naming no attachment at all would hide + * that the message held anything. + */ + private fun unread(reference: String): MediaContent = MediaContent { + throw UnsupportedOperationException("Reading '$reference' is not implemented yet.") + } + + /** The media type this version reports while it cannot read the file itself. */ + private fun mediaType(kind: MediaKind): String = when (kind) { + MediaKind.IMAGE -> "image/*" + MediaKind.AUDIO -> "audio/*" + else -> "video/*" + } + + /** + * What [event] is, as a message that another one can quote. + * + * Every report is read this way and kept for a while, because a report names the message it answers and says + * nothing about it, and reading it back from the implementation costs a call it usually does not need. + */ + public fun quotable(instance: ChannelInstanceId, event: OneBotEvent.Message): QuotedMessage { + val reported = event.sender + return QuotedMessage( + text = quotedText(event.message.segments), + authorName = reported.card?.takeIf { it.isNotEmpty() } ?: reported.nickname, + author = OneBotChats.user(instance, event.userId), + authorIsBot = reported.userId == event.selfId.value, + segments = event.message.segments, + ) + } + + /** What the implementation reported about a message is, as a message that another one can quote. */ + public fun quotable(instance: ChannelInstanceId, info: OneBotMessageInfo, selfId: String?): QuotedMessage { + val reported = info.sender + val segments = OneBotSegmentCodec.decodeMessage(info.message ?: JsonNull).segments + return QuotedMessage( + text = quotedText(segments), + authorName = reported?.card?.takeIf { it.isNotEmpty() } ?: reported?.nickname, + author = reported?.userId?.let { OneBotChats.user(instance, it) }, + authorIsBot = reported?.userId?.value != null && reported.userId.value == selfId, + segments = segments, + ) + } + + /** + * What [event] answers, where [quoted] is what was read about the message it answers. + * + * A report names the message it answers and says nothing else about it, so [quoted] is read separately and can be + * missing, since a message may be older than what the channel kept and no longer readable at all. The quote then + * still names the message that is answered, and says that it holds nothing. + */ + private fun quote(event: OneBotEvent.Message, chat: ChatAddress, quoted: QuotedMessage?): Quote? { + val reply = event.message.segments.filterIsInstance().firstOrNull() ?: return null + val target = ChannelMessageRef(chat, reply.id.value) + if (quoted == null) { + return Quote.builder("").target(target).trust(QuoteTrust.UNTRUSTED).build() + } + return Quote.builder(quoted.text) + .senderName(quoted.authorName) + .sender(quoted.author) + .senderIsBot(quoted.authorIsBot) + .target(target) + .trust(QuoteTrust.UNTRUSTED) + .build() + } + + /** + * What a quoted message says: its texts, and what its other parts are. + * + * A part that carries something a reader would know is named after that, because a quote of a card that showed + * only its type would say nothing of what the sender meant. + */ + private fun quotedText(segments: List): String = segments.joinToString("") { segment -> + when (segment) { + is OneBotSegment.Text -> segment.text + + is OneBotSegment.At -> "@${segment.userId}" + + is OneBotSegment.Image -> "[image]" + + is OneBotSegment.Record -> "[voice]" + + is OneBotSegment.Video -> "[video]" + + is OneBotSegment.Face -> "[face]" + + is OneBotSegment.Music -> { + val named = listOfNotNull(segment.title, segment.content).joinToString(" - ") + val where = segment.url ?: segment.audio + "[music${if (named.isEmpty()) "" else ": $named"}]${if (where == null) "" else " $where"}" + } + + is OneBotSegment.Share -> "[link: ${segment.title}] ${segment.url}" + + is OneBotSegment.Json -> "[json] ${segment.data.take(QUOTED_CHARS)}" + + is OneBotSegment.Xml -> "[xml] ${segment.data.take(QUOTED_CHARS)}" + + else -> "[${segment.type}]" + } + } + + private fun facts(event: OneBotEvent.Message, segments: List): List = buildList { + if (event is OneBotEvent.Message.Group) { + event.anonymous?.let { add(DisplayFact("anonymous", it.name ?: it.flag ?: "unnamed")) } + } + for (segment in segments) { + when (segment) { + is OneBotSegment.Unknown -> add(DisplayFact("unsupported segment", segment.type)) + is OneBotSegment.Json -> add(DisplayFact("json message", segment.data)) + is OneBotSegment.Xml -> add(DisplayFact("xml message", segment.data)) + is OneBotSegment.Forward -> add(DisplayFact("forwarded message", segment.id)) + is OneBotSegment.At -> add(DisplayFact("mentioned", segment.userId)) + is OneBotSegment.Image -> add(DisplayFact("image", segment.file ?: segment.url ?: "unnamed")) + is OneBotSegment.Record -> add(DisplayFact("voice", segment.file ?: segment.url ?: "unnamed")) + is OneBotSegment.Video -> add(DisplayFact("video", segment.file ?: segment.url ?: "unnamed")) + is OneBotSegment.Face -> add(DisplayFact("emoji", segment.id)) + is OneBotSegment.Share -> add(DisplayFact("link", segment.url)) + else -> Unit + } + } + } + + private const val RATE_LIMITED = 1004 + + private const val FORBIDDEN = 1003 + + private const val NOT_ALLOWED = 1002 + + private const val TOO_LONG = 1005 + + private const val NOT_FOUND = 100 +} + +/** The characters of a quoted card that are kept, which is enough of it to know what it is. */ +private const val QUOTED_CHARS = 500 + +/** + * What a message holds, as the channel read it, so that a message answering it can say what it answered. + * + * This is what a channel keeps of the messages it read: a report names the message it answers and nothing about it, + * so what that message holds is either read when it arrives and kept, or read back from the implementation. + */ +public class QuotedMessage( + public val text: String, + public val authorName: String?, + public val author: UserAddress?, + public val authorIsBot: Boolean, + /** + * What the quoted message was reported to hold, which is kept beside its text so that the media of a quoted + * message reaches whoever answers it: a picture cannot be read out of a text, however it is written. + */ + public val segments: List = emptyList(), +) + +/** + * How the bytes a reported segment names are read. + * + * A report names an image or a voice message by what the implementation knows it as, and reading it needs this + * plugin's transport, which the mapping does not have: it is given one of these by whoever asks for the bytes. A + * segment it cannot read names no attachment, so the rest of the message is read either way. + */ +public fun interface MediaReader { + /** How to read [segment], null when this reader does not read that kind. */ + public fun content(segment: OneBotSegment): MediaContent? +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotNotices.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotNotices.kt new file mode 100644 index 00000000..54ffd9b1 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotNotices.kt @@ -0,0 +1,210 @@ +package org.foedusprogramme.alexandrite.channel.onebot.mapping + +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.sdk.channel.ChannelNotice +import org.foedusprogramme.alexandrite.sdk.channel.ChannelNoticeKind +import org.foedusprogramme.alexandrite.sdk.channel.DisplayFact +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.chat.UserAddress +import java.time.Instant + +/** + * What a report says besides a message, as the channel contract's own notice. + * + * A report of an event this version does not model is published all the same, under the name the implementation gave + * it: a plugin written for an implementation's own vocabulary reads it without this mapping having to know it, and a + * subscriber sees that something happened rather than nothing at all. + */ +public object OneBotNotices { + /** The notice [event] reports, null when it reports none this channel publishes. */ + public fun of(instance: ChannelInstanceId, event: OneBotEvent, selfId: String): ChannelNotice? = when (event) { + is OneBotEvent.Notice.GroupIncrease -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.MEMBER_JOINED, + chat = OneBotChats.groupChat(instance, event.groupId), + actor = event.operatorId.takeIf { it != event.userId }?.let { user(instance, it, selfId) }, + target = user(instance, event.userId, selfId), + facts = listOfNotNull(subtype(event.subType)), + ) + + is OneBotEvent.Notice.GroupDecrease -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.MEMBER_LEFT, + chat = OneBotChats.groupChat(instance, event.groupId), + actor = event.operatorId.takeIf { it != event.userId }?.let { user(instance, it, selfId) }, + target = user(instance, event.userId, selfId), + facts = listOfNotNull(subtype(event.subType)), + ) + + is OneBotEvent.Notice.GroupBan -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.MEMBER_BANNED, + chat = OneBotChats.groupChat(instance, event.groupId), + actor = user(instance, event.operatorId, selfId), + target = user(instance, event.userId, selfId), + facts = listOfNotNull( + DisplayFact("muted", if (event.isBan) "yes" else "no"), + event.duration?.let { DisplayFact("seconds", "$it") }, + subtype(event.subType), + ), + ) + + is OneBotEvent.Notice.GroupAdmin -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.ROLE_CHANGED, + chat = OneBotChats.groupChat(instance, event.groupId), + target = user(instance, event.userId, selfId), + facts = listOf(DisplayFact("administrator", if (event.isSet) "yes" else "no")), + ) + + is OneBotEvent.Notice.GroupRecall -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.MESSAGE_RECALLED, + chat = OneBotChats.groupChat(instance, event.groupId), + actor = user(instance, event.operatorId, selfId), + target = user(instance, event.userId, selfId), + message = ChannelMessageRef(OneBotChats.groupChat(instance, event.groupId), event.messageId.value), + ) + + is OneBotEvent.Notice.FriendRecall -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.MESSAGE_RECALLED, + chat = OneBotChats.privateChat(instance, event.userId), + target = user(instance, event.userId, selfId), + message = ChannelMessageRef(OneBotChats.privateChat(instance, event.userId), event.messageId.value), + ) + + is OneBotEvent.Notice.FriendAdd -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.FRIEND_ADDED, + chat = OneBotChats.privateChat(instance, event.userId), + target = user(instance, event.userId, selfId), + facts = listOfNotNull(subtype(event.subType)), + ) + + is OneBotEvent.Notice.GroupUpload -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind("group.file_uploaded"), + chat = OneBotChats.groupChat(instance, event.groupId), + actor = user(instance, event.userId, selfId), + facts = listOfNotNull( + DisplayFact("file", event.file.name), + event.file.size?.let { DisplayFact("bytes", "$it") }, + ), + ) + + is OneBotEvent.Notice.Notify -> notify(instance, event, selfId) + + is OneBotEvent.Request.Friend -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.FRIEND_REQUESTED, + chat = OneBotChats.privateChat(instance, event.userId), + target = user(instance, event.userId, selfId), + facts = requestFacts(event.comment, event.flag, null), + ) + + is OneBotEvent.Request.Group -> notice( + instance = instance, + event = event, + kind = ChannelNoticeKind.JOIN_REQUESTED, + chat = OneBotChats.groupChat(instance, event.groupId), + target = user(instance, event.userId, selfId), + facts = requestFacts(event.comment, event.flag, event.subType), + ) + + // Everything else keeps the name the implementation gave it, so that a vocabulary this version does not model + // reaches a subscriber written for that implementation. + is OneBotEvent.Notice.Other -> chatOf(instance, event)?.let { chat -> + notice( + instance = instance, + event = event, + kind = ChannelNoticeKind(event.noticeType), + chat = chat, + actor = event.userId()?.let { user(instance, it, selfId) }, + facts = listOfNotNull(subtype(event.subType)), + ) + } + + else -> null + } + + /** A poke, a lucky king or a member honor, which the implementation reports as one kind of notice. */ + private fun notify(instance: ChannelInstanceId, event: OneBotEvent.Notice.Notify, selfId: String): ChannelNotice { + val chat = event.groupId?.let { OneBotChats.groupChat(instance, it) } + ?: OneBotChats.privateChat(instance, event.userId) + return notice( + instance = instance, + event = event, + kind = ChannelNoticeKind("notice.${event.subType ?: "notify"}"), + chat = chat, + actor = user(instance, event.userId, selfId), + target = event.targetId?.let { user(instance, it, selfId) }, + facts = listOfNotNull(event.honorType?.let { DisplayFact("honor", it) }), + ) + } + + private fun notice( + instance: ChannelInstanceId, + event: OneBotEvent, + kind: ChannelNoticeKind, + chat: ChatAddress, + actor: ChatUser? = null, + target: ChatUser? = null, + message: ChannelMessageRef? = null, + facts: List = emptyList(), + ): ChannelNotice = ChannelNotice( + chat = chat, + kind = kind, + actor = actor, + target = target, + message = message, + receivedAt = Instant.ofEpochSecond(event.time), + facts = facts, + ) + + private fun requestFacts(comment: String, flag: String, subtype: String?): List = listOfNotNull( + DisplayFact("answer with", flag), + comment.takeIf { it.isNotBlank() }?.let { DisplayFact("said", it) }, + subtype(subtype), + ) + + private fun subtype(subtype: String?): DisplayFact? = subtype?.let { DisplayFact("how", it) } + + /** The chat of a notice this version does not model, which names a group only when its report carries one. */ + private fun chatOf(instance: ChannelInstanceId, event: OneBotEvent.Notice.Other): ChatAddress? { + val group = event.raw["group_id"]?.toString()?.trim('"')?.takeIf { it.isNotEmpty() && it != "null" } + val user = event.raw["user_id"]?.toString()?.trim('"')?.takeIf { it.isNotEmpty() && it != "null" } + return when { + group != null -> OneBotChats.groupChat(instance, GroupId(group)) + user != null -> OneBotChats.privateChat(instance, UserId(user)) + else -> null + } + } + + /** Whether an unmodelled notice names anybody, which is read from the report it kept. */ + private fun OneBotEvent.Notice.Other.userId(): UserId? = + raw["user_id"]?.toString()?.trim('"')?.takeIf { it.isNotEmpty() && it != "null" }?.let(::UserId) + + /** Who [id] is, as far as a report that names them without a profile says. */ + private fun user(instance: ChannelInstanceId, id: UserId, selfId: String): ChatUser = ChatUser( + address = UserAddress(instance, id.value), + displayName = id.value, + username = null, + isBot = id.value == selfId, + isAdmin = false, + ) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/pairing/OneBotPairingDesk.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/pairing/OneBotPairingDesk.kt new file mode 100644 index 00000000..7c95451b --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/pairing/OneBotPairingDesk.kt @@ -0,0 +1,13 @@ +package org.foedusprogramme.alexandrite.channel.onebot.pairing + +import org.foedusprogramme.alexandrite.channel.common.pairing.PairingDesk +import org.foedusprogramme.alexandrite.channel.common.pairing.Pairings +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.store.Records +import java.time.Clock + +/** The pairing of the chats of every OneBot channel instance. */ +@Singleton +@Contribute(PairingDesk::class) +internal class OneBotPairingDesk(clock: Clock, records: Records) : Pairings(clock, records) diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/OneBotIds.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/OneBotIds.kt new file mode 100644 index 00000000..65a3c2eb --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/OneBotIds.kt @@ -0,0 +1,119 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * A QQ number. + * + * OneBot prints it as a JSON number, but implementations also send it as a string, so [OneBotJson] decodes both. + * The value is kept as the string the platform used, so a number outside `Long` still round-trips. + */ +@JvmInline +@Serializable(with = UserIdSerializer::class) +public value class UserId(public val value: String) { + init { + require(value.isNotEmpty() && value.all(Char::isDigit)) { "A user id is a non-empty number, was '$value'." } + } + + /** The number, null when it does not fit a [Long]. */ + public val number: Long? get() = value.toLongOrNull() + + override fun toString(): String = value +} + +/** A group number, kept as [UserId] keeps a user number. */ +@JvmInline +@Serializable(with = GroupIdSerializer::class) +public value class GroupId(public val value: String) { + init { + require(value.isNotEmpty() && value.all(Char::isDigit)) { "A group id is a non-empty number, was '$value'." } + } + + public val number: Long? get() = value.toLongOrNull() + + override fun toString(): String = value +} + +/** The message id of one platform message, kept as [UserId] keeps a number. */ +@JvmInline +@Serializable(with = MessageIdSerializer::class) +public value class MessageId(public val value: String) { + init { + require(value.isNotEmpty() && value.all(Char::isDigit)) { "A message id is a non-empty number, was '$value'." } + } + + public val number: Long? get() = value.toLongOrNull() + + override fun toString(): String = value +} + +/** The QQ number an implementation reports as its own. */ +@JvmInline +@Serializable(with = SelfIdSerializer::class) +public value class SelfId(public val value: String) { + init { + require(value.isNotEmpty() && value.all(Char::isDigit)) { "A self id is a non-empty number, was '$value'." } + } + + public val number: Long? get() = value.toLongOrNull() + + override fun toString(): String = value +} + +internal object UserIdSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OneBotUserId", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: UserId) { + encoder.encodeNumber(value.value, value.number) + } + + override fun deserialize(decoder: Decoder): UserId = UserId(decoder.numberText()) +} + +internal object GroupIdSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OneBotGroupId", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: GroupId) { + encoder.encodeNumber(value.value, value.number) + } + + override fun deserialize(decoder: Decoder): GroupId = GroupId(decoder.numberText()) +} + +internal object MessageIdSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OneBotMessageId", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: MessageId) { + encoder.encodeNumber(value.value, value.number) + } + + override fun deserialize(decoder: Decoder): MessageId = MessageId(decoder.numberText()) +} + +internal object SelfIdSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OneBotSelfId", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: SelfId) { + encoder.encodeNumber(value.value, value.number) + } + + override fun deserialize(decoder: Decoder): SelfId = SelfId(decoder.numberText()) +} + +/** Prints [number] as a JSON number when it fits one, and the id as a string when it does not. */ +private fun Encoder.encodeNumber(id: String, number: Long?) { + if (number == null) encodeString(id) else encodeLong(number) +} + +/** The number [this] holds, whether the platform printed it as a JSON number or as a string. */ +private fun Decoder.numberText(): String { + val text = decodeString() + require(text.isNotEmpty() && text.all(Char::isDigit)) { "Expected a number, got '$text'." } + return text +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/OneBotStatus.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/OneBotStatus.kt new file mode 100644 index 00000000..ebb96f4b --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/OneBotStatus.kt @@ -0,0 +1,80 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol + +import kotlinx.serialization.Serializable + +/** + * The `status` field of an OneBot response. + * + * `ok` carries `retcode` 0, `async` carries 1, and `failed` carries anything else, so an implementation's own + * code stays readable through [of]. + */ +@JvmInline +@Serializable +public value class OneBotStatus(public val value: String) { + init { + require(value.isNotEmpty()) { "A status may not be empty." } + } + + override fun toString(): String = value + + public companion object { + public val OK: OneBotStatus = OneBotStatus("ok") + + public val ASYNC: OneBotStatus = OneBotStatus("async") + + public val FAILED: OneBotStatus = OneBotStatus("failed") + + /** The values this version knows. */ + public val entries: List = listOf(OK, ASYNC, FAILED) + + /** The value of [value], which keeps a status this version does not know. */ + public fun of(value: String): OneBotStatus = OneBotStatus(value) + + /** The status that [retcode] means. */ + public fun ofRetcode(retcode: Int): OneBotStatus = when (retcode) { + OneBotRetcode.OK -> OK + OneBotRetcode.ASYNC -> ASYNC + else -> FAILED + } + } +} + +/** The `retcode` values OneBot v11 defines. */ +public object OneBotRetcode { + public const val OK: Int = 0 + + public const val ASYNC: Int = 1 + + /** Bad request, the WebSocket counterpart of HTTP 400. */ + public const val BAD_REQUEST: Int = 1400 + + /** Unauthorized, the counterpart of HTTP 401. */ + public const val UNAUTHORIZED: Int = 1401 + + /** Forbidden, the counterpart of HTTP 403. */ + public const val FORBIDDEN: Int = 1403 + + /** No such action, the counterpart of HTTP 404. */ + public const val NOT_FOUND: Int = 1404 + + /** The counterpart of the HTTP status [status], which a WebSocket reports instead of a status line. */ + public fun ofHttp(status: Int): Int = when (status) { + 400 -> BAD_REQUEST + 401 -> UNAUTHORIZED + 403 -> FORBIDDEN + 404 -> NOT_FOUND + else -> BAD_REQUEST + } +} + +/** What an OneBot response was about, used to report and to classify failures. */ +public enum class OneBotResponseKind { + /** A synchronous success. */ + OK, + + /** Work accepted for later, whose outcome the client cannot learn. */ + ASYNC, + + /** The implementation refused the call, see its `retcode`. */ + FAILED, +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotAction.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotAction.kt new file mode 100644 index 00000000..c3142d12 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotAction.kt @@ -0,0 +1,82 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.api + +/** + * One action of the OneBot API. + * + * The suffix of a call is not part of the action: `send_private_msg_async` calls [name] with [OneBotCallSuffix.ASYNC], + * so an implementation never has to copy an action for the variants the standard derives. + */ +public data class OneBotAction( + /** The action name, such as `send_private_msg`. */ + public val name: String, + /** Which part of the API the action belongs to. */ + public val category: OneBotCategory, + /** Whether the action is one the standard hides from ordinary users, such as `.handle_quick_operation`. */ + public val hidden: Boolean = false, + /** Whether an implementation runs the action anyway, so the `async` suffix adds nothing. */ + public val inherentlyAsync: Boolean = false, +) { + init { + require(name.isNotEmpty()) { "An action needs a name." } + } + + /** Whether the action exists under [suffix]. */ + public fun accepts(suffix: OneBotCallSuffix): Boolean = when (suffix) { + OneBotCallSuffix.NONE -> true + OneBotCallSuffix.ASYNC, OneBotCallSuffix.RATE_LIMITED -> category.derivable + } + + /** The action name of a call with [suffix]. */ + public fun actionName(suffix: OneBotCallSuffix): String = name + suffix.suffix + + public companion object { + /** The prefix every hidden action carries. */ + public const val HIDDEN_PREFIX: String = "." + } +} + +/** A variant of an action the standard derives with a suffix. */ +public enum class OneBotCallSuffix(public val suffix: String) { + /** The plain call. */ + NONE(""), + + /** The call the implementation answers at once and carries out later. */ + ASYNC("_async"), + + /** The call the implementation queues behind its own rate limit. */ + RATE_LIMITED("_rate_limited"), + ; + + public companion object { + /** The suffix [action] carries, null when it carries none. */ + public fun of(action: String): OneBotCallSuffix? = + entries.firstOrNull { it != NONE && action.endsWith(it.suffix) } + + /** [action] without the suffix [this] names. */ + public fun strip(action: String): String = + entries.filter { it != NONE }.firstOrNull { action.endsWith(it.suffix) } + ?.let { action.dropLast(it.suffix.length) } + ?: action + } +} + +/** Which part of the API an action belongs to, which decides whether the derived suffixes exist. */ +public enum class OneBotCategory( + /** Whether the standard derives the `_async` and `_rate_limited` calls of the actions. */ + public val derivable: Boolean, +) { + /** Messages: sending, recalling and reading them. */ + MESSAGE(true), + + /** Friends, groups and their members. */ + GROUP(true), + + /** The credentials and the files of the implementation. */ + ACCOUNT(true), + + /** The implementation itself: its status, its version, its restart. */ + SYSTEM(true), + + /** What an implementation does on its own, such as running a quick operation. */ + HIDDEN(false), +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotApiTypes.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotApiTypes.kt new file mode 100644 index 00000000..04907fc7 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotApiTypes.kt @@ -0,0 +1,170 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId + +/** + * The answer `get_msg` gives. + * + * Fields an implementation adds are not kept here; a caller that needs one reads the raw response the result + * carries. + */ +@Serializable +public class OneBotMessageInfo( + @SerialName("time") public val time: Long? = null, + @SerialName("message_type") public val messageType: String? = null, + @SerialName("message_id") public val messageId: MessageId? = null, + @SerialName("real_id") public val realId: MessageId? = null, + @SerialName("sender") public val sender: OneBotSenderInfo? = null, + @SerialName("message") public val message: kotlinx.serialization.json.JsonElement? = null, +) + +/** The sender fields `get_msg` and the message events share. */ +@Serializable +public class OneBotSenderInfo( + @SerialName("user_id") public val userId: UserId? = null, + @SerialName("nickname") public val nickname: String? = null, + @SerialName("sex") public val sex: String? = null, + @SerialName("age") public val age: Int? = null, + @SerialName("card") public val card: String? = null, + @SerialName("area") public val area: String? = null, + @SerialName("level") public val level: String? = null, + @SerialName("role") public val role: String? = null, + @SerialName("title") public val title: String? = null, +) + +/** One friend, as `get_friend_list` reports it. */ +@Serializable +public class OneBotFriend( + @SerialName("user_id") public val userId: UserId, + @SerialName("nickname") public val nickname: String = "", + @SerialName("remark") public val remark: String? = null, +) + +/** A stranger, as `get_stranger_info` reports them. */ +@Serializable +public class OneBotStrangerInfo( + @SerialName("user_id") public val userId: UserId? = null, + @SerialName("nickname") public val nickname: String? = null, + @SerialName("sex") public val sex: String? = null, + @SerialName("age") public val age: Int? = null, +) + +/** A group, as `get_group_info` and `get_group_list` report it. */ +@Serializable +public class OneBotGroupInfo( + @SerialName("group_id") public val groupId: GroupId? = null, + @SerialName("group_name") public val groupName: String? = null, + @SerialName("member_count") public val memberCount: Int? = null, + @SerialName("max_member_count") public val maxMemberCount: Int? = null, +) + +/** One group member, as `get_group_member_info` and `get_group_member_list` report them. */ +@Serializable +public class OneBotGroupMemberInfo( + @SerialName("group_id") public val groupId: GroupId? = null, + @SerialName("user_id") public val userId: UserId? = null, + @SerialName("nickname") public val nickname: String? = null, + @SerialName("card") public val card: String? = null, + @SerialName("sex") public val sex: String? = null, + @SerialName("age") public val age: Int? = null, + @SerialName("area") public val area: String? = null, + @SerialName("join_time") public val joinTime: Long? = null, + @SerialName("last_sent_time") public val lastSentTime: Long? = null, + @SerialName("level") public val level: String? = null, + @SerialName("role") public val role: String? = null, + @SerialName("unfriendly") public val unfriendly: Boolean? = null, + @SerialName("title") public val title: String? = null, + @SerialName("title_expire_time") public val titleExpireTime: Long? = null, + @SerialName("card_changeable") public val cardChangeable: Boolean? = null, +) + +/** The honors of a group, as `get_group_honor_info` reports them. */ +@Serializable +public class OneBotGroupHonorInfo( + @SerialName("group_id") public val groupId: GroupId? = null, + @SerialName("current_talkative") public val currentTalkative: OneBotTalkative? = null, + @SerialName("talkative_list") public val talkativeList: List = emptyList(), + @SerialName("performer_list") public val performerList: List = emptyList(), + @SerialName("legend_list") public val legendList: List = emptyList(), + @SerialName("strong_newbie_list") public val strongNewbieList: List = emptyList(), + @SerialName("emotion_list") public val emotionList: List = emptyList(), +) + +/** The current 龙王 of a group. */ +@Serializable +public class OneBotTalkative( + @SerialName("user_id") public val userId: UserId? = null, + @SerialName("nickname") public val nickname: String? = null, + @SerialName("avatar") public val avatar: String? = null, + @SerialName("day_count") public val dayCount: Int? = null, +) + +/** One entry of a group's honor lists. */ +@Serializable +public class OneBotHonor( + @SerialName("user_id") public val userId: UserId? = null, + @SerialName("nickname") public val nickname: String? = null, + @SerialName("avatar") public val avatar: String? = null, + @SerialName("description") public val description: String? = null, +) + +/** The cookies of a domain. */ +@Serializable +public class OneBotCookies(@SerialName("cookies") public val cookies: String = "") + +/** A CSRF token. */ +@Serializable +public class OneBotCsrfToken(@SerialName("token") public val token: Long? = null) + +/** The cookies and CSRF token of a domain. */ +@Serializable +public class OneBotCredentials( + @SerialName("cookies") public val cookies: String = "", + @SerialName("csrf_token") public val csrfToken: Long? = null, +) + +/** The path of a file an implementation converted or downloaded. */ +@Serializable +public class OneBotFile(@SerialName("file") public val file: String = "") + +/** Whether an implementation can send a kind of media. */ +@Serializable +public class OneBotYesNo(@SerialName("yes") public val yes: Boolean = false) + +/** The status of an implementation, whose extra fields stay in the raw response. */ +@Serializable +public class OneBotStatusInfo( + /** Whether the account is online, null when the implementation cannot tell. */ + @SerialName("online") public val online: Boolean? = null, + /** Whether every module of the implementation works and the account is online. */ + @SerialName("good") public val good: Boolean? = null, +) + +/** What an implementation says it is. */ +@Serializable +public class OneBotVersionInfo( + @SerialName("app_name") public val appName: String? = null, + @SerialName("app_version") public val appVersion: String? = null, + @SerialName("protocol_version") public val protocolVersion: String? = null, +) + +/** The login of an implementation. */ +@Serializable +public class OneBotLoginInfo( + @SerialName("user_id") public val userId: UserId? = null, + @SerialName("nickname") public val nickname: String? = null, +) + +/** The data of a message that an implementation sent. */ +@Serializable +public class OneBotMessageSent(@SerialName("message_id") public val messageId: MessageId? = null) + +/** The result of `get_forward_msg`, whose nodes are the segments of the message. */ +@Serializable +public class OneBotForwardMessage( + @SerialName("message") public val message: kotlinx.serialization.json.JsonElement? = null, +) diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRegistry.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRegistry.kt new file mode 100644 index 00000000..bbcc0ee1 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRegistry.kt @@ -0,0 +1,109 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.api + +/** + * Every action of the OneBot v11 API, and how a call names one. + * + * A call is an action plus an [OneBotCallSuffix], so the `_async` and `_rate_limited` variants the standard derives + * exist without an implementation of their own. An action this version does not know is still a call: [resolve] + * reports it as unknown and the transport sends its name as it is, which keeps an implementation's own extension + * usable without a release of this module. + */ +public object OneBotRegistry { + /** The action that runs a quick operation on an event. */ + public val QUICK_OPERATION: OneBotAction = + OneBotAction(".handle_quick_operation", OneBotCategory.HIDDEN, hidden = true) + + /** The hidden actions of the standard. */ + public val hiddenActions: List = listOf(QUICK_OPERATION) + + /** The actions the standard lists, by name. */ + public val actions: List = listOf( + OneBotAction("send_private_msg", OneBotCategory.MESSAGE), + OneBotAction("send_group_msg", OneBotCategory.MESSAGE), + OneBotAction("send_msg", OneBotCategory.MESSAGE), + OneBotAction("delete_msg", OneBotCategory.MESSAGE), + OneBotAction("get_msg", OneBotCategory.MESSAGE), + OneBotAction("get_forward_msg", OneBotCategory.MESSAGE), + OneBotAction("send_like", OneBotCategory.GROUP), + OneBotAction("set_group_kick", OneBotCategory.GROUP), + OneBotAction("set_group_ban", OneBotCategory.GROUP), + OneBotAction("set_group_anonymous_ban", OneBotCategory.GROUP), + OneBotAction("set_group_whole_ban", OneBotCategory.GROUP), + OneBotAction("set_group_admin", OneBotCategory.GROUP), + OneBotAction("set_group_anonymous", OneBotCategory.GROUP), + OneBotAction("set_group_card", OneBotCategory.GROUP), + OneBotAction("set_group_name", OneBotCategory.GROUP), + OneBotAction("set_group_leave", OneBotCategory.GROUP), + OneBotAction("set_group_special_title", OneBotCategory.GROUP), + OneBotAction("set_friend_add_request", OneBotCategory.ACCOUNT), + OneBotAction("set_group_add_request", OneBotCategory.ACCOUNT), + OneBotAction("get_login_info", OneBotCategory.ACCOUNT), + OneBotAction("get_stranger_info", OneBotCategory.ACCOUNT), + OneBotAction("get_friend_list", OneBotCategory.ACCOUNT), + OneBotAction("get_group_info", OneBotCategory.GROUP), + OneBotAction("get_group_list", OneBotCategory.GROUP), + OneBotAction("get_group_member_info", OneBotCategory.GROUP), + OneBotAction("get_group_member_list", OneBotCategory.GROUP), + OneBotAction("get_group_honor_info", OneBotCategory.GROUP), + OneBotAction("get_cookies", OneBotCategory.ACCOUNT), + OneBotAction("get_csrf_token", OneBotCategory.ACCOUNT), + OneBotAction("get_credentials", OneBotCategory.ACCOUNT), + OneBotAction("get_record", OneBotCategory.ACCOUNT), + OneBotAction("get_image", OneBotCategory.ACCOUNT), + OneBotAction("can_send_image", OneBotCategory.SYSTEM), + OneBotAction("can_send_record", OneBotCategory.SYSTEM), + OneBotAction("get_status", OneBotCategory.SYSTEM), + OneBotAction("get_version_info", OneBotCategory.SYSTEM), + OneBotAction("set_restart", OneBotCategory.SYSTEM), + OneBotAction("clean_cache", OneBotCategory.SYSTEM), + ) + + private val byName: Map = (actions + hiddenActions).associateBy(OneBotAction::name) + + /** The action named [name], null when this version does not know it. */ + public fun action(name: String): OneBotAction? = byName[name] + + /** How [call] names an action, unknown when this version does not list the action it names. */ + public fun resolve(call: String): OneBotCall { + val suffix = OneBotCallSuffix.of(call) ?: OneBotCallSuffix.NONE + val name = OneBotCallSuffix.strip(call) + val action = byName[name] + return if (action != null) { + OneBotCall.Known(action, suffix) + } else { + OneBotCall.Unknown(call, name, suffix) + } + } + + /** Whether [call] names an action this version lists. */ + public fun knows(call: String): Boolean = resolve(call) is OneBotCall.Known +} + +/** How a call names one action. */ +public sealed interface OneBotCall { + /** The action the call names. */ + public val action: String + + /** The suffix the call carries. */ + public val suffix: OneBotCallSuffix + + /** A call of an action this version lists. */ + public data class Known(public val definition: OneBotAction, override val suffix: OneBotCallSuffix) : OneBotCall { + override val action: String get() = definition.name + + /** Whether the action exists in the shape the call asks for. */ + public val isSupported: Boolean get() = definition.accepts(suffix) + } + + /** + * A call of an action this version does not list. + * + * [name] is [action] without the suffix, so a transport sends [action] as the implementation wrote it and can + * still tell the variants apart. + */ + public data class Unknown( + override val action: String, + public val name: String, + override val suffix: OneBotCallSuffix, + ) : OneBotCall +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRequests.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRequests.kt new file mode 100644 index 00000000..7d79f413 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRequests.kt @@ -0,0 +1,443 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.api + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegmentCodec + +/** + * The parameters of one API call. + * + * A request writes only the fields it holds, because the standard gives several of them defaults that an + * implementation applies itself, and writing a default would take that choice away from it. + */ +public sealed interface OneBotRequest { + /** The parameters of the call. */ + public fun toJson(): JsonObject +} + +/** `send_private_msg`: sends a message to one user. */ +public class SendPrivateMessage( + public val userId: UserId, + public val message: OneBotMessage, + /** Whether the implementation sends the message as plain text, which only a string message allows. */ + public val autoEscape: Boolean = false, +) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("user_id", OneBotSegmentCodec.number(userId.value)) + put("message", OneBotSegmentCodec.encodeMessage(message)) + // Only a whole message as one piece of text can be taken as it is written, which is what an implementation + // reads `auto_escape` as. A message of segments is sent as segments, so that the CQ text one of them holds + // is not read as an escape it never asked for. + if (autoEscape && message is OneBotMessage.StringValue) put("auto_escape", true) + } +} + +/** `send_group_msg`: sends a message to one group. */ +public class SendGroupMessage( + public val groupId: GroupId, + public val message: OneBotMessage, + public val autoEscape: Boolean = false, +) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("message", OneBotSegmentCodec.encodeMessage(message)) + // Only a whole message as one piece of text can be taken as it is written, which is what an implementation + // reads `auto_escape` as. A message of segments is sent as segments, so that the CQ text one of them holds + // is not read as an escape it never asked for. + if (autoEscape && message is OneBotMessage.StringValue) put("auto_escape", true) + } +} + +/** `send_msg`: sends a message, whose chat the implementation takes from whichever id the request holds. */ +public class SendMessage( + public val message: OneBotMessage, + /** `private` or `group`, null to let the implementation decide from the ids. */ + public val messageType: String? = null, + public val userId: UserId? = null, + public val groupId: GroupId? = null, + public val autoEscape: Boolean = false, +) : OneBotRequest { + init { + require(userId != null || groupId != null) { "A message needs a user id or a group id." } + require(messageType == null || messageType == PRIVATE || messageType == GROUP) { + "A message type is '$PRIVATE' or '$GROUP', was '$messageType'." + } + } + + override fun toJson(): JsonObject = buildJsonObject { + messageType?.let { put("message_type", it) } + userId?.let { put("user_id", OneBotSegmentCodec.number(it.value)) } + groupId?.let { put("group_id", OneBotSegmentCodec.number(it.value)) } + put("message", OneBotSegmentCodec.encodeMessage(message)) + // Only a whole message as one piece of text can be taken as it is written, which is what an implementation + // reads `auto_escape` as. A message of segments is sent as segments, so that the CQ text one of them holds + // is not read as an escape it never asked for. + if (autoEscape && message is OneBotMessage.StringValue) put("auto_escape", true) + } + + public companion object { + public const val PRIVATE: String = "private" + public const val GROUP: String = "group" + } +} + +/** `delete_msg`: recalls a message. */ +public class DeleteMessage(public val messageId: MessageId) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("message_id", OneBotSegmentCodec.number(messageId.value)) + } +} + +/** `get_msg`: reads one message. */ +public class GetMessage(public val messageId: MessageId) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("message_id", OneBotSegmentCodec.number(messageId.value)) + } +} + +/** `get_forward_msg`: reads the nodes of a forwarded message. */ +public class GetForwardMessage(public val id: String) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { put("id", id) } +} + +/** `send_like`: gives a friend a thumbs up. */ +public class SendLike(public val userId: UserId, public val times: Int = 1) : OneBotRequest { + init { + require(times > 0) { "A like is sent at least once, was $times." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("user_id", OneBotSegmentCodec.number(userId.value)) + put("times", times) + } +} + +/** `set_group_kick`: removes a member from a group. */ +public class SetGroupKick( + public val groupId: GroupId, + public val userId: UserId, + /** Whether the implementation also refuses that member's pending request to join. */ + public val rejectAddRequest: Boolean = false, +) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) + if (rejectAddRequest) put("reject_add_request", true) + } +} + +/** `set_group_ban`: mutes one member of a group, or lifts their mute with a duration of 0. */ +public class SetGroupBan( + public val groupId: GroupId, + public val userId: UserId, + /** The mute lasts this many seconds, 0 lifts it. */ + public val duration: Long = DEFAULT_DURATION, +) : OneBotRequest { + init { + require(duration >= 0) { "A mute lasts at least no time, was $duration." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) + put("duration", duration) + } + + public companion object { + /** The mute the standard gives a call that names none: 30 minutes. */ + public const val DEFAULT_DURATION: Long = 30L * 60L + } +} + +/** `set_group_anonymous_ban`: mutes one anonymous member of a group. */ +public class SetGroupAnonymousBan( + public val groupId: GroupId, + /** The flag the message event reported, which identifies the anonymous member. */ + public val flag: String, + public val duration: Long = SetGroupBan.DEFAULT_DURATION, +) : OneBotRequest { + init { + require(flag.isNotBlank()) { "An anonymous ban needs the flag of the anonymous member." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("anonymous_flag", flag) + put("duration", duration) + } +} + +/** `set_group_whole_ban`: mutes everyone in a group, or lifts it. */ +public class SetGroupWholeBan(public val groupId: GroupId, public val enable: Boolean = true) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("enable", enable) + } +} + +/** `set_group_admin`: makes a member of a group an administrator, or takes the role away. */ +public class SetGroupAdmin(public val groupId: GroupId, public val userId: UserId, public val enable: Boolean = true) : + OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) + put("enable", enable) + } +} + +/** `set_group_anonymous`: allows or forbids anonymous messages in a group. */ +public class SetGroupAnonymous(public val groupId: GroupId, public val enable: Boolean = true) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("enable", enable) + } +} + +/** `set_group_card`: writes a member's card in a group, or removes it with an empty card. */ +public class SetGroupCard(public val groupId: GroupId, public val userId: UserId, public val card: String = "") : + OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) + put("card", card) + } +} + +/** `set_group_name`: renames a group. */ +public class SetGroupName(public val groupId: GroupId, public val groupName: String) : OneBotRequest { + init { + require(groupName.isNotBlank()) { "A group name may not be blank." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("group_name", groupName) + } +} + +/** `set_group_leave`: leaves a group, or dismisses it when the account owns it and [isDismiss] is set. */ +public class SetGroupLeave(public val groupId: GroupId, public val isDismiss: Boolean = false) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + if (isDismiss) put("is_dismiss", true) + } +} + +/** `set_group_special_title`: writes a member's special title, or removes it with an empty title. */ +public class SetGroupSpecialTitle( + public val groupId: GroupId, + public val userId: UserId, + public val specialTitle: String = "", + /** The title lasts this many seconds, -1 for good. */ + public val duration: Long = PERMANENT, +) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) + put("special_title", specialTitle) + put("duration", duration) + } + + public companion object { + public const val PERMANENT: Long = -1L + } +} + +/** `set_friend_add_request`: answers a friend request. */ +public class SetFriendAddRequest( + /** The flag the request event reported. */ + public val flag: String, + public val approve: Boolean = true, + /** The remark to save with a friend the call accepts. */ + public val remark: String = "", +) : OneBotRequest { + init { + require(flag.isNotBlank()) { "A request answer needs the flag of the request." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("flag", flag) + put("approve", approve) + if (approve && remark.isNotEmpty()) put("remark", remark) + } +} + +/** `set_group_add_request`: answers a request to join a group, or an invitation to one. */ +public class SetGroupAddRequest( + public val flag: String, + /** `add` or `invite`, which must match the subtype the request event reported. */ + public val subType: String, + public val approve: Boolean = true, + /** The reason to give a request the call refuses. */ + public val reason: String = "", +) : OneBotRequest { + init { + require(flag.isNotBlank()) { "A request answer needs the flag of the request." } + require(subType == ADD || subType == INVITE) { "A group request is '$ADD' or '$INVITE', was '$subType'." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("flag", flag) + put("sub_type", subType) + put("approve", approve) + if (!approve && reason.isNotEmpty()) put("reason", reason) + } + + public companion object { + public const val ADD: String = "add" + public const val INVITE: String = "invite" + } +} + +/** `get_stranger_info`: reads a user's information. */ +public class GetStrangerInfo(public val userId: UserId, public val noCache: Boolean = false) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("user_id", OneBotSegmentCodec.number(userId.value)) + if (noCache) put("no_cache", true) + } +} + +/** `get_group_info`: reads a group's information. */ +public class GetGroupInfo(public val groupId: GroupId, public val noCache: Boolean = false) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + if (noCache) put("no_cache", true) + } +} + +/** `get_group_member_info`: reads one member's information in a group. */ +public class GetGroupMemberInfo( + public val groupId: GroupId, + public val userId: UserId, + public val noCache: Boolean = false, +) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("user_id", OneBotSegmentCodec.number(userId.value)) + if (noCache) put("no_cache", true) + } +} + +/** `get_group_member_list`: reads the members of a group. */ +public class GetGroupMemberList(public val groupId: GroupId) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { put("group_id", OneBotSegmentCodec.number(groupId.value)) } +} + +/** `get_group_honor_info`: reads the honors of a group. */ +public class GetGroupHonorInfo(public val groupId: GroupId, public val type: String = ALL) : OneBotRequest { + init { + require(type.isNotEmpty()) { "A honor type may not be empty." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("group_id", OneBotSegmentCodec.number(groupId.value)) + put("type", type) + } + + public companion object { + public const val TALKATIVE: String = "talkative" + public const val PERFORMER: String = "performer" + public const val LEGEND: String = "legend" + public const val STRONG_NEWBIE: String = "strong_newbie" + public const val EMOTION: String = "emotion" + public const val ALL: String = "all" + } +} + +/** `get_cookies`: reads the cookies of a domain. */ +public class GetCookies(public val domain: String = "") : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + if (domain.isNotEmpty()) put("domain", domain) + } +} + +/** `get_credentials`: reads the cookies and the CSRF token of a domain. */ +public class GetCredentials(public val domain: String = "") : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + if (domain.isNotEmpty()) put("domain", domain) + } +} + +/** `get_record`: asks the implementation to convert a voice message. */ +public class GetRecord(public val file: String, public val outFormat: String) : OneBotRequest { + init { + require(file.isNotBlank()) { "A record needs the file the message segment named." } + require(outFormat.isNotBlank()) { "A record conversion needs its output format." } + } + + override fun toJson(): JsonObject = buildJsonObject { + put("file", file) + put("out_format", outFormat) + } +} + +/** `get_image`: asks the implementation to download an image. */ +public class GetImage(public val file: String) : OneBotRequest { + init { + require(file.isNotBlank()) { "An image needs the file the message segment named." } + } + + override fun toJson(): JsonObject = buildJsonObject { put("file", file) } +} + +/** `set_restart`: asks the implementation to restart itself. */ +public class SetRestart(public val delayMillis: Long = 0) : OneBotRequest { + init { + require(delayMillis >= 0) { "A restart waits at least no time, was $delayMillis." } + } + + override fun toJson(): JsonObject = buildJsonObject { + if (delayMillis > 0) put("delay", delayMillis) + } +} + +/** `.handle_quick_operation`: runs a quick operation as if an event's report had asked for it. */ +public class HandleQuickOperation( + /** The event object, which may name only the fields the operation reads. */ + public val context: JsonObject, + /** The operation, such as `{"ban": true, "reply": "please stop"}`. */ + public val operation: JsonObject, +) : OneBotRequest { + override fun toJson(): JsonObject = buildJsonObject { + put("context", context) + put("operation", operation) + } +} + +/** The calls that take no parameters at all. */ +public enum class OneBotNoParameters : OneBotRequest { + /** `get_login_info`. */ + GET_LOGIN_INFO, + + /** `get_friend_list`. */ + GET_FRIEND_LIST, + + /** `get_group_list`. */ + GET_GROUP_LIST, + + /** `get_csrf_token`. */ + GET_CSRF_TOKEN, + + /** `can_send_image`. */ + CAN_SEND_IMAGE, + + /** `can_send_record`. */ + CAN_SEND_RECORD, + + /** `get_status`. */ + GET_STATUS, + + /** `get_version_info`. */ + GET_VERSION_INFO, + + /** `clean_cache`. */ + CLEAN_CACHE, + ; + + override fun toJson(): JsonObject = JsonObject(emptyMap()) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEvent.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEvent.kt new file mode 100644 index 00000000..b2c1d2ca --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEvent.kt @@ -0,0 +1,490 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.event + +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.SelfId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotMessage + +/** + * One event an OneBot implementation reported. + * + * Every event carries the fields the standard gives every event, and [raw] keeps the whole JSON object, so a + * consumer reads a field of an implementation's own extension without this module knowing it. + */ +public sealed interface OneBotEvent { + /** When the event happened, in seconds since the epoch. */ + public val time: Long + + /** The QQ number the implementation reported the event for. */ + public val selfId: SelfId + + /** The `post_type` of the event. */ + public val postType: String + + /** The whole event object as the implementation sent it. */ + public val raw: JsonObject + + /** The quick operation fields this event accepts. */ + public val quickOperations: Set + + /** A message event, private or in a group. */ + public sealed interface Message : OneBotEvent { + /** The message. */ + public val message: OneBotMessage + + /** The message as the implementation spelled it, with its CQ codes. */ + public val rawMessage: String + + public val messageId: MessageId + + public val userId: UserId + + /** The sender, as far as the implementation could tell. */ + public val sender: Sender + + /** How the message arrived. */ + public val subType: String + + /** A private message. */ + public data class Private( + override val time: Long, + override val selfId: SelfId, + override val message: OneBotMessage, + override val rawMessage: String, + override val messageId: MessageId, + override val userId: UserId, + override val sender: Sender, + override val subType: String, + override val raw: JsonObject, + ) : Message { + override val postType: String get() = POST_TYPE + override val quickOperations: Set get() = REPLY + } + + /** A group message. */ + public data class Group( + override val time: Long, + override val selfId: SelfId, + override val message: OneBotMessage, + override val rawMessage: String, + override val messageId: MessageId, + override val userId: UserId, + override val sender: Sender, + override val subType: String, + public val groupId: GroupId, + /** Who sent the message without a name, null when it carried a name. */ + public val anonymous: Anonymous?, + override val raw: JsonObject, + ) : Message { + override val postType: String get() = POST_TYPE + override val quickOperations: Set get() = GROUP_OPERATIONS + + /** Whether the message was sent without a name. */ + public val isAnonymous: Boolean get() = anonymous != null + } + + public companion object { + public const val POST_TYPE: String = "message" + + /** The subtypes a private message reports. */ + public const val FRIEND: String = "friend" + public const val GROUP_TEMPORARY: String = "group" + public const val OTHER: String = "other" + + /** The subtypes a group message reports. */ + public const val NORMAL: String = "normal" + public const val ANONYMOUS: String = "anonymous" + public const val NOTICE: String = "notice" + + private val REPLY: Set = setOf("reply", "auto_escape") + + private val GROUP_OPERATIONS: Set = + REPLY + setOf("at_sender", "delete", "kick", "ban", "ban_duration") + } + } + + /** A notification, such as a membership change or a recall. */ + public sealed interface Notice : OneBotEvent { + /** A notification type. */ + public val noticeType: String + + /** A file uploaded to a group. */ + public data class GroupUpload( + override val time: Long, + override val selfId: SelfId, + public val groupId: GroupId, + public val userId: UserId, + public val file: File, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = GROUP_UPLOAD + override val quickOperations: Set get() = emptySet() + } + + /** An administrator set or unset. */ + public data class GroupAdmin( + override val time: Long, + override val selfId: SelfId, + public val groupId: GroupId, + public val userId: UserId, + /** Whether the member became an administrator. */ + public val isSet: Boolean, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = GROUP_ADMIN + override val quickOperations: Set get() = emptySet() + } + + /** A member left a group. */ + public data class GroupDecrease( + override val time: Long, + override val selfId: SelfId, + public val groupId: GroupId, + /** Who left. */ + public val userId: UserId, + /** Who made them leave, the same as [userId] when they left on their own. */ + public val operatorId: UserId, + val subType: String? = null, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = GROUP_DECREASE + override val quickOperations: Set get() = emptySet() + } + + /** A member joined a group. */ + public data class GroupIncrease( + override val time: Long, + override val selfId: SelfId, + public val groupId: GroupId, + public val userId: UserId, + public val operatorId: UserId, + val subType: String? = null, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = GROUP_INCREASE + override val quickOperations: Set get() = emptySet() + } + + /** A member was muted or unmuted. */ + public data class GroupBan( + override val time: Long, + override val selfId: SelfId, + public val groupId: GroupId, + public val userId: UserId, + public val operatorId: UserId, + /** Whether the member was muted. */ + public val isBan: Boolean, + /** How long the mute lasts, in seconds. */ + public val duration: Long?, + val subType: String? = null, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = GROUP_BAN + override val quickOperations: Set get() = emptySet() + } + + /** The bot got a new friend. */ + public data class FriendAdd( + override val time: Long, + override val selfId: SelfId, + public val userId: UserId, + val subType: String? = null, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = FRIEND_ADD + override val quickOperations: Set get() = emptySet() + } + + /** A group message was recalled. */ + public data class GroupRecall( + override val time: Long, + override val selfId: SelfId, + public val groupId: GroupId, + public val userId: UserId, + public val operatorId: UserId, + public val messageId: MessageId, + val subType: String? = null, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = GROUP_RECALL + override val quickOperations: Set get() = emptySet() + } + + /** A private message was recalled. */ + public data class FriendRecall( + override val time: Long, + override val selfId: SelfId, + public val userId: UserId, + public val messageId: MessageId, + val subType: String? = null, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = FRIEND_RECALL + override val quickOperations: Set get() = emptySet() + } + + /** A poke, a lucky king or a member honor, which all report the `notify` notice type. */ + public data class Notify( + override val time: Long, + override val selfId: SelfId, + public val groupId: GroupId?, + public val userId: UserId, + /** The member a poke or a lucky king names, null for a honor. */ + public val targetId: UserId?, + val subType: String? = null, + /** The honor kind, null unless the subtype is `honor`. */ + public val honorType: String?, + override val raw: JsonObject, + ) : Notice { + override val postType: String get() = POST_TYPE + override val noticeType: String get() = NOTIFY + override val quickOperations: Set get() = emptySet() + } + + /** A notification of a type this version does not model. */ + public data class Other( + override val time: Long, + override val selfId: SelfId, + override val noticeType: String, + override val raw: JsonObject, + val subType: String? = null, + ) : Notice { + override val postType: String get() = POST_TYPE + override val quickOperations: Set get() = emptySet() + } + + /** The file a [GroupUpload] reported. */ + public data class File( + public val id: String, + public val name: String, + /** The size in bytes. */ + public val size: Long?, + public val busid: Long?, + ) + + public companion object { + public const val POST_TYPE: String = "notice" + + public const val GROUP_UPLOAD: String = "group_upload" + public const val GROUP_ADMIN: String = "group_admin" + public const val GROUP_DECREASE: String = "group_decrease" + public const val GROUP_INCREASE: String = "group_increase" + public const val GROUP_BAN: String = "group_ban" + public const val FRIEND_ADD: String = "friend_add" + public const val GROUP_RECALL: String = "group_recall" + public const val FRIEND_RECALL: String = "friend_recall" + public const val NOTIFY: String = "notify" + + /** The subtypes a [Notify] reports. */ + public const val POKE: String = "poke" + public const val LUCKY_KING: String = "lucky_king" + public const val HONOR: String = "honor" + + /** The subtypes a [GroupDecrease] reports. */ + public const val LEAVE: String = "leave" + public const val KICK: String = "kick" + public const val KICK_ME: String = "kick_me" + + /** The subtypes a [GroupIncrease] reports. */ + public const val APPROVE: String = "approve" + public const val INVITE: String = "invite" + + /** The subtypes a [GroupBan] reports. */ + public const val BAN: String = "ban" + public const val LIFT_BAN: String = "lift_ban" + + /** The subtypes a [GroupAdmin] reports. */ + public const val SET: String = "set" + public const val UNSET: String = "unset" + } + } + + /** A request the bot may answer. */ + public sealed interface Request : OneBotEvent { + /** The request type. */ + public val requestType: String + + /** The flag the API answers the request with. */ + public val flag: String + + /** The verification message. */ + public val comment: String + + public val userId: UserId + + /** A friend request. */ + public data class Friend( + override val time: Long, + override val selfId: SelfId, + override val flag: String, + override val comment: String, + override val userId: UserId, + override val raw: JsonObject, + ) : Request { + override val postType: String get() = POST_TYPE + override val requestType: String get() = FRIEND + override val quickOperations: Set get() = OPERATIONS + } + + /** A request to join a group, or an invitation to one. */ + public data class Group( + override val time: Long, + override val selfId: SelfId, + override val flag: String, + override val comment: String, + override val userId: UserId, + public val groupId: GroupId, + val subType: String? = null, + override val raw: JsonObject, + ) : Request { + override val postType: String get() = POST_TYPE + override val requestType: String get() = GROUP + override val quickOperations: Set get() = OPERATIONS + } + + /** A request of a type this version does not model. */ + public data class Other( + override val time: Long, + override val selfId: SelfId, + override val requestType: String, + override val flag: String, + override val comment: String, + override val userId: UserId, + override val raw: JsonObject, + val subType: String? = null, + ) : Request { + override val postType: String get() = POST_TYPE + override val quickOperations: Set get() = emptySet() + } + + public companion object { + public const val POST_TYPE: String = "request" + + public const val FRIEND: String = "friend" + public const val GROUP: String = "group" + + /** The subtypes a group request reports. */ + public const val ADD: String = "add" + public const val INVITE: String = "invite" + + private val OPERATIONS: Set = setOf("approve", "remark", "reason") + } + } + + /** An event about the OneBot implementation itself. */ + public sealed interface Meta : OneBotEvent { + /** The meta event type. */ + public val metaEventType: String + + /** The implementation started, stopped or a WebSocket connected. */ + public data class Lifecycle( + override val time: Long, + override val selfId: SelfId, + /** `enable`, `disable` or `connect`. */ + val subType: String? = null, + override val raw: JsonObject, + ) : Meta { + override val postType: String get() = POST_TYPE + override val metaEventType: String get() = LIFECYCLE + override val quickOperations: Set get() = emptySet() + } + + /** The implementation reports how it is doing. */ + public data class Heartbeat( + override val time: Long, + override val selfId: SelfId, + /** The status object, as `get_status` returns it. */ + public val status: JsonObject, + /** The milliseconds until the next heartbeat. */ + public val interval: Long?, + override val raw: JsonObject, + ) : Meta { + override val postType: String get() = POST_TYPE + override val metaEventType: String get() = HEARTBEAT + override val quickOperations: Set get() = emptySet() + } + + /** A meta event of a type this version does not model. */ + public data class Other( + override val time: Long, + override val selfId: SelfId, + override val metaEventType: String, + override val raw: JsonObject, + val subType: String? = null, + ) : Meta { + override val postType: String get() = POST_TYPE + override val quickOperations: Set get() = emptySet() + } + + public companion object { + public const val POST_TYPE: String = "meta_event" + + public const val LIFECYCLE: String = "lifecycle" + public const val HEARTBEAT: String = "heartbeat" + + /** The subtypes a lifecycle event reports. */ + public const val ENABLE: String = "enable" + public const val DISABLE: String = "disable" + public const val CONNECT: String = "connect" + } + } + + /** An event of a kind this version does not model, with its JSON untouched. */ + public data class Unknown( + override val time: Long, + override val selfId: SelfId, + override val postType: String, + override val raw: JsonObject, + ) : OneBotEvent { + override val quickOperations: Set get() = emptySet() + } + + /** The sender of a message, as far as an implementation could tell. */ + public data class Sender( + public val userId: String?, + public val nickname: String?, + /** `male`, `female` or `unknown`. */ + public val sex: String?, + public val age: Long?, + /** The member's card in the group, null outside a group. */ + public val card: String?, + /** The member's area, null when the implementation did not report one. */ + public val area: String?, + /** The member's level, null when the implementation did not report one. */ + public val level: String?, + /** `owner`, `admin` or `member`, null outside a group. */ + public val role: String?, + /** The member's special title, null when they hold none. */ + public val title: String?, + ) { + /** Whether the sender owns the group. */ + public val isOwner: Boolean get() = role == ROLE_OWNER + + /** Whether the sender administers the group. */ + public val isAdmin: Boolean get() = role == ROLE_ADMIN || isOwner + + public companion object { + public const val ROLE_OWNER: String = "owner" + public const val ROLE_ADMIN: String = "admin" + public const val ROLE_MEMBER: String = "member" + } + } + + /** Who sent a group message without a name. */ + public data class Anonymous( + public val id: String?, + public val name: String?, + /** The flag the ban API takes. */ + public val flag: String?, + ) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodec.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodec.kt new file mode 100644 index 00000000..130545ca --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodec.kt @@ -0,0 +1,270 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.event + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.SelfId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegmentCodec + +/** + * Reads an event object. + * + * The kind of an event is read from `post_type` and then its own type field, the same way the standard separates + * them. An event whose kind this version does not model becomes [OneBotEvent.Unknown] with its JSON untouched, and + * so does an event whose kind it models but whose own type it does not. + */ +public object OneBotEventCodec { + /** The event of [element], which throws when it is no object, an event of the standard being one. */ + public fun decode(element: JsonElement): OneBotEvent { + // A scalar or an array is no event: making one of it would tell a caller that a report naming nothing is a + // report worth reading, and would lose what actually arrived. + val raw = element as? JsonObject + ?: throw IllegalArgumentException("An event is a JSON object, was ${element::class.simpleName}.") + val time = raw.number("time") ?: 0L + val selfId = SelfId(raw.numberText("self_id") ?: "0") + return when (val postType = raw.string("post_type")) { + OneBotEvent.Message.POST_TYPE -> decodeMessage(raw, time, selfId) + OneBotEvent.Notice.POST_TYPE -> decodeNotice(raw, time, selfId) + OneBotEvent.Request.POST_TYPE -> decodeRequest(raw, time, selfId) + OneBotEvent.Meta.POST_TYPE -> decodeMeta(raw, time, selfId) + else -> OneBotEvent.Unknown(time, selfId, postType.orEmpty(), raw) + } + } + + private fun decodeMessage(raw: JsonObject, time: Long, selfId: SelfId): OneBotEvent = when ( + val messageType = raw.string("message_type") + ) { + "private" -> OneBotEvent.Message.Private( + time = time, + selfId = selfId, + message = message(raw), + rawMessage = raw.string("raw_message").orEmpty(), + messageId = MessageId(raw.numberText("message_id") ?: "0"), + userId = UserId(raw.numberText("user_id") ?: "0"), + sender = sender(raw), + subType = raw.string("sub_type").orEmpty(), + raw = raw, + ) + + "group" -> OneBotEvent.Message.Group( + time = time, + selfId = selfId, + message = message(raw), + rawMessage = raw.string("raw_message").orEmpty(), + messageId = MessageId(raw.numberText("message_id") ?: "0"), + userId = UserId(raw.numberText("user_id") ?: "0"), + sender = sender(raw), + subType = raw.string("sub_type").orEmpty(), + groupId = GroupId(raw.numberText("group_id") ?: "0"), + anonymous = anonymous(raw), + raw = raw, + ) + + else -> OneBotEvent.Unknown(time, selfId, OneBotEvent.Message.POST_TYPE, raw) + } + + private fun decodeNotice(raw: JsonObject, time: Long, selfId: SelfId): OneBotEvent { + val noticeType = raw.string("notice_type").orEmpty() + val subType = raw.string("sub_type") + val groupId = { raw.numberText("group_id")?.let(::GroupId) } + val userId = { UserId(raw.numberText("user_id") ?: "0") } + val operatorId = { UserId(raw.numberText("operator_id") ?: "0") } + return when (noticeType) { + OneBotEvent.Notice.GROUP_UPLOAD -> OneBotEvent.Notice.GroupUpload( + time, + selfId, + groupId() ?: GroupId("0"), + userId(), + file(raw["file"] as? JsonObject ?: JsonObject(emptyMap())), + raw, + ) + + OneBotEvent.Notice.GROUP_ADMIN -> OneBotEvent.Notice.GroupAdmin( + time, + selfId, + groupId() ?: GroupId("0"), + userId(), + subType == OneBotEvent.Notice.SET, + raw, + ) + + OneBotEvent.Notice.GROUP_DECREASE -> OneBotEvent.Notice.GroupDecrease( + time, + selfId, + groupId() ?: GroupId("0"), + userId(), + operatorId(), + subType.orEmpty(), + raw, + ) + + OneBotEvent.Notice.GROUP_INCREASE -> OneBotEvent.Notice.GroupIncrease( + time, + selfId, + groupId() ?: GroupId("0"), + userId(), + operatorId(), + subType.orEmpty(), + raw, + ) + + OneBotEvent.Notice.GROUP_BAN -> OneBotEvent.Notice.GroupBan( + time = time, + selfId = selfId, + groupId = groupId() ?: GroupId("0"), + userId = userId(), + operatorId = operatorId(), + isBan = subType == OneBotEvent.Notice.BAN, + duration = raw.number("duration"), + subType = subType, + raw = raw, + ) + + OneBotEvent.Notice.FRIEND_ADD -> + OneBotEvent.Notice.FriendAdd(time, selfId, userId(), raw = raw) + + OneBotEvent.Notice.GROUP_RECALL -> OneBotEvent.Notice.GroupRecall( + time = time, + selfId = selfId, + groupId = groupId() ?: GroupId("0"), + userId = userId(), + operatorId = operatorId(), + messageId = MessageId(raw.numberText("message_id") ?: "0"), + raw = raw, + ) + + OneBotEvent.Notice.FRIEND_RECALL -> OneBotEvent.Notice.FriendRecall( + time = time, + selfId = selfId, + userId = userId(), + messageId = MessageId(raw.numberText("message_id") ?: "0"), + raw = raw, + ) + + OneBotEvent.Notice.NOTIFY -> OneBotEvent.Notice.Notify( + time = time, + selfId = selfId, + groupId = groupId(), + userId = userId(), + targetId = raw.numberText("target_id")?.let(::UserId), + subType = subType.orEmpty(), + honorType = raw.string("honor_type"), + raw = raw, + ) + + else -> OneBotEvent.Notice.Other( + time = time, + selfId = selfId, + noticeType = noticeType, + raw = raw, + subType = subType, + ) + } + } + + private fun decodeRequest(raw: JsonObject, time: Long, selfId: SelfId): OneBotEvent { + val requestType = raw.string("request_type").orEmpty() + val flag = raw.string("flag").orEmpty() + val comment = raw.string("comment").orEmpty() + val userId = UserId(raw.numberText("user_id") ?: "0") + return when (requestType) { + OneBotEvent.Request.FRIEND -> + OneBotEvent.Request.Friend(time, selfId, flag, comment, userId, raw) + + OneBotEvent.Request.GROUP -> OneBotEvent.Request.Group( + time, + selfId, + flag, + comment, + userId, + GroupId(raw.numberText("group_id") ?: "0"), + raw.string("sub_type").orEmpty(), + raw, + ) + + else -> OneBotEvent.Request.Other( + time = time, + selfId = selfId, + requestType = requestType, + flag = flag, + comment = comment, + userId = userId, + raw = raw, + subType = raw.string("sub_type"), + ) + } + } + + private fun decodeMeta(raw: JsonObject, time: Long, selfId: SelfId): OneBotEvent { + val metaEventType = raw.string("meta_event_type").orEmpty() + val subType = raw.string("sub_type") + return when (metaEventType) { + OneBotEvent.Meta.LIFECYCLE -> + OneBotEvent.Meta.Lifecycle(time, selfId, subType.orEmpty(), raw) + + OneBotEvent.Meta.HEARTBEAT -> OneBotEvent.Meta.Heartbeat( + time, + selfId, + raw["status"] as? JsonObject ?: JsonObject(emptyMap()), + raw.number("interval"), + raw, + ) + + else -> OneBotEvent.Meta.Other( + time = time, + selfId = selfId, + metaEventType = metaEventType, + raw = raw, + subType = subType, + ) + } + } + + private fun message(raw: JsonObject): OneBotMessage = OneBotSegmentCodec.decodeMessage(raw["message"] ?: JsonNull) + + private fun sender(raw: JsonObject): OneBotEvent.Sender { + val sender = raw["sender"] as? JsonObject ?: JsonObject(emptyMap()) + return OneBotEvent.Sender( + userId = sender.numberText("user_id"), + nickname = sender.string("nickname"), + sex = sender.string("sex"), + age = sender.number("age"), + card = sender.string("card"), + area = sender.string("area"), + level = sender.string("level"), + role = sender.string("role"), + title = sender.string("title"), + ) + } + + private fun anonymous(raw: JsonObject): OneBotEvent.Anonymous? { + val anonymous = raw["anonymous"] as? JsonObject ?: return null + return OneBotEvent.Anonymous( + id = anonymous.numberText("id"), + name = anonymous.string("name"), + flag = anonymous.string("flag"), + ) + } + + private fun file(raw: JsonObject): OneBotEvent.Notice.File = OneBotEvent.Notice.File( + id = raw.string("id").orEmpty(), + name = raw.string("name").orEmpty(), + size = raw.number("size"), + busid = raw.number("busid"), + ) + + private fun JsonObject.string(key: String): String? = + (this[key] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull + + private fun JsonObject.number(key: String): Long? = (this[key] as? JsonPrimitive)?.longOrNull + + private fun JsonObject.numberText(key: String): String? = + (this[key] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull?.takeIf { it.isNotEmpty() } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCode.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCode.kt new file mode 100644 index 00000000..cc4714f0 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCode.kt @@ -0,0 +1,363 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.message + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId + +/** + * The CQ code string format of a message, `[CQ:type,key=value]` with plain text between the codes. + * + * The characters that end a code are escaped, so a message always round-trips: `&` as `&`, `[` as `[`, + * `]` as `]`, `,` as `,`. Reading accepts these entities and leaves every other character as it is. + */ +public object CqCode { + private const val PREFIX = "[CQ:" + private const val SUFFIX = ']' + + /** The entity a parameter uses for a comma, which is the one entity plain text leaves as it is. */ + private const val COMMA_ENTITY = "," + private const val SEPARATOR = ',' + private const val ASSIGNMENT = '=' + + /** + * The segments of [text], with the text between the codes as [OneBotSegment.Text]. + * + * The entities of the format stand for the characters that would otherwise end a code, so reading turns them + * back into those characters everywhere, inside a code and in the text between the codes alike. + */ + public fun decode(text: String): List { + if (PREFIX !in text) return if (text.isEmpty()) emptyList() else listOf(OneBotSegment.Text(unescape(text))) + val segments = mutableListOf() + val plain = StringBuilder() + var at = 0 + while (at < text.length) { + val start = text.indexOf(PREFIX, at) + if (start < 0) { + plain.append(text, at, text.length) + break + } + plain.append(text, at, start) + val end = text.indexOf(SUFFIX, start + PREFIX.length) + if (end < 0) { + plain.append(text, start, text.length) + break + } + val segment = parse(text.substring(start + PREFIX.length, end)) + if (segment == null) { + plain.append(text, start, end + 1) + } else { + if (plain.isNotEmpty()) { + segments += OneBotSegment.Text(unescape(plain.toString())) + plain.setLength(0) + } + segments += segment + } + at = end + 1 + } + if (plain.isNotEmpty()) segments += OneBotSegment.Text(unescape(plain.toString())) + return segments + } + + /** The string that spells [segments] as CQ codes. */ + public fun encode(segments: List): String = buildString { + for (segment in segments) append(encode(segment)) + } + + /** The string that spells [segment] as a CQ code, or as plain text for a [OneBotSegment.Text]. */ + public fun encode(segment: OneBotSegment): String = when (segment) { + is OneBotSegment.Text -> escape(segment.text) + + is OneBotSegment.Unknown -> encodeUnknown(segment) + + is OneBotSegment.Rps -> "[CQ:rps]" + + is OneBotSegment.Dice -> "[CQ:dice]" + + is OneBotSegment.Shake -> "[CQ:shake]" + + is OneBotSegment.At -> parameters("at", "qq" to segment.userId) + + is OneBotSegment.Face -> parameters("face", "id" to segment.id) + + is OneBotSegment.Image -> parameters( + "image", + "file" to segment.file, + "type" to segment.subtype, + "url" to segment.url, + "cache" to segment.cache?.let(::flag), + "proxy" to segment.proxy?.let(::flag), + "timeout" to segment.timeout?.toString(), + ) + + is OneBotSegment.Record -> parameters( + "record", + "file" to segment.file, + "magic" to segment.magic?.let(::flag), + "url" to segment.url, + "cache" to segment.cache?.let(::flag), + "proxy" to segment.proxy?.let(::flag), + "timeout" to segment.timeout?.toString(), + ) + + is OneBotSegment.File -> parameters( + "file", + "file" to segment.file, + "name" to segment.name, + "url" to segment.url, + ) + + is OneBotSegment.Video -> parameters( + "video", + "file" to segment.file, + "url" to segment.url, + "cache" to segment.cache?.let(::flag), + "proxy" to segment.proxy?.let(::flag), + "timeout" to segment.timeout?.toString(), + ) + + is OneBotSegment.Poke -> parameters( + "poke", + "type" to segment.pokeType, + "id" to segment.id, + "name" to segment.name, + ) + + is OneBotSegment.Anonymous -> parameters("anonymous", "ignore" to segment.ignore?.let(::flag)) + + is OneBotSegment.Share -> parameters( + "share", + "url" to segment.url, + "title" to segment.title, + "content" to segment.content, + "image" to segment.image, + ) + + is OneBotSegment.Contact -> + parameters("contact", "type" to segment.contactType, "id" to segment.id) + + is OneBotSegment.Location -> parameters( + "location", + "lat" to segment.lat, + "lon" to segment.lon, + "title" to segment.title, + "content" to segment.content, + ) + + is OneBotSegment.Music -> parameters( + "music", + "type" to segment.musicType, + "id" to segment.id, + "url" to segment.url, + "audio" to segment.audio, + "title" to segment.title, + "content" to segment.content, + "image" to segment.image, + ) + + is OneBotSegment.Reply -> parameters("reply", "id" to segment.id.value) + + is OneBotSegment.Forward -> parameters("forward", "id" to segment.id) + + is OneBotSegment.Node -> parameters( + "node", + "id" to segment.id?.value, + "user_id" to segment.userId?.value, + "nickname" to segment.nickname, + "content" to segment.content?.let { encode(it.segments) }, + ) + + is OneBotSegment.Xml -> parameters("xml", "data" to segment.data) + + is OneBotSegment.Json -> parameters("json", "data" to segment.data) + } + + /** + * The value of [text] with the characters that end a code escaped. + * + * A comma separates the parameters of a code and means nothing in the text outside one, so it is escaped only by + * [escapeValue]: escaping it in ordinary text would turn every comma a person types into an entity the standard + * does not ask for. + */ + public fun escape(text: String): String = escape(text, comma = false) + + /** The value of a parameter, where a comma also ends it and is therefore escaped. */ + public fun escapeValue(value: String): String = escape(value, comma = true) + + private fun escape(text: String, comma: Boolean): String = buildString(text.length) { + for (char in text) { + when { + char == '&' -> append("&") + char == '[' -> append("[") + char == ']' -> append("]") + char == ',' && comma -> append(",") + else -> append(char) + } + } + } + + /** The value of [text] with the entities the format uses turned back into their characters. */ + public fun unescape(text: String): String = unescape(text, comma = false) + + /** The value of a parameter, where `,` names the comma that would otherwise end it. */ + public fun unescapeValue(value: String): String = unescape(value, comma = true) + + private fun unescape(text: String, comma: Boolean): String = buildString(text.length) { + var at = 0 + while (at < text.length) { + val char = text[at] + if (char != '&') { + append(char) + at++ + continue + } + val entity = ENTITIES.firstOrNull { text.startsWith(it.first, at) && (comma || it.first != COMMA_ENTITY) } + if (entity == null) { + append(char) + at++ + } else { + append(entity.second) + at += entity.first.length + } + } + } + + private val ENTITIES: List> = + listOf("&" to '&', "[" to '[', "]" to ']', "," to ',') + .sortedByDescending { it.first.length } + + private fun flag(value: Boolean): String = if (value) "1" else "0" + + private fun parameters(type: String, vararg pairs: Pair): String = buildString { + append(PREFIX).append(type) + for ((key, value) in pairs) { + if (value == null) continue + append(SEPARATOR).append(key).append(ASSIGNMENT).append(escapeValue(value)) + } + append(SUFFIX) + } + + private fun encodeUnknown(segment: OneBotSegment.Unknown): String { + val pairs = segment.data.mapNotNull { (key, value) -> + (value as? JsonPrimitive)?.contentOrNull?.let { key to it } + } + return parameters(segment.type, *pairs.toTypedArray()) + } + + /** The segment of the text between `[CQ:` and `]`, null when it names no segment type. */ + private fun parse(body: String): OneBotSegment? { + if (body.isEmpty()) return null + val parts = body.split(SEPARATOR) + val type = parts.first().trim() + if (type.isEmpty()) return null + val parameters = LinkedHashMap() + for (part in parts.drop(1)) { + val assignment = part.indexOf(ASSIGNMENT) + if (assignment <= 0) continue + parameters[part.substring(0, assignment).trim()] = unescapeValue(part.substring(assignment + 1)) + } + return segment(type, parameters) + } + + private fun segment(type: String, parameters: Map): OneBotSegment { + val yes = { key: String -> parameters[key]?.let { it == "1" || it == "yes" || it == "true" } } + val int = { key: String -> parameters[key]?.toIntOrNull() } + return when (type) { + OneBotSegment.TEXT -> OneBotSegment.Text(parameters["text"].orEmpty()) + + OneBotSegment.FACE -> OneBotSegment.Face(parameters["id"].orEmpty()) + + OneBotSegment.IMAGE -> OneBotSegment.Image( + file = parameters["file"], + subtype = parameters["type"], + url = parameters["url"], + cache = yes("cache"), + proxy = yes("proxy"), + timeout = int("timeout"), + ) + + OneBotSegment.RECORD -> OneBotSegment.Record( + file = parameters["file"], + magic = yes("magic"), + url = parameters["url"], + cache = yes("cache"), + proxy = yes("proxy"), + timeout = int("timeout"), + ) + + OneBotSegment.VIDEO -> OneBotSegment.Video( + file = parameters["file"], + url = parameters["url"], + cache = yes("cache"), + proxy = yes("proxy"), + timeout = int("timeout"), + ) + + OneBotSegment.AT -> OneBotSegment.At(parameters["qq"].orEmpty()) + + OneBotSegment.RPS -> OneBotSegment.Rps + + OneBotSegment.DICE -> OneBotSegment.Dice + + OneBotSegment.SHAKE -> OneBotSegment.Shake + + OneBotSegment.POKE -> + OneBotSegment.Poke(parameters["type"], parameters["id"], parameters["name"]) + + OneBotSegment.ANONYMOUS -> OneBotSegment.Anonymous(yes("ignore")) + + OneBotSegment.SHARE -> OneBotSegment.Share( + url = parameters["url"].orEmpty(), + title = parameters["title"].orEmpty(), + content = parameters["content"], + image = parameters["image"], + ) + + OneBotSegment.CONTACT -> + OneBotSegment.Contact(parameters["type"].orEmpty(), parameters["id"].orEmpty()) + + OneBotSegment.LOCATION -> OneBotSegment.Location( + lat = parameters["lat"].orEmpty(), + lon = parameters["lon"].orEmpty(), + title = parameters["title"], + content = parameters["content"], + ) + + OneBotSegment.MUSIC -> OneBotSegment.Music( + musicType = parameters["type"].orEmpty(), + id = parameters["id"], + url = parameters["url"], + audio = parameters["audio"], + title = parameters["title"], + content = parameters["content"], + image = parameters["image"], + ) + + OneBotSegment.REPLY -> { + val id = parameters["id"] + OneBotSegment.Reply(MessageId(if (id != null && isNumber(id)) id else "0")) + } + + OneBotSegment.FORWARD -> OneBotSegment.Forward(parameters["id"].orEmpty()) + + OneBotSegment.NODE -> OneBotSegment.Node( + id = parameters["id"]?.takeIf(::isNumber)?.let { MessageId(it) }, + userId = parameters["user_id"]?.takeIf(::isNumber)?.let { UserId(it) }, + nickname = parameters["nickname"], + content = parameters["content"]?.let { OneBotMessage.StringValue(it) }, + ) + + OneBotSegment.XML -> OneBotSegment.Xml(parameters["data"].orEmpty()) + + OneBotSegment.JSON -> OneBotSegment.Json(parameters["data"].orEmpty()) + + else -> OneBotSegment.Unknown(type, unknownData(parameters)) + } + } + + private fun unknownData(parameters: Map): JsonObject = + JsonObject(parameters.mapValues { (_, value) -> JsonPrimitive(value) }) + + private fun isNumber(text: String): Boolean = text.isNotEmpty() && text.all(Char::isDigit) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotMessage.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotMessage.kt new file mode 100644 index 00000000..8ff3abce --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotMessage.kt @@ -0,0 +1,49 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.message + +/** + * A message as the API takes it and as events report it. + * + * OneBot accepts the same message as a string, as an array of segments, or as a single segment object, so all three + * shapes decode into this type and the codec writes the one a request needs. + */ +public sealed interface OneBotMessage { + /** The segments of the message, one text segment for [StringValue]. */ + public val segments: List + + /** A message written as a string, in the message's own [format] such as a CQ code string. */ + public data class StringValue( + public val text: String, + /** How [text] spells its segments, by default the string format with CQ codes. */ + public val format: StringFormat = StringFormat.STRING, + ) : OneBotMessage { + override val segments: List get() = format.parse(text) + } + + /** A message written as an array of segments. */ + public data class ArrayValue(override val segments: List) : OneBotMessage + + /** A message written as one segment object, the shape the API also accepts. */ + public data class SingleSegment(public val segment: OneBotSegment) : OneBotMessage { + override val segments: List get() = listOf(segment) + } + + public companion object { + /** A message of one plain text segment. */ + public fun text(text: String): OneBotMessage = ArrayValue(listOf(OneBotSegment.Text(text))) + + /** A message of [segments]. */ + public fun of(vararg segments: OneBotSegment): OneBotMessage = ArrayValue(segments.toList()) + } +} + +/** How a string message spells its segments. */ +public enum class StringFormat { + /** The string format, where segments are written as CQ codes. */ + STRING { + override fun parse(text: String): List = CqCode.decode(text) + }, + ; + + /** The segments of [text]. */ + public abstract fun parse(text: String): List +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegment.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegment.kt new file mode 100644 index 00000000..ba6d4eae --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegment.kt @@ -0,0 +1,261 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.message + +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId + +/** + * One segment of an OneBot message. + * + * A segment names what it is in `type` and carries its own fields in `data`. A type this version does not know is + * kept as [Unknown] with its `data` untouched, so a message never loses content while it crosses the plugin. + * + * [OneBotSegmentCodec] encodes and decodes the shapes the standard describes, so a `type` field that is also a + * parameter of a segment, as in an image, never collides with the discriminator. + */ +public sealed interface OneBotSegment { + /** The `type` of the segment. */ + public val type: String + + /** Plain text. */ + public data class Text(public val text: String) : OneBotSegment { + override val type: String get() = OneBotSegment.TEXT + } + + /** A QQ emoji. */ + public data class Face(public val id: String) : OneBotSegment { + override val type: String get() = OneBotSegment.FACE + } + + /** An image. */ + public data class Image( + public val file: String? = null, + /** `flash` for a self-destructing image, null for a plain one. */ + public val subtype: String? = null, + public val url: String? = null, + public val cache: Boolean? = null, + public val proxy: Boolean? = null, + public val timeout: Int? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.IMAGE + } + + /** A voice message. */ + public data class Record( + public val file: String? = null, + /** Whether the record is sent with a changed voice. */ + public val magic: Boolean? = null, + public val url: String? = null, + public val cache: Boolean? = null, + public val proxy: Boolean? = null, + public val timeout: Int? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.RECORD + } + + /** A short video. */ + public data class Video( + public val file: String? = null, + public val url: String? = null, + public val cache: Boolean? = null, + public val proxy: Boolean? = null, + public val timeout: Int? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.VIDEO + } + + /** + * A file, which an implementation that offers the extension carries as a segment of its own. + * + * The standard sends a file through the actions for files rather than in a message, so this is a segment the + * standard does not declare: an implementation that reports one names the file and what to call it, and may name + * more about it than this version carries. + */ + public data class File( + /** How the implementation knows the file, which is also what an upload takes. */ + public val file: String, + /** What the file is called. */ + public val name: String? = null, + /** Where the file can be read, when the implementation names a place. */ + public val url: String? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.FILE + } + + /** A mention of one member, or of everyone when [userId] is [At.ALL]. */ + public data class At(public val userId: String) : OneBotSegment { + override val type: String get() = OneBotSegment.AT + + /** Whether the mention names everyone in the group. */ + public val isAll: Boolean get() = userId == ALL + + public companion object { + /** The `qq` value that mentions everyone. */ + public const val ALL: String = "all" + } + } + + /** The rock-paper-scissors magic emoji. */ + public data object Rps : OneBotSegment { + override val type: String get() = OneBotSegment.RPS + } + + /** The dice magic emoji. */ + public data object Dice : OneBotSegment { + override val type: String get() = OneBotSegment.DICE + } + + /** The window shake, the simplest kind of a poke. */ + public data object Shake : OneBotSegment { + override val type: String get() = OneBotSegment.SHAKE + } + + /** A poke. */ + public data class Poke( + /** The poke kind. */ + public val pokeType: String? = null, + public val id: String? = null, + /** The poke's name, which an implementation reports but does not take. */ + public val name: String? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.POKE + } + + /** A message sent without a name. */ + public data class Anonymous( + /** Whether the message is sent anyway when the platform refuses anonymity. */ + public val ignore: Boolean? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.ANONYMOUS + } + + /** A shared link. */ + public data class Share( + public val url: String, + public val title: String, + public val content: String? = null, + public val image: String? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.SHARE + } + + /** A recommended friend or group. */ + public data class Contact( + /** `qq` for a friend, `group` for a group. */ + public val contactType: String, + public val id: String, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.CONTACT + } + + /** A location. */ + public data class Location( + public val lat: String, + public val lon: String, + public val title: String? = null, + public val content: String? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.LOCATION + } + + /** A shared piece of music, either from a provider named by [musicType] or described by [url] and [audio]. */ + public data class Music( + /** `qq`, `163`, `xm` for a provider, or `custom` for a share the sender described itself. */ + public val musicType: String, + public val id: String? = null, + public val url: String? = null, + public val audio: String? = null, + public val title: String? = null, + public val content: String? = null, + public val image: String? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.MUSIC + } + + /** A reply to an earlier message. */ + public data class Reply(public val id: MessageId) : OneBotSegment { + override val type: String get() = OneBotSegment.REPLY + } + + /** A forwarded message, whose content only `get_forward_msg` returns. */ + public data class Forward(public val id: String) : OneBotSegment { + override val type: String get() = OneBotSegment.FORWARD + } + + /** A node of a forwarded message, either an existing message named by [id] or one the sender described. */ + public data class Node( + public val id: MessageId? = null, + public val userId: UserId? = null, + public val nickname: String? = null, + /** A message in any of the three shapes the API accepts, null when the node names a message by [id]. */ + public val content: OneBotMessage? = null, + ) : OneBotSegment { + override val type: String get() = OneBotSegment.NODE + } + + /** An XML message. */ + public data class Xml(public val data: String) : OneBotSegment { + override val type: String get() = OneBotSegment.XML + } + + /** A JSON message. */ + public data class Json(public val data: String) : OneBotSegment { + override val type: String get() = OneBotSegment.JSON + } + + /** A segment of a type this version does not know, with the value of its `data` object untouched. */ + public data class Unknown( + override val type: String, + /** The `data` object as the implementation sent it, empty when the segment carried none. */ + public val data: JsonObject = JsonObject(emptyMap()), + ) : OneBotSegment + + /** The names of the segment types OneBot v11 defines. */ + public companion object { + public const val TEXT: String = "text" + public const val FACE: String = "face" + public const val IMAGE: String = "image" + public const val RECORD: String = "record" + public const val VIDEO: String = "video" + public const val FILE: String = "file" + public const val AT: String = "at" + public const val RPS: String = "rps" + public const val DICE: String = "dice" + public const val SHAKE: String = "shake" + public const val POKE: String = "poke" + public const val ANONYMOUS: String = "anonymous" + public const val SHARE: String = "share" + public const val CONTACT: String = "contact" + public const val LOCATION: String = "location" + public const val MUSIC: String = "music" + public const val REPLY: String = "reply" + public const val FORWARD: String = "forward" + public const val NODE: String = "node" + public const val XML: String = "xml" + public const val JSON: String = "json" + + /** The names of every segment type, in the order the standard lists them. */ + public val KNOWN_TYPES: List = listOf( + TEXT, + FACE, + IMAGE, + RECORD, + VIDEO, + AT, + RPS, + DICE, + SHAKE, + POKE, + ANONYMOUS, + SHARE, + CONTACT, + LOCATION, + MUSIC, + REPLY, + FORWARD, + NODE, + XML, + JSON, + ) + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegmentCodec.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegmentCodec.kt new file mode 100644 index 00000000..ddf18541 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotSegmentCodec.kt @@ -0,0 +1,292 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.message + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId + +/** + * Reads and writes the three shapes of a message and the `type` plus `data` shape of a segment. + * + * Reading keeps every field of a segment the standard does not name in [OneBotSegment.Unknown], and reading a + * segment whose parameter the standard names but this version does not keep still returns the named type. + */ +public object OneBotSegmentCodec { + /** The message of [element], which may be a string, an array of segments or a single segment object. */ + public fun decodeMessage(element: JsonElement): OneBotMessage = when (element) { + is JsonArray -> OneBotMessage.ArrayValue(element.map(::decodeSegment)) + + is JsonObject -> + if (element.containsKey("type")) { + OneBotMessage.SingleSegment(decodeSegment(element)) + } else { + OneBotMessage.ArrayValue(emptyList()) + } + + is JsonPrimitive -> OneBotMessage.StringValue(element.content) + } + + /** The segments of [element], which may be an array of segments or a single segment object. */ + public fun decodeMessageSegments(element: JsonElement): List = decodeMessage(element).segments + + /** The segment of [element], an object with a `type` and a `data` object. */ + public fun decodeSegment(element: JsonElement): OneBotSegment { + val segment = element as? JsonObject + ?: return OneBotSegment.Unknown(OneBotSegment.TEXT, JsonObject(emptyMap())) + val type = segment["type"]?.jsonPrimitive?.contentOrNull.orEmpty() + val data = segment["data"] as? JsonObject ?: JsonObject(emptyMap()) + return segment(type, data) + } + + /** The JSON of [message] in the array shape, or as the string it already is. */ + public fun encodeMessage(message: OneBotMessage): JsonElement = when (message) { + is OneBotMessage.StringValue -> JsonPrimitive(message.text) + is OneBotMessage.ArrayValue -> buildJsonArray { message.segments.forEach { add(encodeSegment(it)) } } + is OneBotMessage.SingleSegment -> encodeSegment(message.segment) + } + + /** The JSON of [message] in the array shape. */ + public fun encodeMessageArray(message: OneBotMessage): JsonArray = + buildJsonArray { message.segments.forEach { add(encodeSegment(it)) } } + + /** The JSON of [segment], an object with a `type` and a `data` object. */ + public fun encodeSegment(segment: OneBotSegment): JsonObject = buildJsonObject { + put("type", segment.type) + when (segment) { + is OneBotSegment.Text -> putJsonObject("data") { put("text", segment.text) } + + is OneBotSegment.Face -> putJsonObject("data") { put("id", segment.id) } + + is OneBotSegment.Image -> putJsonObject("data") { + put("file", segment.file) + optional("type", segment.subtype) + optional("url", segment.url) + optional("cache", segment.cache?.let(::flag)) + optional("proxy", segment.proxy?.let(::flag)) + optional("timeout", segment.timeout) + } + + is OneBotSegment.Record -> putJsonObject("data") { + put("file", segment.file) + optional("magic", segment.magic?.let(::flag)) + optional("url", segment.url) + optional("cache", segment.cache?.let(::flag)) + optional("proxy", segment.proxy?.let(::flag)) + optional("timeout", segment.timeout) + } + + is OneBotSegment.Video -> putJsonObject("data") { + put("file", segment.file) + optional("url", segment.url) + optional("cache", segment.cache?.let(::flag)) + optional("proxy", segment.proxy?.let(::flag)) + optional("timeout", segment.timeout) + } + + is OneBotSegment.File -> putJsonObject("data") { + put("file", segment.file) + optional("name", segment.name) + optional("url", segment.url) + } + + is OneBotSegment.At -> putJsonObject("data") { put("qq", user(segment.userId)) } + + is OneBotSegment.Rps, is OneBotSegment.Dice, is OneBotSegment.Shake -> putJsonObject("data") {} + + is OneBotSegment.Poke -> putJsonObject("data") { + put("type", segment.pokeType) + put("id", segment.id) + optional("name", segment.name) + } + + is OneBotSegment.Anonymous -> putJsonObject("data") { optional("ignore", segment.ignore?.let(::flag)) } + + is OneBotSegment.Share -> putJsonObject("data") { + optional("url", segment.url) + optional("title", segment.title) + optional("content", segment.content) + optional("image", segment.image) + } + + is OneBotSegment.Contact -> putJsonObject("data") { + put("type", segment.contactType) + put("id", segment.id) + } + + is OneBotSegment.Location -> putJsonObject("data") { + put("lat", segment.lat) + put("lon", segment.lon) + optional("title", segment.title) + optional("content", segment.content) + } + + is OneBotSegment.Music -> putJsonObject("data") { + put("type", segment.musicType) + put("id", segment.id) + optional("url", segment.url) + optional("audio", segment.audio) + optional("title", segment.title) + optional("content", segment.content) + optional("image", segment.image) + } + + is OneBotSegment.Reply -> putJsonObject("data") { put("id", number(segment.id.value)) } + + is OneBotSegment.Forward -> putJsonObject("data") { put("id", segment.id) } + + is OneBotSegment.Node -> putJsonObject("data") { + segment.id?.let { put("id", number(it.value)) } + segment.userId?.let { put("user_id", user(it.value)) } + put("nickname", segment.nickname) + segment.content?.let { content -> put("content", encodeMessage(content)) } + } + + is OneBotSegment.Xml -> putJsonObject("data") { put("data", segment.data) } + + is OneBotSegment.Json -> putJsonObject("data") { put("data", segment.data) } + + is OneBotSegment.Unknown -> put("data", segment.data) + } + } + + private fun flag(value: Boolean): String = if (value) "1" else "0" + + /** [value] written under [key], and left out when it is null, as an optional parameter that is absent. */ + private fun JsonObjectBuilder.optional(key: String, value: String?) { + if (value != null) put(key, value) + } + + /** [value] written under [key], and left out when it is null. */ + private fun JsonObjectBuilder.optional(key: String, value: Int?) { + if (value != null) put(key, value) + } + + /** [value] as a JSON number when it is one, as a string when it is too large for a [Long]. */ + internal fun number(id: String): JsonElement = id.toLongOrNull()?.let(::JsonPrimitive) ?: JsonPrimitive(id) + + /** [qq] as a JSON number, or the string `all`. */ + private fun user(qq: String): JsonElement = qq.toLongOrNull()?.let(::JsonPrimitive) ?: JsonPrimitive(qq) + + private fun segment(type: String, data: JsonObject): OneBotSegment = when (type) { + OneBotSegment.TEXT -> OneBotSegment.Text(data.string("text").orEmpty()) + + OneBotSegment.FACE -> OneBotSegment.Face(data.string("id").orEmpty()) + + OneBotSegment.IMAGE -> OneBotSegment.Image( + file = data.string("file"), + subtype = data.string("type"), + url = data.string("url"), + cache = data.flag("cache"), + proxy = data.flag("proxy"), + timeout = data.number("timeout"), + ) + + OneBotSegment.RECORD -> OneBotSegment.Record( + file = data.string("file"), + magic = data.flag("magic"), + url = data.string("url"), + cache = data.flag("cache"), + proxy = data.flag("proxy"), + timeout = data.number("timeout"), + ) + + OneBotSegment.VIDEO -> OneBotSegment.Video( + file = data.string("file"), + url = data.string("url"), + cache = data.flag("cache"), + proxy = data.flag("proxy"), + timeout = data.number("timeout"), + ) + + OneBotSegment.FILE -> OneBotSegment.File( + file = data.string("file").orEmpty(), + name = data.string("name"), + url = data.string("url"), + ) + + OneBotSegment.AT -> OneBotSegment.At(data.string("qq").orEmpty()) + + OneBotSegment.RPS -> OneBotSegment.Rps + + OneBotSegment.DICE -> OneBotSegment.Dice + + OneBotSegment.SHAKE -> OneBotSegment.Shake + + OneBotSegment.POKE -> OneBotSegment.Poke(data.string("type"), data.string("id"), data.string("name")) + + OneBotSegment.ANONYMOUS -> OneBotSegment.Anonymous(data.flag("ignore")) + + OneBotSegment.SHARE -> OneBotSegment.Share( + url = data.string("url").orEmpty(), + title = data.string("title").orEmpty(), + content = data.string("content"), + image = data.string("image"), + ) + + OneBotSegment.CONTACT -> OneBotSegment.Contact(data.string("type").orEmpty(), data.string("id").orEmpty()) + + OneBotSegment.LOCATION -> OneBotSegment.Location( + lat = data.string("lat").orEmpty(), + lon = data.string("lon").orEmpty(), + title = data.string("title"), + content = data.string("content"), + ) + + OneBotSegment.MUSIC -> OneBotSegment.Music( + musicType = data.string("type").orEmpty(), + id = data.string("id"), + url = data.string("url"), + audio = data.string("audio"), + title = data.string("title"), + content = data.string("content"), + image = data.string("image"), + ) + + OneBotSegment.REPLY -> OneBotSegment.Reply(MessageId(data.numberText("id") ?: "0")) + + OneBotSegment.FORWARD -> OneBotSegment.Forward(data.string("id").orEmpty()) + + OneBotSegment.NODE -> OneBotSegment.Node( + id = data.numberText("id")?.let(::MessageId), + userId = data.numberText("user_id")?.let(::UserId), + nickname = data.string("nickname"), + content = data["content"]?.let(::decodeMessage), + ) + + OneBotSegment.XML -> OneBotSegment.Xml(data.string("data").orEmpty()) + + OneBotSegment.JSON -> OneBotSegment.Json(data.string("data").orEmpty()) + + else -> OneBotSegment.Unknown(type, data) + } + + private fun JsonObject.string(key: String): String? = + (this[key] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull + + private fun JsonObject.number(key: String): Int? = (this[key] as? JsonPrimitive)?.intOrNull + + private fun JsonObject.numberText(key: String): String? = + (this[key] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull?.takeIf { it.isNotEmpty() } + + private fun JsonObject.flag(key: String): Boolean? = (this[key] as? JsonPrimitive)?.let { value -> + when (value.contentOrNull) { + "1", "yes", "true" -> true + "0", "no", "false" -> false + else -> value.booleanOrNull + } + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/result/OneBotResult.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/result/OneBotResult.kt new file mode 100644 index 00000000..92ce0059 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/result/OneBotResult.kt @@ -0,0 +1,139 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.result + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import org.foedusprogramme.alexandrite.channel.onebot.protocol.OneBotRetcode +import org.foedusprogramme.alexandrite.channel.onebot.protocol.OneBotStatus + +/** + * What came of one API call. + * + * The kinds stay apart on purpose: a transport that never reached the implementation, an implementation that + * refused the call, and a call that succeeded are different facts, and only [data] needs the answer of the call. + * Every kind keeps the response [raw], so a field an implementation added is read from there. + */ +public sealed interface OneBotResult { + /** The `status` the response reported, or the one the transport made of its failure. */ + public val status: OneBotStatus + + /** The `retcode` the response reported, or one this module chose for a failure it saw itself. */ + public val retcode: Int + + /** The `echo` the response carried back, null when it carried none. */ + public val echo: JsonElement? + + /** The whole response object, empty when no response came. */ + public val raw: JsonObject + + /** The data of a successful call. */ + public data class Ok( + public val data: T, + override val retcode: Int = OneBotRetcode.OK, + override val echo: JsonElement? = null, + override val raw: JsonObject = JsonObject(emptyMap()), + ) : OneBotResult { + override val status: OneBotStatus get() = OneBotStatus.OK + } + + /** A call the implementation accepted for later, whose outcome the client cannot learn. */ + public data class Async( + override val retcode: Int = OneBotRetcode.ASYNC, + override val echo: JsonElement? = null, + override val raw: JsonObject = JsonObject(emptyMap()), + ) : OneBotResult { + override val status: OneBotStatus get() = OneBotStatus.ASYNC + } + + /** A call the implementation refused, carrying its `retcode`. */ + public data class Failed( + override val retcode: Int, + /** The `message` an implementation may add, null when it added none. */ + public val message: String? = null, + override val echo: JsonElement? = null, + override val raw: JsonObject = JsonObject(emptyMap()), + ) : OneBotResult { + override val status: OneBotStatus get() = OneBotStatus.FAILED + } + + /** A call that never reached the implementation, or whose answer was lost on the way. */ + public data class Unreachable( + public val failure: OneBotFailure, + /** The answer of the implementation, null when the request never got one. */ + override val raw: JsonObject = JsonObject(emptyMap()), + override val echo: JsonElement? = null, + ) : OneBotResult { + override val status: OneBotStatus get() = OneBotStatus.FAILED + + override val retcode: Int + get() = when (failure.kind) { + OneBotFailureKind.AUTHENTICATION -> OneBotRetcode.UNAUTHORIZED + OneBotFailureKind.UNKNOWN_ACTION -> OneBotRetcode.NOT_FOUND + else -> OneBotRetcode.BAD_REQUEST + } + } + + /** An answer this version cannot read, with what the implementation sent. */ + public data class Malformed( + public val detail: String, + override val echo: JsonElement? = null, + /** The answer as it arrived, so that a caller can read the field this version misread. */ + override val raw: JsonObject = JsonObject(emptyMap()), + ) : OneBotResult { + override val status: OneBotStatus get() = OneBotStatus.FAILED + + override val retcode: Int get() = OneBotRetcode.BAD_REQUEST + } +} + +/** Why an [OneBotResult.Unreachable] call failed. */ +public enum class OneBotFailureKind { + /** The endpoint could not be reached, or the connection broke. */ + CONNECTION, + + /** The implementation was too slow for the call's timeouts. */ + TIMEOUT, + + /** The token was missing or wrong. */ + AUTHENTICATION, + + /** The implementation answered with an HTTP status the call cannot use. */ + HTTP_STATUS, + + /** The implementation closed the connection, or the client did. */ + CLOSED, + + /** The call spoke about an action the registry does not know. */ + UNKNOWN_ACTION, +} + +/** One failure of a call that never produced an answer. */ +public data class OneBotFailure( + public val kind: OneBotFailureKind, + /** A description that names no token or secret. */ + public val message: String, + /** The HTTP status of the answer, null when none came. */ + public val httpStatus: Int? = null, +) { + init { + require(message.isNotBlank()) { "A failure needs a message." } + } + + /** Whether sending the same call again may succeed. */ + public val retryable: Boolean + get() = kind == OneBotFailureKind.CONNECTION || kind == OneBotFailureKind.TIMEOUT || + (httpStatus != null && httpStatus in 500..599) +} + +/** The `message` field of a response, null when it holds none. */ +public fun JsonObject.messageOrNull(): String? = + (this["message"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotEmpty() } + +/** The `retcode` the response body of [raw] holds, null when it holds none. */ +public fun retcodeOf(raw: JsonObject): Int? = (raw["retcode"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull() + +/** The `status` of the response body of [raw], null when it holds none. */ +public fun statusOf(raw: JsonObject): OneBotStatus? = + (raw["status"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotEmpty() }?.let(OneBotStatus::of) diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionTool.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionTool.kt new file mode 100644 index 00000000..f738750d --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionTool.kt @@ -0,0 +1,354 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.tool.FloorCheck +import org.foedusprogramme.alexandrite.sdk.tool.HardFloor +import org.foedusprogramme.alexandrite.sdk.tool.Tool +import org.foedusprogramme.alexandrite.sdk.tool.ToolContext +import org.foedusprogramme.alexandrite.sdk.tool.ToolDefinition +import org.foedusprogramme.alexandrite.sdk.tool.ToolResult +import org.foedusprogramme.alexandrite.sdk.tool.ToolRisk +import java.nio.file.Files +import java.nio.file.Path +import java.util.Base64 +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +/** + * Reads what the platform knows about a chat, a member or a message this bot acts in. + * + * One tool serves every action it may be asked for rather than one tool per action, because an action is a row of + * [OneBotActions] and not a class: the platform's surface is large, and a row says what an action takes and what it + * is worth. What a call may ask for is the table, so an action the table leaves out cannot be named at all, which is + * where the actions that hand out the account's credentials and the ones that restart the implementation are kept. + */ +@Singleton +@Contribute(Tool::class) +internal class OneBotReadTool(private val apis: OneBotApis, private val floor: HardFloor?) : Tool { + override val definition: ToolDefinition = ToolDefinition( + name = "onebot.read", + description = describe("Reads what the QQ platform knows", OneBotActions.rows.filterNot { it.changes }), + parameters = schema(), + risk = ToolRisk.READ_ONLY, + ) + + override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult = + call(apis, OneBotActions.rows.filterNot { it.changes }, floor, arguments, context) +} + +/** Changes something of a chat or a member of the platform this bot acts in. */ +@Singleton +@Contribute(Tool::class) +internal class OneBotActTool(private val apis: OneBotApis, private val floor: HardFloor?) : Tool { + override val definition: ToolDefinition = ToolDefinition( + name = "onebot.act", + description = describe( + "Changes something of a QQ chat this bot acts in. A call that ends without an answer may still have " + + "been carried out, so look at what that chat or group holds before calling again", + OneBotActions.rows.filter { it.changes }, + ), + parameters = schema(), + risk = ToolRisk.EXTERNAL_ACTION, + timeout = actTimeout, + ) + + override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult = + call(apis, OneBotActions.rows.filter { it.changes }, floor, arguments, context) +} + +/** The arguments every call of these tools takes, which is the action and whatever that action takes. */ +private fun schema(): JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("action") { + put("type", "string") + put("description", "The action to call, named as one of the actions the description lists.") + } + putJsonObject("params") { + put("type", "object") + put("description", "That action's arguments, by the names the description gives them.") + put("additionalProperties", true) + } + } + put("required", JsonArray(listOf(JsonPrimitive("action")))) + put("additionalProperties", false) +} + +/** What the model is told these tools are for: every action they may be asked for, and what it takes. */ +private fun describe(header: String, rows: List): String = buildString { + append(header) + append(". It calls one action per call, named as 'action', with that action's arguments under 'params'. ") + append("The actions it may be asked for, with a ? on the arguments that may be left out:\n") + rows.forEach { row -> + append("- ").append(row.name).append('(') + append(row.parameters.joinToString(", ") { it.name + (if (it.required) "" else "?") }) + append(") \u2014 ").append(row.said).append('\n') + row.parameters.forEach { parameter -> + append(" ").append(parameter.name).append(": ").append(parameter.description).append('\n') + } + } +} + +/** + * Calls [name] of [rows] in the chat of [context], and says what the platform answered. + * + * An action the call may not ask for is refused rather than passed on, and only the arguments its row declares are + * sent, so nothing a call writes under `params` can reach the platform unless the action takes it by that name. + */ +private suspend fun call( + apis: OneBotApis, + rows: List, + floor: HardFloor?, + arguments: JsonObject, + context: ToolContext, +): ToolResult { + val name = arguments.text("action") + ?: return ToolResult("A call names an action, as action.", isError = true) + val row = rows.firstOrNull { it.name == name } + ?: return ToolResult( + "'$name' is no action this call may ask for. It may ask for: " + + rows.joinToString(", ") { it.name } + ".", + isError = true, + ) + val given = arguments["params"] as? JsonObject ?: JsonObject(emptyMap()) + val params = when (val prepared = prepare(row, given, context.trigger?.id)) { + is Prepared.Refused -> return ToolResult(prepared.said, isError = true) + is Prepared.Ready -> prepared.params + } + // A file of the workspace is read here rather than where the arguments are written, because whether this call may + // read one is the floor's to say, which a function of the row and the arguments has no way to ask. + val carried = when ( + val files = + withFiles(row, params, floor, context) { call -> apis.serving(context.turn.chat).raw(UPLOAD_STREAM, call) } + ) { + is Prepared.Refused -> return ToolResult(files.said, isError = true) + is Prepared.Ready -> files.params + } + val api = apis.serving(context.turn.chat) + val result = api.raw(name, carried) + // What a read answers is what other people wrote, so a turn that carries it carries content this bot cannot vouch + // for, and the conversation is marked the way any other content read from outside is marked. + if (!row.changes && result is OneBotResult.Ok) { + context.untrusted("the platform answered what '$name' asked for") + } + return ToolResult(result.outcome(row.said, answered = !row.changes)) +} + +/** What a call to an action amounts to: the arguments to send, or why they cannot be sent. */ +internal sealed interface Prepared { + /** The arguments to send, which are the ones [OneBotActions.Row.parameters] declares. */ + class Ready(val params: JsonObject) : Prepared + + /** Why nothing is sent. */ + class Refused(val said: String) : Prepared +} + +/** + * The arguments [row] sends when a call gave [given], with [messageId] standing in for the id of the message of the + * turn where the row allows it to be left out. + * + * Every argument is written the way the standard asks for it, since a model writes them all as text: one that names a + * message, a group or a length is sent as the number it names, and one that asks whether to do something is sent as + * yes or no. An argument that must be given and was not, or one that cannot be read as what the row says it is, is + * refused, naming it and what it is for, rather than sent as something the platform would answer without saying what + * the call got wrong. + */ +internal fun prepare(row: OneBotActions.Row, given: JsonObject, messageId: String?): Prepared { + val params = buildJsonObject { + row.parameters.forEach { parameter -> + val value = given[parameter.name] + if (value != null && value !is JsonNull) { + when (parameter.kind) { + OneBotActions.Kind.TEXT -> put(parameter.name, value) + + OneBotActions.Kind.NUMBER -> put(parameter.name, number(value)) + + OneBotActions.Kind.FILE -> put(parameter.name, value) + + OneBotActions.Kind.FLAG -> { + val flag = boolean(value) + ?: return Prepared.Refused( + "The action '${row.name}' takes ${parameter.name} as yes or no, not '$value': " + + parameter.description, + ) + put(parameter.name, flag) + } + } + return@forEach + } + // An action about a message means the message of this turn when the call named none. A turn no message + // started leaves nothing to mean, and a call that had to name one is refused rather than sent without it. + if (parameter.name == OneBotActions.MESSAGE_ID && parameter.optional) { + val fallback = messageId ?: return Prepared.Refused( + "The action '${row.name}' needs ${parameter.name}: ${parameter.description}", + ) + put(parameter.name, number(JsonPrimitive(fallback))) + return@forEach + } + if (parameter.required) { + return Prepared.Refused( + "The action '${row.name}' needs ${parameter.name}: ${parameter.description}", + ) + } + } + } + return Prepared.Ready(params) +} + +/** + * [value] as yes or no, null when it says neither. + * + * A model writes an argument as text even when it asks whether to do something, so both the words it may write and + * the values a call may carry are read, and anything else is refused rather than sent as something the platform + * would answer without saying what the call got wrong. + */ +private fun boolean(value: JsonElement): Boolean? { + val primitive = value as? JsonPrimitive ?: return null + primitive.booleanOrNull?.let { return it } + return when (primitive.contentOrNull?.trim()?.lowercase()) { + "yes", "y", "1" -> true + "no", "n", "0" -> false + else -> null + } +} + +/** [value] as the number it names, or as the text it is when it names no number. */ +private fun number(value: JsonElement): JsonElement { + val text = (value as? JsonPrimitive)?.contentOrNull ?: return value + return text.toLongOrNull()?.let { JsonPrimitive(it) } ?: value +} + +/** + * [params] with every argument that names a file of this workspace carrying the file itself instead. + * + * A path of this machine means nothing to an implementation on another one, so the file is read and handed over as + * the bytes the platform takes, written the way it takes them. What may be read is the floor's to say: a path it + * denies is refused here with the reason it gave, and one it allows is read at the path it names, which is the path a + * link is followed to. A runtime with no floor is one where nothing decides what may be read, so a call that needs a + * file is refused rather than read of its own accord. + * + * A file larger than a call should carry goes to the implementation in pieces instead, which names it in answer, and + * what the action carries is that name: a file of any size can be sent, and neither side ever holds it whole. [send] + * is one call of the action that takes a file in pieces. + */ +internal suspend fun withFiles( + row: OneBotActions.Row, + params: JsonObject, + floor: HardFloor?, + context: ToolContext, + send: suspend (JsonObject) -> OneBotResult, +): Prepared { + val named = row.parameters.filter { it.kind == OneBotActions.Kind.FILE } + if (named.isEmpty()) return Prepared.Ready(params) + val reading = floor ?: return Prepared.Refused( + "The action '${row.name}' cannot read a file of your workspace: nothing in this runtime decides what may " + + "be read, so no file is sent.", + ) + val carried = buildJsonObject { + params.forEach { (key, value) -> put(key, value) } + named.forEach { parameter -> + val given = params[parameter.name]?.jsonPrimitive?.contentOrNull + ?: return Prepared.Refused( + "The action '${row.name}' needs ${parameter.name}: ${parameter.description}", + ) + // A file tool takes a path that is absolute, one relative to the workspace or one under the home + // directory, and asks the floor about the path that comes of it. What a call may name is the same, so the + // path is resolved the same way before the floor is asked, which is told of an absolute path alone. + val expanded = when { + given == "~" -> home + given.startsWith("~/") || given.startsWith("~\\") -> "$home${given.substring(1)}" + else -> given + } + val path = try { + Path.of(expanded) + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + return Prepared.Refused("'$given' is no path: ${parameter.description}") + } + val absolute = when { + path.isAbsolute -> path.normalize() + + context.workspace == null -> return Prepared.Refused( + "'$given' is a relative path, and this call has no workspace to resolve it against: " + + "give an absolute path.", + ) + + else -> try { + context.openWorkspace().resolve(path).normalize() + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + return Prepared.Refused("'$given' could not be resolved against your workspace: ${e.message}") + } + } + val allowed = when (val check = reading.checkRead(absolute, context.turn.agent)) { + is FloorCheck.Denied -> return Prepared.Refused( + "The action '${row.name}' may not read '$given': ${check.reason}.", + ) + + is FloorCheck.Allowed -> check.canonical + } + val size = try { + withContext(Dispatchers.IO) { Files.size(allowed) } + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + return Prepared.Refused("The action '${row.name}' could not read '$given': ${e.message}") + } + if (size > FILE_LIMIT) { + // Too large to carry in one request, so it goes in pieces and what is carried is the name the + // implementation gives it. A file it did not take whole is never sent on as if it were. + val called = params["name"]?.jsonPrimitive?.contentOrNull?.takeIf(String::isNotEmpty) + ?: allowed.fileName?.toString() + ?: "file" + val name = streamFile(allowed, called, send) ?: return Prepared.Refused( + "The action '${row.name}' could not send '$given': the implementation took no file in pieces, " + + "or named none to send on by.", + ) + context.touched(allowed) + put(parameter.name, name) + return@forEach + } + val bytes = try { + withContext(Dispatchers.IO) { Files.readAllBytes(allowed) } + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + return Prepared.Refused("The action '${row.name}' could not read '$given': ${e.message}") + } + context.touched(allowed) + put(parameter.name, BYTES_PREFIX + Base64.getEncoder().encodeToString(bytes)) + } + } + return Prepared.Ready(carried) +} + +/** What the platform is told a file's bytes are, which is how it is handed one it cannot reach itself. */ +private const val BYTES_PREFIX: String = "base64://" + +/** + * How long a change of this channel may take, which a file of the workspace makes long. + * + * A file larger than one request carries is sent to the implementation in pieces, each of them a call of its own, + * and is then sent on, so the timeout a turn takes by default is too short for one and ends the call with nothing + * known of what became of it. This is a ceiling rather than a wait: a call that is done sooner ends sooner. + */ +private val actTimeout: Duration = 5.minutes + +/** The home directory, which a path of a call may name with a leading `~` the way a file tool takes one. */ +private val home: String = System.getProperty("user.home").orEmpty() diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActions.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActions.kt new file mode 100644 index 00000000..0a29fdd6 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActions.kt @@ -0,0 +1,346 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotRegistry +import org.foedusprogramme.alexandrite.sdk.tool.ToolRisk + +/** + * The actions of the platform this bot may be asked for, each declared once. + * + * One row says what an action is for, which arguments it takes, what they mean and how each is written. A tool reads + * the row rather than a class of its own, so an action is added by adding a row, and the ones that must never be + * called are left out of the table rather than guarded in each tool. + * + * An action the standard declares is checked against the registry, so a row naming one that does not exist, or one + * the standard hides, fails where the table is read rather than at the platform. An action the standard does not + * declare at all is a row that says so, which is how an implementation's own actions are reached. + * + * The names of the arguments are the ones the platform takes, which are the keys its own requests are written with. + */ +internal object OneBotActions { + /** How the standard writes an argument, which a model writes as text either way. */ + internal enum class Kind { + /** A name, a title, a flag it was given, a reason: the text as it was written. */ + TEXT, + + /** A message, a group, a member, a length: the number the text names. */ + NUMBER, + + /** Whether to do something: yes or no. */ + FLAG, + + /** + * A file of the agent's own workspace, named by its path. + * + * The platform is usually on another machine, where a path of this one means nothing, so the tool reads the + * file and hands it over as the bytes the platform takes. What may be read is not decided here: the floor + * that decides what the tools for files may read decides it, where a runtime has one. + */ + FILE, + } + + /** + * What an action is, and what calling it takes. + * + * [parameters] names the arguments the platform takes, in the order they are shown. An argument that must be given + * says so, and the one an action about a message takes may be left out, in which case the message of this turn is + * meant: a prompt does not name the id of the message it answers, so a call about it must not have to. + */ + internal class Row( + val name: String, + val said: String, + val parameters: List, + /** Whether the standard declares this action at all. An implementation may have actions it does not. */ + val extension: Boolean = false, + ) { + /** Whether this action changes something rather than only reading. */ + val changes: Boolean get() = this in changing + + /** Whether the id of the message of this turn stands in for an argument that was left out. */ + val takesAMessage: Boolean get() = parameters.any { it.name == MESSAGE_ID && it.optional } + } + + /** One argument of an action. */ + internal class Parameter( + val name: String, + val description: String, + val kind: Kind = Kind.TEXT, + val required: Boolean = true, + ) { + /** Whether this argument may be left out. */ + val optional: Boolean get() = !required + } + + /** The argument that names the message an action is about. */ + const val MESSAGE_ID: String = "message_id" + + private val reading: List = listOf( + Row( + name = "get_msg", + said = "The message was read.", + parameters = listOf(message()), + ), + Row( + name = "get_forward_msg", + said = "The forwarded messages were read.", + parameters = listOf(Parameter("id", "The id of the forwarded message to read.")), + ), + Row( + name = "get_stranger_info", + said = "The account was read.", + parameters = listOf( + Parameter("user_id", "The account to read.", Kind.NUMBER), + Parameter("no_cache", "Whether to read it afresh rather than from the cache.", Kind.FLAG, false), + ), + ), + Row( + name = "get_group_info", + said = "The group was read.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("no_cache", "Whether to read it afresh rather than from the cache.", Kind.FLAG, false), + ), + ), + Row( + name = "get_group_member_info", + said = "The member was read.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("user_id", "The member to read.", Kind.NUMBER), + Parameter("no_cache", "Whether to read them afresh rather than from the cache.", Kind.FLAG, false), + ), + ), + Row( + name = "get_group_member_list", + said = "The members were read.", + parameters = listOf(Parameter("group_id", "The group.", Kind.NUMBER)), + ), + Row( + name = "get_group_honor_info", + said = "The honors were read.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("type", "Which honors to read, such as all, talkative or performer."), + ), + ), + Row( + name = "get_file", + said = "The file was read.", + parameters = listOf( + Parameter("file", "How the implementation knows the file."), + Parameter( + "type", + "Which kind of reference to answer with, where the implementation asks.", + required = false, + ), + ), + extension = true, + ), + Row( + name = "get_group_file_url", + said = "Where the group file can be read was read.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("file_id", "The id the group file is known by."), + Parameter("busid", "Which part of the group's files the file is in.", Kind.NUMBER), + ), + extension = true, + ), + ) + + private val changing: List = listOf( + Row( + name = "delete_group_file", + said = "The group file was deleted.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("file_id", "The id the group file is known by, which listing its files gives."), + Parameter( + "busid", + "Which part of the group's files the file is in, which listing its files gives.", + Kind.NUMBER, + ), + ), + extension = true, + ), + Row( + name = "delete_msg", + said = "The message was taken back.", + parameters = listOf(message("The message to take back.")), + ), + Row( + name = "set_msg_emoji_like", + said = "The emoji response was added.", + parameters = listOf( + message("The message to respond to."), + Parameter("emoji_id", "The number of the emoji, such as 128077 for a thumbs up.", Kind.NUMBER), + ), + extension = true, + ), + Row( + name = "send_like", + said = "The account was given a like.", + parameters = listOf( + Parameter("user_id", "The account to like.", Kind.NUMBER), + Parameter("times", "How many likes to give.", Kind.NUMBER, false), + ), + ), + Row( + name = "set_friend_add_request", + said = "The friend request was answered.", + parameters = listOf( + Parameter("flag", "The flag the request reported."), + Parameter("approve", "Whether to accept the request.", Kind.FLAG), + Parameter("remark", "The remark to keep with a friend the call accepts.", required = false), + ), + ), + Row( + name = "set_group_add_request", + said = "The request was answered.", + parameters = listOf( + Parameter("flag", "The flag the request reported."), + Parameter("sub_type", "add when somebody asks to join, invite when the bot is invited."), + Parameter("approve", "Whether to accept the request.", Kind.FLAG), + Parameter("reason", "The reason to give for a request the call refuses.", required = false), + ), + ), + Row( + name = "set_group_kick", + said = "The member was removed from the group.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("user_id", "The member to remove.", Kind.NUMBER), + Parameter("reject_add_request", "Whether to refuse their requests to join again.", Kind.FLAG, false), + ), + ), + Row( + name = "set_group_ban", + said = "The mute was set.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("user_id", "The member to mute.", Kind.NUMBER), + Parameter("duration", "How many seconds the mute lasts, 0 to lift it.", Kind.NUMBER), + ), + ), + Row( + name = "set_group_whole_ban", + said = "The mute of the whole group was set.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("enable", "Whether every member is muted.", Kind.FLAG), + ), + ), + Row( + name = "set_group_admin", + said = "Whether the member is an administrator was set.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("user_id", "The member.", Kind.NUMBER), + Parameter("enable", "Whether they administer the group.", Kind.FLAG), + ), + ), + Row( + name = "set_group_card", + said = "The name the member goes by in the group was set.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("user_id", "The member.", Kind.NUMBER), + Parameter("card", "The name they go by in the group, empty to clear it."), + ), + ), + Row( + name = "set_group_name", + said = "The name of the group was set.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("group_name", "The name to give the group."), + ), + ), + Row( + name = "set_group_leave", + said = "The bot left the group.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("is_dismiss", "Whether to dismiss the group rather than only leave it.", Kind.FLAG, false), + ), + ), + Row( + name = "set_group_special_title", + said = "The title was set.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("user_id", "The member.", Kind.NUMBER), + Parameter("special_title", "The title to give them, empty to clear it."), + Parameter("duration", "How many seconds the title lasts.", Kind.NUMBER, false), + ), + ), + Row( + name = "upload_group_file", + said = "The file was uploaded to the group.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("file", "The path of the file to upload, in your workspace.", Kind.FILE), + Parameter("name", "What to call the file in the group."), + Parameter("folder", "Which folder of the group's files to put it in.", required = false), + ), + extension = true, + ), + Row( + name = "upload_private_file", + said = "The file was uploaded to the chat.", + parameters = listOf( + Parameter("user_id", "The account to send the file to.", Kind.NUMBER), + Parameter("file", "The path of the file to upload, in your workspace.", Kind.FILE), + Parameter("name", "What to call the file."), + ), + extension = true, + ), + ) + + /** An action that is about the message of this turn when a call names none. */ + private fun message(what: String = "The message to read."): Parameter = + Parameter(MESSAGE_ID, "$what Default: the message this turn is about.", Kind.NUMBER, required = false) + + /** What a group's files are, which an implementation that offers the extension reports. */ + private val fileReading: List = listOf( + Row( + name = "get_group_root_files", + said = "The files of the group were read.", + parameters = listOf(Parameter("group_id", "The group.", Kind.NUMBER)), + extension = true, + ), + Row( + name = "get_group_files_by_folder", + said = "The files of that folder were read.", + parameters = listOf( + Parameter("group_id", "The group.", Kind.NUMBER), + Parameter("folder_id", "The folder to read, as the group names it."), + ), + extension = true, + ), + Row( + name = "get_group_file_system_info", + said = "What the group's files hold was read.", + parameters = listOf(Parameter("group_id", "The group.", Kind.NUMBER)), + extension = true, + ), + ) + + /** Everything this bot may be asked for, the reads first. */ + val rows: List = reading + fileReading + changing + + private val byName: Map = rows.associateBy(Row::name) + + init { + rows.forEach { row -> + if (!row.extension) { + val declared = OneBotRegistry.action(row.name) + require(declared != null) { "The platform declares no action '${row.name}'." } + require(!declared.hidden) { "The action '${row.name}' is one the standard hides." } + } + } + require(byName.size == rows.size) { "An action is declared twice." } + } + + /** The row of [name], null when this bot may not be asked for it. */ + fun of(name: String): Row? = byName[name] +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolSupport.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolSupport.kt new file mode 100644 index 00000000..bbd494a5 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolSupport.kt @@ -0,0 +1,64 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.api.OneBotApi +import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress + +/** The API of the instance that serves [chat], which fails when no such instance runs. */ +internal fun OneBotApis.serving(chat: ChatAddress): OneBotApi = + of(chat.instance) ?: throw IllegalStateException("No OneBot channel instance serves ${chat.instance}.") + +/** How much of an answer is shown, since an answer to a read may be far longer than a turn should carry. */ +internal const val ANSWER_LIMIT: Int = 4_000 + +/** + * How large a file a call carries in one request, above which the file goes to the implementation in pieces. + * + * The bytes are written the way the platform takes them, which grows them by about a third, and an implementation + * refuses what is larger than it takes without saying so in a way a model can act on, so a call is refused here + * instead, naming the limit it passed. + */ +internal const val FILE_LIMIT: Long = 8L * 1024 * 1024 + +/** + * What [what] says when the call was taken, and what the implementation said when it was not. + * + * A call that only reads is answered with what the platform gave it, because what was asked for *is* that answer: a + * call told only that its action was carried out leaves the model with nothing to read, which is what a read that + * discards the answer amounts to. A call that changes something is answered with what it did, since the platform's + * answer to it carries nothing the model can use. + */ +internal fun OneBotResult<*>.outcome(what: String, answered: Boolean = false): String = when (this) { + is OneBotResult.Ok -> if (answered) answer() else what + is OneBotResult.Async -> "$what The implementation answers later." + is OneBotResult.Failed -> "The implementation refused it: ${message ?: "it gave no reason"} (retcode $retcode)." + is OneBotResult.Unreachable -> "The implementation could not be reached: ${failure.message}." + is OneBotResult.Malformed -> "The implementation answered something this channel cannot read." +} + +/** What the platform answered, as the text it is, cut where it is longer than a turn should carry. */ +private fun OneBotResult.Ok<*>.answer(): String { + val shown = data?.toString().orEmpty() + return when { + shown.isEmpty() || shown == "null" -> "The implementation answered with nothing." + shown.length <= ANSWER_LIMIT -> shown + else -> shown.take(ANSWER_LIMIT) + " … (the answer was longer and stops here.)" + } +} + +/** The text of [name], null when the call gave none. */ +internal fun JsonObject.text(name: String): String? = + this[name]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + +/** [value] as the number it is, or as the text it is when it names no number. */ +internal fun JsonObjectBuilder.putId(name: String, value: String) { + val number = value.toLongOrNull() + if (number == null) put(name, JsonPrimitive(value)) else put(name, number) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUpload.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUpload.kt new file mode 100644 index 00000000..32401072 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUpload.kt @@ -0,0 +1,121 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest +import java.util.Base64 +import java.util.UUID + +/** + * How much of a file one call of the streaming upload carries. + * + * The implementation takes a file in pieces so that neither side holds all of it, and this is the piece it is given: + * the size the reference implementation of this protocol sends, which the platform takes. + */ +internal const val STREAM_CHUNK: Int = 256 * 1024 + +/** + * How long the implementation keeps the file it was sent, in milliseconds. + * + * The file is streamed to the implementation and then sent on to a chat by the reference it answers with, so it has to + * outlive the streaming by however long sending takes. + */ +internal const val STREAM_RETENTION_MS: Long = 300_000 + +/** The action that takes a file in pieces, which the standard does not declare and an implementation may have. */ +internal const val UPLOAD_STREAM: String = "upload_file_stream" + +/** Where an implementation names the file it was sent, which is what sending it on refers to. */ +private val STREAM_REFERENCE: List = listOf("file_path", "file", "path", "url") + +/** + * [path] sent to the implementation in pieces, answered by what the file can then be sent on by. + * + * The file is read twice and never held whole: once to say how large it is and what its digest is, which every piece + * is sent with, and once to send the pieces themselves. An implementation that refuses a piece, or that answers + * without naming the file, ends the sending, and nothing is sent on afterwards, so a file that was only partly + * streamed is never sent as if it were whole. + * + * [send] is one call of the action, which is what the tests stand in for rather than speaking to an implementation. + */ +internal suspend fun streamFile( + path: Path, + name: String, + send: suspend (JsonObject) -> OneBotResult, +): String? = withContext(Dispatchers.IO) { + val size = Files.size(path) + val digest = digest(path) + val total = ((size + STREAM_CHUNK - 1) / STREAM_CHUNK).toInt() + val stream = UUID.randomUUID().toString() + Files.newInputStream(path).use { input -> + val buffer = ByteArray(STREAM_CHUNK) + var index = 0 + while (index < total) { + val read = input.readNBytes(buffer, 0, STREAM_CHUNK) + if (read <= 0) break + val piece = if (read == buffer.size) buffer else buffer.copyOf(read) + val call = buildJsonObject { + put("stream_id", stream) + put("chunk_data", Base64.getEncoder().encodeToString(piece)) + put("chunk_index", index) + put("total_chunks", total) + put("file_size", size) + put("expected_sha256", digest) + put("filename", name) + put("file_retention", STREAM_RETENTION_MS) + } + if (send(call) !is OneBotResult.Ok) return@withContext null + index++ + } + } + val completed = send( + buildJsonObject { + put("stream_id", stream) + put("is_complete", true) + put("file_retention", STREAM_RETENTION_MS) + }, + ) + (completed as? OneBotResult.Ok)?.data?.reference() +} + +/** + * The name [data] gives the file it was sent, null when it names none. + * + * The reference is a field of what the implementation answered, which is where the reference implementation of this + * protocol reads it. One that answers under a field of its own is read as well, since what the file is called is the + * implementation's to decide. + */ +private fun JsonElement.reference(): String? { + val fields = this as? JsonObject ?: return null + return fields.reference() ?: (fields["data"] as? JsonObject)?.reference() +} + +/** The first of the names an implementation may give the file under, which is how the file is referred to after. */ +private fun JsonObject.reference(): String? = STREAM_REFERENCE.firstNotNullOfOrNull { name -> + this[name]?.jsonPrimitive?.contentOrNull?.takeIf(String::isNotEmpty) +} + +/** What [path] holds, as the digest the pieces of it are sent with. */ +private fun digest(path: Path): String { + val sha = MessageDigest.getInstance("SHA-256") + Files.newInputStream(path).use { input -> + val buffer = ByteArray(STREAM_CHUNK) + while (true) { + val read = input.read(buffer) + if (read < 0) break + sha.update(buffer, 0, read) + } + } + return sha.digest().joinToString("") { "%02x".format(it) } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/Connection.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/Connection.kt new file mode 100644 index 00000000..1820ff69 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/Connection.kt @@ -0,0 +1,127 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import java.util.concurrent.atomic.AtomicLong + +/** Where a connection is in its life, which its instance reports to whoever asks. */ +public enum class OneBotLinkState { + /** Nothing was connected yet. */ + CLOSED, + + /** The transport is reaching its implementation, or waiting for it. */ + CONNECTING, + + /** The connection takes calls and reports the events. */ + OPEN, + + /** The connection is closing, or closed on its own. */ + LOST, + ; + + /** Whether the connection takes calls right now. */ + public val isOpen: Boolean get() = this == OPEN +} + +/** + * One live connection to an OneBot implementation, whichever transport carries it. + * + * The plugin calls [send] and reads [events]; nothing outside this package sees the socket or the library behind + * either. The events end when the connection closes, so a reader stops waiting rather than hanging on a connection + * that will never speak again. + */ +public interface OneBotConnection : AutoCloseable { + /** Where the connection is. */ + public val state: StateFlow + + /** The events of the implementation, in the order it reported them. */ + public val events: Flow + + /** Sends one call and returns the answer body, which the caller decodes into its own type. */ + public suspend fun send(action: String, params: JsonObject): JsonObject + + /** Ends the connection and returns once it takes no call anymore. */ + override fun close() +} + +/** + * What every transport of this module shares: the events it reports, the state it reports them under and the close + * that ends both. + * + * A full queue drops its oldest event rather than blocking the thread the implementation reports on, and what it + * dropped is worked out from what it took less what was read and what still waits, so that a connection that cannot + * keep up is visible rather than silent. + */ +internal abstract class AbstractOneBotConnection(private val eventCapacity: Int) : OneBotConnection { + private val dropped = AtomicLong() + private val queue = Channel(eventCapacity, BufferOverflow.DROP_OLDEST) { dropped.incrementAndGet() } + private val mutableState = MutableStateFlow(OneBotLinkState.CLOSED) + + /** + * How many events this connection dropped because nothing read them fast enough. + * + * The queue answers that it took an event even when it displaced one, so the count cannot be read from its answer. + * It is the events it never delivered, which it names as they are dropped, and it only grows. + */ + internal val droppedEvents: Long get() = dropped.get() + + override val state: StateFlow = mutableState.asStateFlow() + + override val events: Flow = queue.receiveAsFlow() + + /** The last failure of the connection, null while it is healthy. */ + @Volatile + protected var failure: Throwable? = null + + @Volatile + private var closed = false + + /** Reports [event], dropping the oldest when nothing reads them fast enough. */ + protected fun report(event: OneBotEvent): Boolean = queue.trySend(event).isSuccess + + /** Moves the connection to [state]. */ + protected fun moveTo(state: OneBotLinkState) { + mutableState.value = state + } + + /** Records [error] and moves the connection to [OneBotLinkState.LOST]. */ + protected fun lost(error: Throwable) { + failure = error + moveTo(OneBotLinkState.LOST) + } + + /** Ends the events once, so that a reader stops after the ones already reported. */ + protected fun endEvents() { + queue.close() + } + + override fun close() { + if (closed) return + closed = true + try { + stop() + } finally { + moveTo(OneBotLinkState.CLOSED) + endEvents() + } + } + + /** Ends whatever the transport opened, once [close] was called for the first time. */ + protected abstract fun stop() +} + +/** The key a call is answered under, which an implementation returns as it is. */ +internal class Echo { + private val counter = java.util.concurrent.atomic.AtomicLong() + + /** The `echo` of the next call. */ + fun next(): String = "alexandrite-${counter.incrementAndGet()}" +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotForwardWebSocket.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotForwardWebSocket.kt new file mode 100644 index 00000000..33dac0d1 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotForwardWebSocket.kt @@ -0,0 +1,183 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEventCodec +import org.java_websocket.client.WebSocketClient +import org.java_websocket.handshake.ServerHandshake +import java.net.URI +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The connection of an implementation that serves its API and pushes its events on one WebSocket. + * + * The standard answers a call on the same socket it was sent on, under the `echo` of the request, and pushes events + * on the same socket without an echo, so this class tells the two apart and keeps the calls that are still waiting. + * The socket is kept up: a connection that drops is made again after the instance's reconnect interval, and a call + * that waits when the socket drops fails rather than waiting for a socket that may never answer it. + */ +internal class OneBotForwardWebSocket(settings: OneBotSettings) : AbstractOneBotConnection(settings.eventCapacity) { + private val settings = settings + private var scope: CoroutineScope? = null + private var loop: Job? = null + + @Volatile + private var client: AnsweringClient? = null + + /** Connects and returns once the implementation answered the handshake. */ + public suspend fun start() { + check(scope == null) { "The connection is already open." } + moveTo(OneBotLinkState.CONNECTING) + val opened = CompletableDeferred() + val run = CoroutineScope(SupervisorJob() + Dispatchers.IO) + scope = run + loop = run.launch { connectLoop(opened) } + val failure = withTimeout(settings.connectTimeoutMillis) { opened.await() } + if (failure != null) throw failure + } + + override suspend fun send(action: String, params: JsonObject): JsonObject = send(action, params, null) + + /** + * Sends one call, waiting at most [waitMillis] for its answer where a caller asks for longer than the instance + * allows. + * + * A call that carries a file takes longer than one that only reads something, and its answer is what says whether + * it arrived. Null waits as the instance's own timeout says. + */ + suspend fun send(action: String, params: JsonObject, waitMillis: Long?): JsonObject { + val socket = client ?: throw IllegalStateException("The connection is not open.") + val echo = echoes.next() + val waiting = CompletableDeferred() + socket.awaiting[echo] = waiting + try { + socket.send(OneBotWire.encodeToString(requestOf(action, params, echo))) + } catch (e: Exception) { + socket.awaiting.remove(echo) + throw e + } + return try { + withTimeout(waitMillis ?: settings.firstByteTimeoutMillis) { waiting.await() } + } catch (e: Exception) { + socket.awaiting.remove(echo) + throw e + } + } + + override fun stop() { + // A call waiting for an answer is ended here rather than by the close, whose handler runs on a thread this is + // about to cancel: a caller would otherwise stay suspended until its own timeout, after `stop` has returned. + // The close comes after, so that nothing new is sent in between. + failPending(IllegalStateException("The connection to ${settings.endpoint} is closing.")) + runCatching { client?.close() } + scope?.cancel() + scope = null + loop = null + } + + private suspend fun connectLoop(opened: CompletableDeferred) { + while (scope != null) { + val ended = CompletableDeferred() + val socket = AnsweringClient(URI(settings.requireEndpoint()), ended) + client = socket + val connected = try { + socket.connectBlocking(settings.connectTimeoutMillis, java.util.concurrent.TimeUnit.MILLISECONDS) + } catch (e: Exception) { + if (!opened.isCompleted) { + opened.complete(e) + return + } + null + } + if (connected == false) { + if (!opened.isCompleted) { + opened.complete(IllegalStateException("The implementation at ${settings.endpoint} did not answer.")) + return + } + } else if (connected == true) { + moveTo(OneBotLinkState.OPEN) + if (!opened.isCompleted) opened.complete(null) + ended.await() + failPending(IllegalStateException("The connection to ${settings.endpoint} ended.")) + } + if (scope == null) return + delay(settings.reconnectIntervalMillis) + } + } + + private fun failPending(error: Throwable) { + val socket = client ?: return + for (waiting in socket.awaiting.values) waiting.completeExceptionally(error) + socket.awaiting.clear() + } + + /** The socket of one connection, which reads its frames on the thread of the library. */ + private inner class AnsweringClient(uri: URI, private val ended: CompletableDeferred) : + WebSocketClient( + uri, + buildMap { + settings.token?.let { put(OneBotAuth.AUTHORIZATION, OneBotAuth.bearer(it)) } + }, + ) { + val awaiting = ConcurrentHashMap>() + private val stopping = AtomicBoolean() + + override fun onOpen(handshake: ServerHandshake) { + moveTo(OneBotLinkState.OPEN) + } + + override fun onMessage(message: String) { + val element = try { + OneBotWire.parseToJsonElement(message) + } catch (e: Exception) { + failure = e + return + } + val answer = element as? JsonObject ?: return + val echo = (answer["echo"] as? kotlinx.serialization.json.JsonPrimitive)?.content + if (echo != null) { + val waiting = awaiting.remove(echo) + if (waiting != null) { + if (answer["status"] == null && answer["retcode"] == null) { + waiting.completeExceptionally(IllegalArgumentException("The answer names no status: $answer")) + } else { + waiting.complete(answer) + } + return + } + } + report(OneBotEventCodec.decode(answer)) + } + + override fun onClose(code: Int, reason: String, remote: Boolean) { + if (!stopping.get() && scope != null) lost(IllegalStateException("The connection closed: $code $reason")) + ended.complete(Unit) + } + + override fun onError(ex: Exception) { + failure = ex + } + + /** The headers this connection presents, which carry the token when the instance has one. */ + private fun headers(): Map = buildMap { + settings.token?.let { put(OneBotAuth.AUTHORIZATION, OneBotAuth.bearer(it)) } + } + + override fun close() { + stopping.set(true) + super.close() + } + } + + private val echoes = Echo() +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpApi.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpApi.kt new file mode 100644 index 00000000..0be54bf8 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpApi.kt @@ -0,0 +1,193 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.auth.Value +import org.foedusprogramme.alexandrite.channel.onebot.protocol.OneBotRetcode +import org.foedusprogramme.alexandrite.channel.onebot.protocol.OneBotStatus +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailure +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailureKind +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.internal.http.HttpCall +import org.foedusprogramme.alexandrite.internal.http.HttpException +import org.foedusprogramme.alexandrite.internal.http.HttpFailureKind +import org.foedusprogramme.alexandrite.internal.http.HttpTimeouts +import org.foedusprogramme.alexandrite.internal.http.HttpTransport +import java.net.URI +import kotlin.time.Duration.Companion.milliseconds + +/** + * The API of an implementation that serves it over HTTP. + * + * One call is one request to `/` with the token as a bearer header, and the answer is the JSON + * object of the standard. The transport of `internal` sends it, so this class only says what a call is and what its + * answer means. + */ +public class OneBotHttpApi( + private val endpoint: String, + private val token: Value?, + connectTimeoutMillis: Long, + firstByteTimeoutMillis: Long, + idleTimeoutMillis: Long, +) : AutoCloseable { + private val masked: List = listOfNotNull(token?.reveal()?.let(Value::secretOf)) + private val transport = HttpTransport( + HttpTimeouts( + connect = connectTimeoutMillis.milliseconds, + firstByte = firstByteTimeoutMillis.milliseconds, + idle = idleTimeoutMillis.milliseconds, + ), + ) + + /** Sends one call, with [action] already carrying its suffix. */ + public suspend fun call(action: String, params: JsonObject): OneBotResult { + val call = HttpCall( + method = "POST", + // The token goes in both places the standard allows: a call is accepted by an implementation that reads + // the header and by one that only reads the query, and sending the same token twice costs nothing. + uri = URI.create(OneBotAuth.withToken("${endpoint.trimEnd('/')}/$action", token)), + headers = buildMap { + put("Content-Type", "application/json") + token?.let { put(OneBotAuth.AUTHORIZATION, OneBotAuth.bearer(it)) } + }, + body = OneBotWire.encodeToString(JsonObject.serializer(), params), + secrets = masked.map(Value::reveal), + ) + val answer = try { + OneBotWire.decodeFromString(JsonObject.serializer(), transport.send(call)) + } catch (e: HttpException) { + return failure(e) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + return unreachable(OneBotFailureKind.CONNECTION, call.mask("$call was not answered: ${e.message}")) + } + return resultOf(answer, call) + } + + override fun close() { + transport.close() + } + + private fun failure(error: HttpException): OneBotResult.Unreachable { + val kind = when (error.kind) { + HttpFailureKind.AUTHENTICATION, HttpFailureKind.PERMISSION_DENIED -> OneBotFailureKind.AUTHENTICATION + HttpFailureKind.TIMEOUT -> OneBotFailureKind.TIMEOUT + HttpFailureKind.CONNECTION -> OneBotFailureKind.CONNECTION + else -> OneBotFailureKind.HTTP_STATUS + } + return unreachable(kind, error.message ?: "the call failed", error.status) + } + + private fun unreachable( + kind: OneBotFailureKind, + message: String, + httpStatus: Int? = null, + ): OneBotResult.Unreachable = OneBotResult.Unreachable( + OneBotFailure(kind, OneBotAuth.redact(message, masked), httpStatus), + ) + + private fun resultOf(answer: JsonObject, call: HttpCall): OneBotResult { + val status = answer.status() + val retcode = answer.retcode() + val echo = answer["echo"]?.takeUnless { it is JsonNull } + return when { + status == OneBotStatus.ASYNC -> OneBotResult.Async( + retcode = retcode ?: OneBotRetcode.ASYNC, + echo = echo, + raw = answer, + ) + + status == OneBotStatus.OK && (retcode == null || retcode == OneBotRetcode.OK) -> OneBotResult.Ok( + data = answer.data(), + echo = echo, + raw = answer, + ) + + status == null -> OneBotResult.Malformed( + "the answer of $call names no status: ${answer.keys.sorted()}", + echo, + answer, + ) + + else -> OneBotResult.Failed( + retcode = retcode ?: OneBotRetcode.BAD_REQUEST, + message = answer.message(), + echo = echo, + raw = answer, + ) + } + } +} + +/** + * Classifies the answer of one call that came back over a socket, as a call over HTTP classifies its own. + * + * A socket carries the same envelope as an HTTP answer, and reading it as data made a `failed` answer an `Ok` whose + * data was the envelope, which a caller read as a delivery that happened. The status is what says whether a call + * succeeded, whichever transport carried it. + */ +internal fun envelopeResult(answer: JsonObject): OneBotResult { + val status = answer.status() + val retcode = answer.retcode() + val echo = answer["echo"]?.takeUnless { it is JsonNull } + return when { + status == OneBotStatus.ASYNC -> OneBotResult.Async( + retcode = retcode ?: OneBotRetcode.ASYNC, + echo = echo, + raw = answer, + ) + + status == OneBotStatus.OK && (retcode == null || retcode == OneBotRetcode.OK) -> OneBotResult.Ok( + data = answer.data(), + echo = echo, + raw = answer, + ) + + status == null -> OneBotResult.Malformed( + "an answer over a socket names no status: ${answer.keys.sorted()}", + echo, + answer, + ) + + else -> OneBotResult.Failed( + retcode = retcode ?: OneBotRetcode.BAD_REQUEST, + message = answer.message(), + echo = echo, + raw = answer, + ) + } +} + +/** The `status` of this answer, null when it carries none. */ +internal fun JsonObject.status(): OneBotStatus? = + (this["status"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotEmpty() }?.let(OneBotStatus::of) + +/** The `retcode` of this answer, null when it carries none. */ +internal fun JsonObject.retcode(): Int? = (this["retcode"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull() + +/** + * The `data` of this answer as it arrived. + * + * The standard lets an action answer with an object, with an array or with nothing, and a list of friends or of groups + * is one of the arrays, so a reader that took the object shape alone reported every such answer as malformed. + */ +internal fun JsonObject.data(): JsonElement = this["data"] ?: JsonNull + +/** The `message` or `wording` of this answer, null when it carries neither. */ +internal fun JsonObject.message(): String? = + (this["message"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotEmpty() } + ?: (this["wording"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotEmpty() } + +/** The request of one call over a socket, as the standard spells it. */ +internal fun requestOf(action: String, params: JsonObject, echo: String): JsonObject = buildJsonObject { + put("action", action) + put("params", params) + put("echo", echo) +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiver.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiver.kt new file mode 100644 index 00000000..3fda22d2 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiver.kt @@ -0,0 +1,351 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.foedusprogramme.alexandrite.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEventCodec +import java.io.BufferedInputStream +import java.io.IOException +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.nio.charset.StandardCharsets +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.thread + +/** Why a report was not accepted, as the status the implementation is answered with. */ +public enum class OneBotReportRejection(public val status: Int, public val reason: String) { + /** The request is not a POST of a JSON body. */ + NOT_A_REPORT(400, "a report is a POST of a JSON body"), + + /** The request names another path than the one this instance accepts reports on. */ + WRONG_PATH(404, "no report is accepted on this path"), + + /** The report is larger than a report may be. */ + TOO_LARGE(413, "the report is too large"), + + /** Every thread of this instance is reading a report and the queue behind them is full. */ + BUSY(503, "the reports of this instance are being read as fast as they arrive"), + + /** The report carries no signature, or one that does not match its body. */ + BAD_SIGNATURE(403, "the signature of the report does not match its body"), + + /** The report names another account than the one this instance serves. */ + WRONG_ACCOUNT(403, "the report names another account"), + + /** The body is no event of the standard. */ + MALFORMED(400, "the report is no event of the standard"), +} + +/** What this instance does with one report. */ +public sealed interface OneBotReportAnswer { + /** The report was taken, and nothing more is said. */ + public data object Accepted : OneBotReportAnswer + + /** The report was taken, and the implementation should run [operation] as a quick operation. */ + public data class QuickOperation(public val operation: JsonObject) : OneBotReportAnswer + + /** The report was refused, and the implementation is told why. */ + public data class Rejected(public val rejection: OneBotReportRejection) : OneBotReportAnswer +} + +/** + * The reports of an implementation that sends them over HTTP POST. + * + * One report is one POST of an event, signed with the shared secret when the instance has one, and the answer of the + * receiver is either empty or a quick operation the implementation runs. Alexandrite listens, so this is the side + * that accepts. + */ +internal class OneBotHttpPostReceiver( + private val settings: OneBotSettings, + /** What this instance does with [event], the report it just took. */ + private val answer: (OneBotEvent) -> OneBotReportAnswer = { OneBotReportAnswer.Accepted }, +) : AbstractOneBotConnection(settings.eventCapacity) { + private val accepting = AtomicBoolean() + private val acceptedReports = java.util.concurrent.atomic.AtomicInteger() + private var server: ServerSocket? = null + private var acceptor: Thread? = null + + /** + * The threads that read reports, bounded so that a client cannot hold one thread per connection it opens. + * + * The pool is small and its queue shorter: a listener of one chat is not the busiest thing in the process, and a + * report arrives in a burst of one. Past that a connection is refused at once as busy rather than kept, which is + * what stops a slow sender from occupying the process. + */ + private val readers: java.util.concurrent.ThreadPoolExecutor = java.util.concurrent.ThreadPoolExecutor( + MAX_CONCURRENT_REPORTS, + MAX_CONCURRENT_REPORTS, + 0L, + java.util.concurrent.TimeUnit.MILLISECONDS, + java.util.concurrent.ArrayBlockingQueue(MAX_QUEUED_REPORTS), + java.util.concurrent.ThreadFactory { runnable -> + Thread(runnable, "onebot-http-post-report-$reportPort").apply { isDaemon = true } + }, + ) + + /** The port this receiver bound, which names its threads and which a refusal is about. */ + private val reportPort: Int get() = server?.localPort ?: settings.listenPort + + /** How many reports this receiver took, which tells a refused one from an accepted one. */ + internal val reportedCount: Int get() = acceptedReports.get() + + /** Starts listening and returns once the socket is bound. */ + public fun start() { + check(!accepting.getAndSet(true)) { "The receiver is already open." } + moveTo(OneBotLinkState.CONNECTING) + val socket = try { + ServerSocket().apply { + reuseAddress = true + bind(InetSocketAddress(settings.listenHost, settings.listenPort)) + } + } catch (e: IOException) { + accepting.set(false) + lost(e) + throw e + } + server = socket + acceptor = thread(name = "onebot-http-post-${socket.localPort}", isDaemon = true) { acceptLoop(socket) } + moveTo(OneBotLinkState.OPEN) + } + + /** The port this receiver listens on, which a configured port of 0 leaves to the operating system. */ + public val port: Int get() = server?.localPort ?: settings.listenPort + + override suspend fun send(action: String, params: JsonObject): JsonObject = throw UnsupportedOperationException( + "An implementation that reports over HTTP POST is called over HTTP; use its endpoint as the API.", + ) + + override fun stop() { + accepting.set(false) + runCatching { server?.close() } + acceptor?.let { runCatching { it.join(CLOSE_TIMEOUT_MILLIS) } } + // A reader that is mid-report is left to its deadline rather than interrupted; one that has not started is + // dropped, so that a stopping receiver reads no more than it already took. + readers.shutdownNow() + server = null + acceptor = null + } + + private fun acceptLoop(socket: ServerSocket) { + while (accepting.get() && !socket.isClosed) { + val client = try { + socket.accept() + } catch (e: IOException) { + if (accepting.get()) lost(e) + return + } + try { + readers.execute { client.use { exchange(it) } } + } catch (e: java.util.concurrent.RejectedExecutionException) { + // Every reader is busy and the queue is full, so this connection is answered as busy at once instead + // of being held. A report that is refused this way can be sent again. + client.use { respond(it, OneBotReportRejection.BUSY) } + } + } + } + + private fun exchange(client: Socket) { + try { + // An idle timeout only measures the gap between two reads, so one deadline covers the whole request: a + // client that sends a byte at a time is cut off at the deadline rather than kept for as long as it likes. + val deadline = System.nanoTime() + settings.firstByteTimeoutMillis * 1_000_000 + client.soTimeout = settings.firstByteTimeoutMillis.toInt() + val input = BufferedInputStream(client.getInputStream()) + // A report larger than this instance reads is refused as such, which is what the implementation is told. + // Its declared body is read into nothing first, so that the answer is read by a peer that is still + // sending rather than lost to a connection that closed under it. + val request = when (val outcome = read(input, deadline)) { + is ReadOutcome.TooLarge -> { + drain(input, outcome.declaredBytes, deadline) + return respond(client, OneBotReportRejection.TOO_LARGE) + } + + is ReadOutcome.Read -> outcome.request + + ReadOutcome.NotAReport -> return respond(client, OneBotReportRejection.NOT_A_REPORT) + } + if (request.method != "POST" || request.path != settings.path) { + val rejection = + if (request.method == + "POST" + ) { + OneBotReportRejection.WRONG_PATH + } else { + OneBotReportRejection.NOT_A_REPORT + } + return respond(client, rejection) + } + if (request.body.length > MAX_REPORT_BYTES) return respond(client, OneBotReportRejection.TOO_LARGE) + val secret = settings.secret + val signature = request.headers[OneBotAuth.SIGNATURE.lowercase()] + if (secret != null && !OneBotAuth.matchesSignature(secret, request.body, signature)) { + return respond(client, OneBotReportRejection.BAD_SIGNATURE) + } + val event = try { + OneBotEventCodec.decode(OneBotWire.parseToJsonElement(request.body)) + } catch (e: Exception) { + return respond(client, OneBotReportRejection.MALFORMED) + } + val selfId = settings.selfId + val reported = request.headers[OneBotAuth.SELF_ID.lowercase()] ?: reportedInBody(request.body) + // An instance that serves one account takes no report that names none: the account is what binds the + // report to this instance, so a missing one is as wrong as a different one. This is asked after the body + // is read, so that a report that is no event at all is told apart from one of another account. + if (selfId != null && reported != selfId) { + return respond(client, OneBotReportRejection.WRONG_ACCOUNT) + } + // The decision of this instance is taken first: a report it refuses is answered with that refusal and is + // not reported to anyone, because the implementation has already been told it was not taken. + when (val taken = answer(event)) { + is OneBotReportAnswer.Rejected -> return respond(client, taken.rejection) + + is OneBotReportAnswer.QuickOperation -> { + report(event) + acceptedReports.incrementAndGet() + respond(client, taken.operation) + } + + OneBotReportAnswer.Accepted -> { + report(event) + acceptedReports.incrementAndGet() + respond(client, null) + } + } + } catch (e: IOException) { + failure = e + } catch (e: Exception) { + failure = e + } + } + + private fun respond(client: Socket, rejection: OneBotReportRejection) { + respond(client, null, rejection.status, rejection.reason) + } + + private fun respond(client: Socket, operation: JsonObject?) { + respond(client, operation, 200, null) + } + + private fun respond(client: Socket, operation: JsonObject?, initialStatus: Int, detail: String?) { + val status = if (operation == null && initialStatus == 200) 204 else initialStatus + val body = operation?.let(OneBotWire::encodeToString) ?: detail.orEmpty() + val bytes = body.toByteArray(StandardCharsets.UTF_8) + // A quick operation is an object the implementation reads as JSON; a rejection is a sentence for whoever wrote + // it, and a client that parses it as JSON would fail instead of showing the reason. + val type = if (operation == null) "text/plain; charset=utf-8" else "application/json" + val out = client.getOutputStream() + out.write( + buildString { + append("HTTP/1.1 $status ${if (status == 204) "No Content" else "OK"}\r\n") + if (status != 204) append("Content-Type: $type\r\n") + append("Content-Length: ${bytes.size}\r\n") + append("Connection: close\r\n\r\n") + }.toByteArray(StandardCharsets.US_ASCII), + ) + out.write(bytes) + out.flush() + } + + /** The `self_id` of the report in [body], null when the body names none. */ + private fun reportedInBody(body: String): String? = try { + (OneBotWire.parseToJsonElement(body) as? JsonObject)?.let { report -> + (report["self_id"] as? JsonPrimitive)?.content + } + } catch (e: Exception) { + null + } + + private class Request(val method: String, val path: String, val headers: Map, val body: String) + + /** What reading the head of a connection came to. */ + private sealed interface ReadOutcome { + /** A request this receiver reads. */ + data class Read(val request: Request) : ReadOutcome + + /** A body whose declared length is over what this instance reads, refused before it is allocated. */ + data class TooLarge(val declaredBytes: Int) : ReadOutcome + + /** Not an HTTP request this receiver reads. */ + data object NotAReport : ReadOutcome + } + + /** + * Reads [declaredBytes] of a body that will not be kept, in fixed pieces. + * + * Reading nothing would answer a peer that is still sending, and reading it in one piece is what the limit + * exists to prevent, so it is read in pieces small enough to be forgotten. + */ + private fun drain(input: BufferedInputStream, declaredBytes: Int, deadline: Long) { + val piece = ByteArray(DRAIN_PIECE_BYTES) + var left = declaredBytes.toLong() + while (left > 0) { + // An over-limit body is dropped under the same deadline as any other request, so a sender that drips it + // just faster than the idle timeout cannot hold a reader past the deadline. + if (System.nanoTime() > deadline) return + val count = input.read(piece, 0, minOf(piece.size.toLong(), left).toInt()) + if (count < 0) return + left -= count + } + } + + /** The request of [input], or why it is not one this receiver reads. */ + private fun read(input: BufferedInputStream, deadline: Long): ReadOutcome { + val head = readHead(input, deadline) ?: return ReadOutcome.NotAReport + val lines = head.split("\r\n") + val start = lines.firstOrNull()?.split(' ') ?: return ReadOutcome.NotAReport + if (start.size < 2) return ReadOutcome.NotAReport + val headers = lines.drop(1).mapNotNull { line -> + val colon = line.indexOf(':') + if (colon <= 0) null else line.substring(0, colon).trim().lowercase() to line.substring(colon + 1).trim() + }.toMap() + val length = headers["content-length"]?.toIntOrNull() ?: 0 + // The length is checked before it is used to allocate, so that a client cannot ask this listener for an array + // of the size it names. + if (length < 0 || length > MAX_REPORT_BYTES) return ReadOutcome.TooLarge(length) + val bytes = ByteArray(length) + var read = 0 + while (read < length) { + if (System.nanoTime() > deadline) return ReadOutcome.NotAReport + val count = input.read(bytes, read, length - read) + if (count < 0) return ReadOutcome.NotAReport + read += count + } + return ReadOutcome.Read(Request(start[0].uppercase(), start[1], headers, String(bytes, StandardCharsets.UTF_8))) + } + + /** The request head of [input], which ends at the first empty line, null when it ends first. */ + private fun readHead(input: BufferedInputStream, deadline: Long): String? { + val text = StringBuilder() + var previous = -1 + while (text.length <= MAX_HEAD_BYTES) { + if (System.nanoTime() > deadline) return null + val byte = input.read() + if (byte < 0) return null + text.append(byte.toChar()) + if (previous == '\r'.code && byte == '\n'.code && text.endsWith("\r\n\r\n")) { + return text.toString().removeSuffix("\r\n") + } + previous = byte + } + return null + } + + private companion object { + internal const val MAX_REPORT_BYTES = 8 * 1024 * 1024 + + /** How much of a refused body is read at a time, so that nothing large is ever held. */ + const val DRAIN_PIECE_BYTES = 8 * 1024 + + /** How many reports are read at once; past this a connection waits in the queue or is refused as busy. */ + const val MAX_CONCURRENT_REPORTS = 4 + + /** How many connections wait for a reader before one is refused as busy. */ + const val MAX_QUEUED_REPORTS = 16 + + const val MAX_HEAD_BYTES = 32 * 1024 + + const val CLOSE_TIMEOUT_MILLIS = 2_000L + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotReverseWebSocket.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotReverseWebSocket.kt new file mode 100644 index 00000000..ceee7d96 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotReverseWebSocket.kt @@ -0,0 +1,235 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import org.foedusprogramme.alexandrite.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEventCodec +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds + +/** + * The connection of an implementation that dials Alexandrite to call its API and push its events. + * + * Alexandrite listens, so this is the side that accepts. One connection serves both the calls and the events, as the + * standard's universal client does, and its `X-Client-Role`, `X-Self-ID` and token are checked before it is taken. + */ +internal class OneBotReverseWebSocket(private val settings: OneBotSettings) : + AbstractOneBotConnection(settings.eventCapacity) { + private val awaiting = ConcurrentHashMap>() + private val connected = CompletableDeferred() + private val bound = CountDownLatch(1) + + @Volatile + private var socket: WebSocket? = null + + private val server = object : WebSocketServer(InetSocketAddress(settings.listenHost, settings.listenPort)) { + override fun onStart() { + bound.countDown() + } + + override fun onOpen(connection: WebSocket, handshake: ClientHandshake) { + when (val refusal = refusalOf(handshake)) { + null -> { + // A peer that replaces the one being served takes over the calls of the connection it replaces, + // and the frames of the peer that left are no longer read. Calls sent on that peer would + // therefore wait for an answer that can never be read, so they are ended before it replaces it. + socket?.let { left -> + socket = null + failWaiting(IllegalStateException("A newer peer took over the connection.")) + left.close(REPLACED_CODE, "a newer peer took over the connection") + } + socket = connection + connected.complete(Unit) + } + + else -> { + failure = IllegalStateException(refusal) + connected.completeExceptionally(IllegalStateException(refusal)) + connection.close(REFUSAL_CODE, refusal) + } + } + } + + /** + * Why this connection is refused, null when its token and its account are the ones this instance serves. + * + * The headers are read by walking the handshake: asking the library for one it does not hold answers Java's + * `null` into a Kotlin non-null type, which throws inside the library's selector thread and takes the whole + * connection with it. + */ + private fun refusalOf(handshake: ClientHandshake): String? { + val headers = HEADERS.filter { handshake.hasFieldValue(it) } + .associate { it.lowercase() to handshake.getFieldValue(it) } + val token = settings.token + val authorization = headers[OneBotAuth.AUTHORIZATION.lowercase()] + val offered = OneBotAuth.tokenOf(authorization) + if (token != null && !OneBotAuth.matchesToken(token, offered)) { + return "The connection carries another token than this instance." + } + val selfId = settings.selfId + val reported = headers[OneBotAuth.SELF_ID.lowercase()] + // An instance that serves one account takes no connection that names none: the account is what binds the + // connection to this instance, so a missing one is as wrong as a different one. + if (selfId != null && reported != selfId) { + return "The connection names another account than this instance." + } + return refusedClientRole(headers[OneBotAuth.CLIENT_ROLE.lowercase()]) + } + override fun onMessage(connection: WebSocket, message: String) { + // A connection this instance refused is closed as it is opened, and one that an implementation replaced + // is no longer the peer being served. Frames are read from the active peer alone, so that neither can + // report an event or answer a call of the connection that is. + if (socket !== connection) return + val element = try { + OneBotWire.parseToJsonElement(message) + } catch (e: Exception) { + failure = e + return + } + val answer = element as? JsonObject ?: return + val echo = (answer["echo"] as? JsonPrimitive)?.content + if (echo == null) { + report(OneBotEventCodec.decode(answer)) + return + } + val waiting = awaiting.remove(echo) ?: return + if (answer["status"] == null && answer["retcode"] == null) { + waiting.completeExceptionally(IllegalArgumentException("The answer names no status: $answer")) + } else { + waiting.complete(answer) + } + } + + override fun onClose(connection: WebSocket, code: Int, reason: String, remote: Boolean) { + // A refused connection and one that a newer peer replaced close too, and neither of them owes an answer + // to a call of the connection being served. Their close says nothing about that connection, so nothing + // waiting on it is ended here. + if (socket !== connection) return + socket = null + if (state.value.isOpen) lost(IllegalStateException("The connection closed: $code $reason")) + // A call that was waiting for this connection is not answered by a connection that is gone, so it is told + // so now rather than when its own timeout runs out, which would report a slow implementation instead of a + // lost one. + failWaiting(IllegalStateException("The connection closed before it answered: $code $reason")) + } + + override fun onError(connection: WebSocket?, ex: Exception) { + failure = ex + } + } + + /** Starts listening and returns once the socket is bound, whether an implementation dialed yet or not. */ + suspend fun start() { + moveTo(OneBotLinkState.CONNECTING) + server.start() + if (!bound.await(settings.connectTimeoutMillis, TimeUnit.MILLISECONDS)) { + throw IllegalStateException("The listener at ${settings.listenHost}:${server.port} did not start.") + } + // The library counts the server started from its own thread before that thread takes connections, so a + // client that dials the moment `start` returns can be answered by nobody. Let the selector take over. + delay(START_SETTLE_MILLIS.milliseconds) + moveTo(OneBotLinkState.OPEN) + } + + /** Waits for the implementation to dial in, and returns whether one did. */ + suspend fun awaitPeer(): Boolean = try { + withTimeout(settings.connectTimeoutMillis) { connected.await() } + true + } catch (e: Exception) { + false + } + + /** + * Ends every call that waits for an answer, with [reason]. + * + * A call that outlives the connection that would answer it has nothing left to wait for, so it is told why rather + * than being left until its own timeout says the implementation was slow. + */ + private fun failWaiting(reason: Exception) { + awaiting.keys.toList().forEach { echo -> awaiting.remove(echo)?.completeExceptionally(reason) } + } + + /** The port this connection listens on, which a configured port of 0 leaves to the operating system. */ + val port: Int get() = server.port + + override suspend fun send(action: String, params: JsonObject): JsonObject = send(action, params, null) + + /** + * Sends one call, waiting at most [waitMillis] for its answer where a caller asks for longer than the instance + * allows. + * + * A call that carries a file takes longer than one that only reads something, and its answer is what says whether + * it arrived. Null waits as the instance's own timeout says. + */ + suspend fun send(action: String, params: JsonObject, waitMillis: Long?): JsonObject { + val connection = socket ?: throw IllegalStateException("No implementation is connected.") + val echo = echoes.next() + val waiting = CompletableDeferred() + awaiting[echo] = waiting + try { + connection.send(OneBotWire.encodeToString(requestOf(action, params, echo))) + } catch (e: Exception) { + awaiting.remove(echo) + throw e + } + return try { + withTimeout(waitMillis ?: settings.firstByteTimeoutMillis) { waiting.await() } + } catch (e: Exception) { + awaiting.remove(echo) + throw e + } + } + + override fun stop() { + runCatching { server.stop(CLOSE_TIMEOUT_MILLIS.toInt()) } + for (waiting in awaiting.values) { + waiting.completeExceptionally(IllegalStateException("The connection is closing.")) + } + awaiting.clear() + } + + private val echoes = Echo() + + internal companion object { + const val CLOSE_TIMEOUT_MILLIS = 1_000L + + /** The headers this listener reads, which the library answers only for the ones it holds. */ + val HEADERS = listOf(OneBotAuth.AUTHORIZATION, OneBotAuth.SELF_ID, OneBotAuth.CLIENT_ROLE) + + /** The `X-Client-Role` values one connection of this transport can carry the calls and the events both under. */ + val SERVED_ROLES = listOf("Universal", "Service") + + /** How long the listener gives the library to take over the socket it bound. */ + const val START_SETTLE_MILLIS = 150L + + /** The code a connection is closed under when this instance does not serve it. */ + const val REFUSAL_CODE = 1008 + + /** The code a connection is closed under when a newer peer takes over the instance. */ + const val REPLACED_CODE = 1000 + } +} + +/** + * Why the role of a connection is refused, null when an instance can serve it. + * + * One connection of this transport carries the calls and the events both, which is the standard's universal + * client, so that is the role an instance serves. A connection of `Event` carries no answers to its calls and one + * of `API` sends none of its own, and taking either would leave the instance talking to itself. An implementation + * that names no role keeps working, because older ones report none. + */ +internal fun refusedClientRole(role: String?): String? { + val served = listOf("Universal", "Service") + if (role == null || role in served) return null + return "The connection is a $role client, but this instance serves ${served.joinToString()}; " + + "configure a ws_reverse instance per client role instead." +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotSettings.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotSettings.kt new file mode 100644 index 00000000..a55579a7 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotSettings.kt @@ -0,0 +1,48 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import org.foedusprogramme.alexandrite.channel.onebot.auth.Value +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotConfig +import org.foedusprogramme.alexandrite.channel.onebot.config.OneBotInstanceConfig + +/** + * What one transport of one instance needs, gathered from the plugin's settings and the instance's own. + * + * A transport takes this rather than the two config classes, so that it never reads a setting its own instance + * cannot hold. + */ +internal class OneBotSettings( + /** The URL of the transport that calls its implementation, null for the ones that listen. */ + val endpoint: String?, + val listenHost: String, + val listenPort: Int, + val path: String, + val token: Value?, + val secret: Value?, + val selfId: String?, + val connectTimeoutMillis: Long, + val firstByteTimeoutMillis: Long, + val idleTimeoutMillis: Long, + val reconnectIntervalMillis: Long, + val eventCapacity: Int, +) { + /** The endpoint of a transport that calls its implementation, which one that listens does not hold. */ + fun requireEndpoint(): String = requireNotNull(endpoint) { "This instance listens for its implementation." } + + companion object { + /** The settings of [instance] below [plugin]. */ + fun of(plugin: OneBotConfig, instance: OneBotInstanceConfig): OneBotSettings = OneBotSettings( + endpoint = instance.endpoint, + listenHost = instance.listenHost, + listenPort = instance.listenPort, + path = instance.path, + token = Value.orNull(instance.accessToken), + secret = Value.orNull(instance.secret), + selfId = instance.selfId, + connectTimeoutMillis = instance.connectTimeoutMillis, + firstByteTimeoutMillis = instance.firstByteTimeoutMillis, + idleTimeoutMillis = instance.idleTimeoutMillis, + reconnectIntervalMillis = instance.reconnectIntervalMillis, + eventCapacity = plugin.eventQueueCapacity, + ) + } +} diff --git a/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotWire.kt b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotWire.kt new file mode 100644 index 00000000..e7b823e5 --- /dev/null +++ b/libraries/channels/onebot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotWire.kt @@ -0,0 +1,33 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive + +/** + * The JSON of the OneBot wire. + * + * The standard says nothing about repeated keys or unknown ones, and an implementation adds fields of its own, so + * reading ignores what this version does not know and writing keeps the defaults out of the request. + */ +internal val OneBotWire: Json = Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + encodeDefaults = false +} + +/** The `echo` a call is answered under, which an implementation returns as it is. */ +internal fun echoOf(element: JsonElement?): String? = + (element as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull + +/** The text of [key] in this object, null when it holds none. */ +internal fun JsonObject.text(key: String): String? = + (this[key] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull + +/** The long of [key] in this object, null when it holds no number. */ +internal fun JsonObject.long(key: String): Long? = text(key)?.toLongOrNull() diff --git a/libraries/channels/onebot/src/main/resources/alexandrite/i18n/alexandrite-channel-onebot/en.properties b/libraries/channels/onebot/src/main/resources/alexandrite/i18n/alexandrite-channel-onebot/en.properties new file mode 100644 index 00000000..e8520ab7 --- /dev/null +++ b/libraries/channels/onebot/src/main/resources/alexandrite/i18n/alexandrite-channel-onebot/en.properties @@ -0,0 +1,85 @@ +approval.title=Approval needed (#{id}) +approval.agent=Agent: {agent} +approval.chat=Chat: {chat} +approval.requester=Requested by: {requester} +approval.reason=Why: {reason} +approval.tool=Tool: {tool} ({risk}) +approval.file=File: {path} +approval.directory=Directory: {directory} +approval.program=Program: {program} +approval.program_unknown=not found +approval.opaque=Opaque: Alexandrite cannot see what this program runs. +approval.arguments=Arguments: +approval.answer=Reply to this message with /approve or /deny. +approval.always_rule=Allowing it for good grants: {rule} +approval.outcome_allowed=Approval {id} is allowed by {name}. +approval.outcome_allowed_always=Approval {id} is allowed for good by {name}, which also grants {rule}. +approval.outcome_denied=Approval {id} is denied by {name}. +approval.outcome_expired=Approval {id} expired, so nobody answered it. +approval.outcome_withdrawn=Approval {id} was withdrawn, so it no longer waits for an answer. +approval.admins_only=Only an admin of this bot can answer. +approval.not_pending=This request no longer waits for an answer. +approval.nothing_waits=No approval request waits in this chat. +approval.list_title=Approval requests waiting here: +approval.list_entry=- {id} {tool} {reason} +approval.list_hint=Reply to the message of one with /approve or /deny. +approval.several=That names no one request: {ids}. Write the whole id of the one meant. +refused.admin=Only an admin of this bot may use /{command}. +refused.admin_here=Only an admin of this bot may use /{command} in this chat. +pairing.code=This chat is not paired with this bot yet. To pair it, give the bot's operator the code {code}, which is valid for {minutes} minutes. +activity.verb.read.present=Reading +activity.verb.read.past=Read +activity.verb.list.present=Listing +activity.verb.list.past=Listed +activity.verb.search.present=Running +activity.verb.search.past=Ran +activity.verb.edit.present=Editing +activity.verb.edit.past=Edited +activity.verb.delete.present=Deleting +activity.verb.delete.past=Deleted +activity.verb.move.present=Moving +activity.verb.move.past=Moved +activity.verb.execute.present=Running +activity.verb.execute.past=Ran +activity.verb.fetch.present=Fetching +activity.verb.fetch.past=Fetched +activity.verb.think.present=Thinking +activity.verb.think.past=Thought +activity.verb.other.present=Using +activity.verb.other.past=Used +activity.noun.read.one={count} file +activity.noun.read.many={count} files +activity.noun.list.one={count} directory +activity.noun.list.many={count} directories +activity.noun.search.one={count} search +activity.noun.search.many={count} searches +activity.noun.edit.one={count} file +activity.noun.edit.many={count} files +activity.noun.delete.one={count} file +activity.noun.delete.many={count} files +activity.noun.move.one={count} file +activity.noun.move.many={count} files +activity.noun.execute.one={count} shell command +activity.noun.execute.many={count} shell commands +activity.noun.fetch.one={count} page +activity.noun.fetch.many={count} pages +activity.noun.think.one={count} step +activity.noun.think.many={count} steps +activity.noun.other.one={count} tool +activity.noun.other.many={count} tools +activity.verb.send.present=Sending +activity.verb.send.past=Sent +activity.noun.send.one={count} file +activity.noun.send.many={count} files +activity.summary.not_run=Not run: {noun} +activity.omitted.one={count} earlier call not shown +activity.omitted.many={count} earlier calls not shown +activity.call.waiting_approval=Waiting for approval: {tool} {title} +activity.call.not_run=Not run: {tool} {title} +activity.call.backgrounded=Moved to the background: {tool} {title} +activity.call.exit=exit {code} +activity.call.failed=failed +activity.call.diff=+{added} -{removed} +activity.delegate.present=Delegating to {agent} +activity.delegate.past=Delegated to {agent} +activity.call.by_agent={agent} · {line} \ No newline at end of file diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt new file mode 100644 index 00000000..efca9b83 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotApprovalIntegrationTest.kt @@ -0,0 +1,359 @@ +package org.foedusprogramme.alexandrite.channel.onebot + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalAnswer +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalRequest +import org.foedusprogramme.alexandrite.sdk.channel.Channel +import org.foedusprogramme.alexandrite.sdk.channel.ChannelDirectory +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.chat.TurnInfo +import org.foedusprogramme.alexandrite.sdk.chat.UserAddress +import org.foedusprogramme.alexandrite.sdk.di.key +import org.foedusprogramme.alexandrite.sdk.tool.ToolRisk +import org.foedusprogramme.alexandrite.testkit.PluginHarness +import org.foedusprogramme.alexandrite.testkit.PluginHarness.Running +import org.foedusprogramme.alexandrite.testkit.RecordingTurnSubmitter +import org.foedusprogramme.alexandrite.testkit.testTurn +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Approvals inside a real runtime. + * + * A peer built from the library serves the implementation, and the test drives what an admin would do: the approval is + * shown as a message, and answering it means replying to that message or writing the id of the request. + */ +class OneBotApprovalIntegrationTest { + private val instance = ChannelInstanceId(ChannelType("onebot"), "main") + + private val directory = key() + private val group = ChatAddress(instance, "group:987654") + + @Test + fun `an approval is answered by replying to the message that carried it`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + val asking = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("k3x9")) } + val carried = waiting { peer.awaitSent(1) }.last().id + peer.report(command(replyTo = carried, text = "/approve")) + val answer = assertIs(asking.await()) + assertTrue(answer.by.isAdmin, "the answer did not name an admin") + } + } + } finally { + peer.stop() + } + } + + @Test + fun `an approval the platform refuses once is still shown`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + peer.refuseNext() + val asking = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("retry1")) } + val carried = waiting { peer.awaitSent(1) }.last().id + peer.report(command(replyTo = carried, text = "/approve")) + assertIs(asking.await()) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `an approval no chat takes is answered with what the platform said`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + peer.refuseNext(times = 3) + val asking = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("retry2")) } + val answer = assertIs(asking.await()) + assertTrue( + answer.reason.contains("the platform took no message of it"), + "the reason did not name the platform: ${answer.reason}", + ) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `two requests waiting in one chat are told apart by the message each came in`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + val first = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("aaa111")) } + val second = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("bbb222")) } + val sent = waiting { peer.awaitSent(2) } + val carriedBySecond = sent.last().id + peer.report(command(replyTo = carriedBySecond, text = "/approve")) + assertIs(second.await()) + assertTrue(!first.isCompleted, "answering one request answered the other") + peer.report(command(replyTo = sent.first().id, text = "/deny")) + assertIs(first.await()) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `an id that names several requests answers none of them`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + val first = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("aaa111")) } + val second = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("aaa222")) } + waiting { peer.awaitSent(2) } + peer.report(command(text = "/approve aaa")) + val said = waiting { peer.awaitSent(3) }.last().params + assertTrue( + said.contains("names no one request"), + "an ambiguous id was not refused: $said", + ) + assertTrue(!first.isCompleted && !second.isCompleted, "an ambiguous id answered a request") + // The whole id answers the one it names. + peer.report(command(text = "/approve aaa222")) + assertIs(second.await()) + peer.report(command(text = "/deny aaa111")) + assertIs(first.await()) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `somebody who is no admin of the instance answers nothing`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + val asking = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("k3x9")) } + val carried = waiting { peer.awaitSent(1) }.last().id + peer.report(command(replyTo = carried, text = "/approve", from = "99999999", role = "member")) + val said = waiting { peer.awaitSent(2) }.last().params + assertTrue( + said.contains("Only an admin"), + "somebody who is no admin was answered: $said", + ) + assertTrue(!asking.isCompleted, "somebody who is no admin answered a request") + peer.report(command(replyTo = carried, text = "/approve")) + assertIs(asking.await()) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `an answer that is taken is said once`() { + val peer = ApprovalPeer() + val port = peer.startAndPort() + try { + runBlocking { + harness(peer, port) { + val asking = async(start = CoroutineStart.UNDISPATCHED) { front().askApproval(request("k3x9")) } + val carried = waiting { peer.awaitSent(1) }.last().id + peer.report(command(replyTo = carried, text = "/approve")) + assertIs(asking.await()) + // The request, and the line that says how it ended. The answer is not said a second time in the + // chat the command was written in, which is what an earlier version of this did. + val sent = waiting { peer.awaitQuiet() } + assertEquals(2, sent.size, "an answer that was taken was said more than once: $sent") + assertTrue( + sent.last().params.contains("is allowed by"), + "the outcome of the answer was not said: ${sent.last().params}", + ) + } + } + } finally { + peer.stop() + } + } + + /** Runs [block] with the plugin built over the peer on [port]. */ + private suspend fun harness(peer: ApprovalPeer, port: Int, block: suspend Running.() -> Unit) { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(RecordingTurnSubmitter()) + .build() + .run { + assertTrue(waiting { peer.awaitConnection() }, "the channel did not connect to the implementation") + block() + } + } + + /** Waits for [block], which blocks its thread, without holding the one the test runs on. */ + private suspend fun waiting(block: () -> T): T = withContext(Dispatchers.IO) { block() } + + /** The channel of the instance under test, which is not the recording channel of the harness. */ + private suspend fun Running.front(): Channel = + get(directory).channel(instance) ?: error("the instance has no channel") + + /** An approval request of [id] that the user of the group asks for. */ + private fun request(id: String): ApprovalRequest = ApprovalRequest( + id = id, + turn = testTurn(chat = group) { actor(admin) }, + tool = "fs.write", + risk = ToolRisk.WORKSPACE_WRITE, + arguments = """{"path":"notes.md"}""", + preview = null, + reason = "the chat is in ask mode", + always = null, + ) + + /** A report of the group that answers the message [replyTo] with [text], sent by [from]. */ + private fun command(replyTo: String? = null, text: String, from: String = USER, role: String = "admin"): String = + buildString { + append("""{"time":1515204254,"self_id":10001000,"post_type":"message","message_type":"group",""") + append( + """"sub_type":"normal","message_id":${nextReport.incrementAndGet()},"group_id":987654,"user_id":$from,"message":[""", + ) + replyTo?.let { append("""{"type":"reply","data":{"id":"$it"}},""") } + append("""{"type":"text","data":{"text":"$text"}}],""") + append(""""raw_message":"$text",""") + append(""""sender":{"user_id":$from,"nickname":"someone","role":"$role"}}""") + } + + private val nextReport = AtomicInteger(5000) + + private val admin: ChatUser = ChatUser(UserAddress(instance, USER), "someone", null, isBot = false, isAdmin = true) + + private fun config(port: Int): String = """ + { + "settings": {"eventQueueCapacity": 64}, + "instances": { + "main": { + "transport": "ws", + "endpoint": "ws://127.0.0.1:$port", + "connectTimeoutMillis": 5000, + "firstByteTimeoutMillis": 5000, + "idleTimeoutMillis": 5000, + "reconnectIntervalMillis": 200, + "allowedChats": ["group:987654", "private:12345678"], + "admins": ["$USER"], + "approvalMinutes": 1 + } + } + } + """.trimIndent() + + private companion object { + const val USER = "12345678" + } + + /** An implementation that serves the API, numbering the messages it is told to send, and reports what the test says. */ + private class ApprovalPeer : WebSocketServer(InetSocketAddress("127.0.0.1", 0)) { + private val started = CountDownLatch(1) + private val connected = CountDownLatch(1) + private val counter = AtomicInteger(100) + private val sends = CopyOnWriteArrayList() + private val refusals = AtomicInteger(0) + + @Volatile + private var socket: WebSocket? = null + + override fun onStart() { + started.countDown() + } + + override fun onOpen(connection: WebSocket, handshake: ClientHandshake) { + socket = connection + connected.countDown() + } + + override fun onMessage(connection: WebSocket, message: String) { + val echo = message.substringAfter("\"echo\":\"", "").substringBefore('"') + if (echo.isEmpty()) return + // An answer carries the message id of what was sent, which is what the test answers a request by. + val sending = !message.contains("\"get_login_info\"") && !message.contains("\"get_msg\"") + val id = if (sending) "${counter.incrementAndGet()}" else "${counter.get()}" + if (sending && refusals.getAndUpdate { if (it > 0) it - 1 else 0 } > 0) { + // A message the platform refuses is not one it took, so it is not among those a test waits for. + connection.send("""{"status":"failed","retcode":1,"data":null,"echo":"$echo"}""") + return + } + if (sending) sends += Sent(id, message) + connection.send("""{"status":"ok","retcode":0,"data":{"message_id":$id},"echo":"$echo"}""") + } + + override fun onClose(connection: WebSocket, code: Int, reason: String, remote: Boolean) { + if (socket === connection) socket = null + } + + override fun onError(connection: WebSocket?, ex: Exception) = Unit + + fun startAndPort(): Int { + start() + check(started.await(5, TimeUnit.SECONDS)) { "the peer did not start" } + return port + } + + fun report(json: String) { + val connection = socket ?: error("nothing is connected") + connection.send(json) + } + + /** Makes the next [times] messages the peer takes be refused, as a platform that will not take them does. */ + fun refuseNext(times: Int = 1) { + refusals.set(times) + } + + fun awaitConnection(): Boolean = connected.await(5, TimeUnit.SECONDS) + + /** Waits until at least [count] messages were sent, and returns everything sent so far. */ + fun awaitSent(count: Int): List { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + if (sends.size >= count) return sends.toList() + Thread.sleep(20) + } + error("only ${sends.size} messages were sent, wanted $count: $sends") + } + + /** Waits a moment for anything else to arrive, and returns everything that was sent. */ + fun awaitQuiet(millis: Long = 600): List { + Thread.sleep(millis) + return sends.toList() + } + + class Sent(val id: String, val params: String) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotChannelIntegrationTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotChannelIntegrationTest.kt new file mode 100644 index 00000000..1852372e --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotChannelIntegrationTest.kt @@ -0,0 +1,631 @@ +package org.foedusprogramme.alexandrite.channel.onebot + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.sdk.channel.Channel +import org.foedusprogramme.alexandrite.sdk.channel.ChannelDirectory +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundFile +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.channel.ReplyRequest +import org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus +import org.foedusprogramme.alexandrite.sdk.channel.TurnEvent +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId +import org.foedusprogramme.alexandrite.sdk.di.key +import org.foedusprogramme.alexandrite.sdk.tool.ToolActivity +import org.foedusprogramme.alexandrite.sdk.turn.Submission +import org.foedusprogramme.alexandrite.testkit.PluginHarness +import org.foedusprogramme.alexandrite.testkit.PluginHarness.Running +import org.foedusprogramme.alexandrite.testkit.RecordingTurnSubmitter +import org.foedusprogramme.alexandrite.testkit.testTurn +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The OneBot channel inside a real runtime. + * + * A harness builds the plugin set and the container, a peer built from the library serves the implementation, and the + * test drives what a user would do: the implementation reports a message, the agent is asked for a turn, and the + * answer the agent writes goes back out through the actions of the standard. + */ +class OneBotChannelIntegrationTest { + private val instance = ChannelInstanceId(ChannelType("onebot"), "main") + + private val directory = key() + + @Test + fun `a reported message reaches the agent and the answer goes back to the implementation`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection(), "the channel did not connect to the implementation") + peer.report(privateMessage()) + val submission = awaitSubmission(submitter) + val message = assertIs(submission) + assertEquals("private:12345678", message.message.chat.chat) + assertEquals("hello there", message.message.text) + + val channel = org.foedusprogramme.alexandrite.sdk.channel.ChannelDirectory::class.java + val delivery = sendTo(message.message.chat) + assertEquals("7", assertIs(delivery).messages.single().id) + val call = peer.awaitCall("send_private_msg") + assertTrue(call.contains("hello back"), "the implementation was not sent the reply: $call") + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a group message that names nobody reaches no agent`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection(), "the channel did not connect to the implementation") + peer.report(groupMessage(text = "hello there")) + assertTrue( + awaitNoSubmission(submitter), + "the agent was asked for a turn of a message that names nobody", + ) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a group message that only names the bot reaches no agent`() { + // The Telegram channel ignores a message that is only a mention of the bot, and this channel now does too: + // naming the bot with nothing to answer is not a message the agent is asked a turn of. + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection(), "the channel did not connect to the implementation") + peer.report(groupMessage(text = "", at = "10001000")) + assertTrue( + awaitNoSubmission(submitter), + "the agent was asked for a turn of a message that only names the bot", + ) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a group message that is only a slash and words reaches no agent`() { + // The other channel hears a command only when the platform marks one, so a message whose slash does not start + // a command name is a message like any other: in a group it reaches the bot only when it is addressed to it. + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection(), "the channel did not connect to the implementation") + peer.report(groupMessage(text = "/随便说点什么")) + assertTrue( + awaitNoSubmission(submitter), + "the agent was asked for a turn of a slash message that names no command", + ) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a group message that names the bot reaches the agent`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection(), "the channel did not connect to the implementation") + peer.report(groupMessage(text = "hello there", at = "10001000")) + val message = assertIs(awaitSubmission(submitter)) + assertEquals("hello there", message.message.text) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a command in a group reaches the agent as a command without naming the bot`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection(), "the channel did not connect to the implementation") + peer.report(groupMessage(text = "/model deepseek/one")) + val command = assertIs(awaitSubmission(submitter)) + assertEquals("model", command.invocation.name) + assertEquals("deepseek/one", command.invocation.arguments) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a group message answering a message the bot sent reaches the agent`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection(), "the channel did not connect to the implementation") + val group = ChatAddress(instance, "group:987654") + assertIs(sendTo(group)) + peer.report(groupMessage(text = "hello there", reply = "8")) + assertTrue( + awaitNoSubmission(submitter), + "the agent was asked for a turn of a message answering somebody else", + ) + peer.report(groupMessage(text = "hello there", reply = "7")) + val message = assertIs(awaitSubmission(submitter)) + assertEquals("hello there", message.message.text) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a chat this instance does not serve is offered its pairing code and reaches no agent`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection(), "the channel did not connect to the implementation") + peer.report(unservedGroupMessage()) + val said = peer.awaitCall("send_group_msg") + assertTrue(said.contains("not paired"), "the chat was not offered a code: $said") + assertTrue( + awaitNoSubmission(submitter), + "a message of a chat this instance does not serve reached the agent", + ) + } + } + } finally { + peer.stop() + } + } + + /** A group report of a chat that the config of the test does not declare. */ + private fun unservedGroupMessage(): String = """ + { + "time": 1515204254, + "self_id": 10001000, + "post_type": "message", + "message_type": "group", + "sub_type": "normal", + "message_id": 12, + "group_id": 111111, + "user_id": 12345678, + "message": [ + {"type": "at", "data": {"qq": 10001000}}, + {"type": "text", "data": {"text": "hello there"}} + ], + "raw_message": "hello there", + "sender": {"user_id": 12345678, "nickname": "someone", "role": "member"} + } + """.trimIndent() + + private suspend fun org.foedusprogramme.alexandrite.testkit.PluginHarness.Running.awaitSubmission( + submitter: RecordingTurnSubmitter, + ): Submission { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + submitter.submissions.firstOrNull()?.let { return it } + kotlinx.coroutines.delay(20) + } + error("the agent was asked for no turn") + } + + @Test + fun `a turn that works says so as it starts and as it ends, and not on every step`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection(), "the channel did not connect to the implementation") + peer.report(privateMessage()) + val chat = assertIs(awaitSubmission(submitter)).message.chat + val sink = front().openReply(ReplyRequest(testTurn(chat = chat), null)) + + sink.preview(0, "Let me check.") + assertEquals(0, peer.callsOf("send_private_msg"), "a preview is no message of this platform") + + sink.startSegment(1) + assertEquals(0, peer.callsOf("send_private_msg"), "a segment that ended is no message either") + + sink.event( + TurnEvent.ToolCallStarted(ToolCallId("c1"), "fs.read", ToolActivity.READ, "notes.md"), + ) + val told = peer.awaitCall("send_private_msg") + assertTrue(told.contains("Reading 1 file"), "the chat was not told of the work: $told") + + sink.event( + TurnEvent.ToolCallStarted(ToolCallId("c2"), "fs.search", ToolActivity.SEARCH, "milk"), + ) + sink.event(TurnEvent.ToolCallUpdated(ToolCallId("c2"), ToolCallStatus.COMPLETED)) + sink.preview(1, "It ran.") + assertEquals(1, peer.callsOf("send_private_msg"), "the words of a response are not a preview") + + sink.complete(OutboundMessage.builder("done", MessageKind.REPLY).build()) + val last = peer.awaitCall("send_private_msg") + assertTrue(last.contains("done"), "the answer did not go out: $last") + assertEquals( + 3, + peer.callsOf("send_private_msg"), + "the chat is told as the work starts, told what ended before the answer, then answered", + ) + } + } + } finally { + peer.stop() + } + } + + /** The channel of the instance under test. */ + private suspend fun Running.front(): Channel = + get(directory).channel(instance) ?: error("the instance has no channel") + + @Test + fun `what the channel sends and drops is logged`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + val lines = runBlocking { + logged { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection()) + peer.report(privateMessage()) + val chat = assertIs(awaitSubmission(submitter)).message.chat + val sink = front().openReply(ReplyRequest(testTurn(chat = chat), null)) + sink.preview(0, "Let me check.") + sink.startSegment(1) + sink.complete(OutboundMessage.builder("done", MessageKind.REPLY).build()) + } + } + } + + assertTrue(lines.any { it.contains("Sent ") }) + assertTrue(lines.any { it.contains("segment 0") }) + assertTrue(lines.any { it.contains("becomes a turn") }) + } finally { + peer.stop() + } + } + + @Test + fun `two replies of one conversation tell a chat of its first call once`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection(), "the channel did not connect to the implementation") + peer.report(privateMessage()) + val chat = assertIs(awaitSubmission(submitter)).message.chat + val turn = testTurn(chat = chat) + val first = front().openReply(ReplyRequest(turn, null)) + val second = front().openReply(ReplyRequest(turn, null)) + + first.event( + TurnEvent.ToolCallStarted(ToolCallId("c1"), "fs.read", ToolActivity.READ, "notes.md"), + ) + val told = peer.awaitCall("send_private_msg") + assertTrue(told.contains("Reading 1 file"), "the first call of the conversation: $told") + + second.event( + TurnEvent.ToolCallStarted(ToolCallId("c2"), "fs.list", ToolActivity.LIST, "src"), + ) + assertEquals( + 1, + peer.callsOf("send_private_msg"), + "a chat hears the first call of its conversation once, however many replies it has", + ) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a message that carries a file goes as the file alone`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + val bytes = byteArrayOf(1, 2, 3) + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection(), "the channel did not connect to the implementation") + peer.report(privateMessage()) + val chat = assertIs(awaitSubmission(submitter)).message.chat + val file = OutboundFile.builder("notes.txt", "text/plain", bytes).build() + val message = + OutboundMessage.builder("here it is", MessageKind.REPLY).files(listOf(file)).build() + + front().send(chat, message) + + val call = peer.awaitCall("upload_private_file") + assertTrue( + call.contains("base64://" + java.util.Base64.getEncoder().encodeToString(bytes)), + "the bytes go to the implementation: $call", + ) + assertTrue(call.contains("notes.txt"), "the file keeps its name: $call") + assertEquals( + 0, + peer.callsOf("send_private_msg"), + "this platform shows the file's own name, so no text is sent beside it", + ) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a message the platform refuses is logged as one that was not sent`() { + val peer = ImplementationPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + val lines = runBlocking { + logged { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(peer.awaitConnection(), "the channel did not connect to the implementation") + peer.report(privateMessage()) + val chat = assertIs(awaitSubmission(submitter)).message.chat + peer.failNext("send_private_msg") + val delivery = front().send( + chat, + OutboundMessage.builder("hi", MessageKind.REPLY).build(), + ) + assertIs(delivery, "the platform refused the message") + } + } + } + assertTrue(lines.any { it.contains("Could not send") }, "the refusal was not logged: $lines") + assertTrue(lines.none { it.contains("Sent REPLY") }, "a refused message was logged as sent: $lines") + } finally { + peer.stop() + } + } + + private suspend fun Running.sendTo(chat: ChatAddress): Delivery { + val front = get(directory).channel(instance) ?: error("the instance has no channel") + val message = OutboundMessage.builder("hello back", MessageKind.REPLY).build() + return front.send(chat, message) + } + + private fun config(port: Int): String = """ + { + "settings": {"eventQueueCapacity": 64}, + "instances": { + "main": { + "transport": "ws", + "endpoint": "ws://127.0.0.1:$port", + "connectTimeoutMillis": 5000, + "firstByteTimeoutMillis": 5000, + "idleTimeoutMillis": 5000, + "reconnectIntervalMillis": 200, + "allowedChats": ["group:987654", "private:12345678"] + } + } + } + """.trimIndent() + + private fun privateMessage(): String = """ + { + "time": 1515204254, + "self_id": 10001000, + "post_type": "message", + "message_type": "private", + "sub_type": "friend", + "message_id": 12, + "user_id": 12345678, + "message": "hello there", + "raw_message": "hello there", + "sender": {"user_id": 12345678, "nickname": "someone"} + } + """.trimIndent() + + /** A group report of [text], whose message names [at] or answers [reply] when they are given. */ + private fun groupMessage(text: String, at: String? = null, reply: String? = null): String = buildString { + append("""{"time":1515204254,"self_id":10001000,"post_type":"message","message_type":"group",""") + append(""""sub_type":"normal","message_id":12,"group_id":987654,"user_id":12345678,"message":[""") + at?.let { append("""{"type":"at","data":{"qq":$it}},""") } + reply?.let { append("""{"type":"reply","data":{"id":$it}},""") } + append("""{"type":"text","data":{"text":"$text"}}],""") + append(""""raw_message":"$text",""") + append(""""sender":{"user_id":12345678,"nickname":"someone","role":"member"}}""") + } + + /** Whether the agent was asked for no turn within the time one would arrive in. */ + private suspend fun awaitNoSubmission(submitter: RecordingTurnSubmitter): Boolean { + val deadline = System.nanoTime() + 500_000_000L + while (System.nanoTime() < deadline) { + if (submitter.submissions.isNotEmpty()) return false + kotlinx.coroutines.delay(20) + } + return true + } + + /** An implementation that serves the API and reports what the test tells it to. */ + private class ImplementationPeer : WebSocketServer(InetSocketAddress("127.0.0.1", 0)) { + private val started = CountDownLatch(1) + private val connected = CountDownLatch(1) + private val calls = CopyOnWriteArrayList() + private val callWaiters = CopyOnWriteArrayList>() + + @Volatile + private var socket: WebSocket? = null + + override fun onStart() { + started.countDown() + } + + override fun onOpen(connection: WebSocket, handshake: ClientHandshake) { + socket = connection + connected.countDown() + } + + override fun onMessage(connection: WebSocket, message: String) { + calls += message + val echo = message.substringAfter("\"echo\":\"", "").substringBefore('"') + if (echo.isEmpty()) return + val refused = failing.firstOrNull { message.contains("\"$it\"") }?.also { failing.remove(it) } + if (refused != null) { + connection.send("""{"status":"failed","retcode":1,"data":null,"echo":"$echo"}""") + return + } + connection.send( + """{"status":"ok","retcode":0,"data":{"message_id":7},"echo":"$echo"}""", + ) + } + + override fun onClose(connection: WebSocket, code: Int, reason: String, remote: Boolean) { + if (socket === connection) socket = null + } + + override fun onError(connection: WebSocket?, ex: Exception) = Unit + + /** Starts listening and returns the port this peer took. */ + fun startAndPort(): Int { + start() + check(started.await(5, TimeUnit.SECONDS)) { "the peer did not start" } + return port + } + + /** Reports [json] as an event of the implementation. */ + fun report(json: String) { + val connection = socket ?: error("nothing is connected") + connection.send(json) + } + + /** The actions whose next call the peer answers with a failure, which a test sets to make one fail. */ + private val failing = CopyOnWriteArrayList() + + /** Answers the next call of [action] with a failure, once. */ + fun failNext(action: String) { + failing += action + } + + /** Waits for the channel to connect and returns whether it did. */ + fun awaitConnection(): Boolean = connected.await(5, TimeUnit.SECONDS) + + /** How many calls of [action] the implementation was sent. */ + fun callsOf(action: String): Int = calls.count { it.contains("\"$action\"") } + + /** Waits for a call of [action] and returns it, whether it arrived before or after this call. */ + fun awaitCall(action: String): String { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + calls.lastOrNull { it.contains("\"$action\"") }?.let { return it } + Thread.sleep(20) + } + error("no call of $action arrived, saw $calls") + } + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotLogs.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotLogs.kt new file mode 100644 index 00000000..b8b5f10f --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotLogs.kt @@ -0,0 +1,28 @@ +package org.foedusprogramme.alexandrite.channel.onebot + +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.AppenderBase +import org.slf4j.LoggerFactory +import java.util.concurrent.CopyOnWriteArrayList + +/** The lines logged while [block] ran, as `LEVEL message`. */ +internal suspend fun logged(block: suspend () -> Unit): List { + val logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME) as Logger + val recorder = LineRecorder().apply { start() } + logger.addAppender(recorder) + try { + block() + } finally { + logger.detachAppender(recorder) + } + return recorder.lines.toList() +} + +private class LineRecorder : AppenderBase() { + val lines = CopyOnWriteArrayList() + + override fun append(event: ILoggingEvent) { + lines += "${event.level} ${event.formattedMessage}" + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotMediaIntegrationTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotMediaIntegrationTest.kt new file mode 100644 index 00000000..8bdbcf81 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotMediaIntegrationTest.kt @@ -0,0 +1,231 @@ +package org.foedusprogramme.alexandrite.channel.onebot + +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.foedusprogramme.alexandrite.sdk.channel.ChannelDirectory +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.di.key +import org.foedusprogramme.alexandrite.sdk.turn.Submission +import org.foedusprogramme.alexandrite.testkit.PluginHarness +import org.foedusprogramme.alexandrite.testkit.PluginHarness.Running +import org.foedusprogramme.alexandrite.testkit.RecordingTurnSubmitter +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * What a message carrying a picture gives the agent. + * + * A report names the picture by an address, and the bytes behind that address are what the model is shown. A reader + * that cannot get them says so rather than handing the turn an empty file. + */ +class OneBotMediaIntegrationTest { + private val instance = ChannelInstanceId(ChannelType("onebot"), "main") + + private val directory = key() + private val picture = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8) + + @Test + fun `a picture a report names is read from the address it names`() { + withPicture { url, peer, submitter -> + peer.report(pictureMessage(url)) + val message = assertIs(waiting { awaitSubmission(submitter) }).message + val attachment = message.media.single() + assertEquals("image/*", attachment.mediaType) + assertContentEquals(picture, waiting { runBlocking { attachment.content.read(1024) } }) + } + } + + @Test + fun `a picture larger than the agent allows is refused`() { + withPicture { url, peer, submitter -> + peer.report(pictureMessage(url)) + val message = assertIs(waiting { awaitSubmission(submitter) }).message + val attachment = message.media.single() + val failure = assertFailsWith { waiting { runBlocking { attachment.content.read(4) } } } + assertTrue( + failure.message.orEmpty().contains("larger"), + "a body larger than the agent allows was not refused: ${failure.message}", + ) + } + } + + @Test + fun `a picture in the message that was quoted reaches the agent as well`() { + withPicture { url, peer, submitter -> + peer.report(pictureMessage(url)) + awaitSubmissions(submitter, 1) + peer.report(questionAbout(12)) + val message = assertIs(awaitSubmissions(submitter, 2).last()).message + // The report answers a message holding a picture and says nothing about that picture, so the picture + // reaches the turn only because what was read of the quoted message is kept beside its text. + val attachment = message.media.single() + assertEquals("image/*", attachment.mediaType) + assertContentEquals(picture, waiting { runBlocking { attachment.content.read(1024) } }) + } + } + + /** A group report that answers the message [id] and names the bot. */ + private fun questionAbout(id: Long): String = """ + { + "time": 1515204255, + "self_id": 10001000, + "post_type": "message", + "message_type": "group", + "sub_type": "normal", + "message_id": 13, + "group_id": 987654, + "user_id": 12345678, + "message": [ + {"type": "reply", "data": {"id": $id}}, + {"type": "at", "data": {"qq": 10001000}}, + {"type": "text", "data": {"text": "what is this"}} + ], + "raw_message": "[CQ:reply,id=$id] what is this", + "sender": {"user_id": 12345678, "nickname": "someone", "role": "member"} + } + """.trimIndent() + + /** The first [count] turns the agent was asked for. */ + private suspend fun awaitSubmissions(submitter: RecordingTurnSubmitter, count: Int): List { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + val taken = submitter.submissions + if (taken.size >= count) return taken + Thread.sleep(20) + } + error("the agent was asked for ${submitter.submissions.size} turns, not $count") + } + + /** Serves [picture] over HTTP, runs the plugin against [peer], and calls [block]. */ + private fun withPicture(block: suspend Running.(String, MediaPeer, RecordingTurnSubmitter) -> Unit) { + val http = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + http.createContext("/picture") { exchange -> + exchange.sendResponseHeaders(200, picture.size.toLong()) + exchange.responseBody.use { it.write(picture) } + } + http.start() + val url = "http://127.0.0.1:${http.address.port}/picture" + val peer = MediaPeer() + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(waiting { peer.awaitConnection() }, "the channel did not connect") + block(url, peer, submitter) + } + } + } finally { + peer.stop() + http.stop(0) + } + } + + private suspend fun waiting(block: () -> T): T = withContext(Dispatchers.IO) { block() } + + private fun awaitSubmission(submitter: RecordingTurnSubmitter): Submission { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + submitter.submissions.firstOrNull()?.let { return it } + Thread.sleep(20) + } + error("the agent was asked for no turn") + } + + /** A group report that names the bot and carries a picture at [url]. */ + private fun pictureMessage(url: String): String = """ + { + "time": 1515204254, + "self_id": 10001000, + "post_type": "message", + "message_type": "group", + "sub_type": "normal", + "message_id": 12, + "group_id": 987654, + "user_id": 12345678, + "message": [ + {"type": "at", "data": {"qq": 10001000}}, + {"type": "image", "data": {"url": "$url", "file": "a.jpg"}} + ], + "raw_message": "[image]", + "sender": {"user_id": 12345678, "nickname": "someone", "role": "member"} + } + """.trimIndent() + + private fun config(port: Int): String = """ + { + "settings": {"eventQueueCapacity": 64}, + "instances": { + "main": { + "transport": "ws", + "endpoint": "ws://127.0.0.1:$port", + "connectTimeoutMillis": 5000, + "firstByteTimeoutMillis": 5000, + "idleTimeoutMillis": 5000, + "reconnectIntervalMillis": 200, + "allowedChats": ["group:987654"] + } + } + } + """.trimIndent() + + /** An implementation that serves the API and reports what the test says. */ + private class MediaPeer : WebSocketServer(InetSocketAddress("127.0.0.1", 0)) { + private val started = CountDownLatch(1) + private val connected = CountDownLatch(1) + + @Volatile + private var socket: WebSocket? = null + + override fun onStart() { + started.countDown() + } + + override fun onOpen(connection: WebSocket, handshake: ClientHandshake) { + socket = connection + connected.countDown() + } + + override fun onMessage(connection: WebSocket, message: String) { + val echo = message.substringAfter("\"echo\":\"", "").substringBefore('"') + if (echo.isEmpty()) return + connection.send("""{"status":"ok","retcode":0,"data":{"message_id":7},"echo":"$echo"}""") + } + + override fun onClose(connection: WebSocket, code: Int, reason: String, remote: Boolean) { + if (socket === connection) socket = null + } + + override fun onError(connection: WebSocket?, ex: Exception) = Unit + + fun startAndPort(): Int { + start() + check(started.await(5, TimeUnit.SECONDS)) { "the peer did not start" } + return port + } + + fun report(json: String) { + val connection = socket ?: error("nothing is connected") + connection.send(json) + } + + fun awaitConnection(): Boolean = connected.await(5, TimeUnit.SECONDS) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotNoticeIntegrationTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotNoticeIntegrationTest.kt new file mode 100644 index 00000000..2469192d --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotNoticeIntegrationTest.kt @@ -0,0 +1,160 @@ +package org.foedusprogramme.alexandrite.channel.onebot + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.foedusprogramme.alexandrite.sdk.channel.ChannelNotice +import org.foedusprogramme.alexandrite.sdk.channel.ChannelNoticeKind +import org.foedusprogramme.alexandrite.sdk.channel.ChannelPoints +import org.foedusprogramme.alexandrite.sdk.hook.ObserverHook +import org.foedusprogramme.alexandrite.sdk.hook.ObserverPoint +import org.foedusprogramme.alexandrite.testkit.PluginHarness +import org.foedusprogramme.alexandrite.testkit.RecordingTurnSubmitter +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A report that is not a message reaching a plugin. + * + * Nothing in the runtime knows about a channel notice: it reaches a subscriber because the subscriber says which point + * it observes, which is what this proves by observing one. + */ +class OneBotNoticeIntegrationTest { + @Test + fun `a report that is not a message reaches a plugin that observes it`() { + val peer = NoticePeer() + val port = peer.startAndPort() + val observer = RecordingObserver() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(RecordingTurnSubmitter()) + .hook(observer) + .build() + .run { + assertTrue(waiting { peer.awaitConnection() }, "the channel did not connect") + peer.report(increase()) + val notice = assertNotNull( + waiting { observer.awaitOne() }, + "the notice reached no subscriber", + ) + assertEquals(ChannelNoticeKind.MEMBER_JOINED, notice.kind) + assertEquals("group:111111", notice.chat.chat) + assertEquals("2222", notice.target?.address?.user) + } + } + } finally { + peer.stop() + } + } + + private suspend fun waiting(block: () -> T): T = withContext(Dispatchers.IO) { block() } + + private fun assertNotNull(value: T?, message: String): T = kotlin.test.assertNotNull(value, message) + + /** + * A group that the config does not declare, so that this also fixes the decision that a notice is published for a + * chat the instance does not serve: a chat that is not served yet is where somebody asks to join. + */ + private fun increase(): String = """ + { + "time": 1515204254, + "self_id": 10001000, + "post_type": "notice", + "notice_type": "group_increase", + "sub_type": "approve", + "group_id": 111111, + "user_id": 2222, + "operator_id": 3333 + } + """.trimIndent() + + private fun config(port: Int): String = """ + { + "settings": {"eventQueueCapacity": 64}, + "instances": { + "main": { + "transport": "ws", + "endpoint": "ws://127.0.0.1:$port", + "connectTimeoutMillis": 5000, + "firstByteTimeoutMillis": 5000, + "idleTimeoutMillis": 5000, + "reconnectIntervalMillis": 200 + } + } + } + """.trimIndent() + + /** A subscriber that keeps every notice it is handed. */ + private class RecordingObserver : ObserverHook { + override val point: ObserverPoint = ChannelPoints.NOTICE + + private val seen = CopyOnWriteArrayList() + + override suspend fun observe(payload: ChannelNotice) { + seen += payload + } + + /** Waits for the first notice, and returns it, null when none came. */ + fun awaitOne(): ChannelNotice? { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + seen.firstOrNull()?.let { return it } + Thread.sleep(20) + } + return null + } + } + + /** An implementation that serves the API and reports what the test says. */ + private class NoticePeer : WebSocketServer(InetSocketAddress("127.0.0.1", 0)) { + private val started = CountDownLatch(1) + private val connected = CountDownLatch(1) + + @Volatile + private var socket: WebSocket? = null + + override fun onStart() { + started.countDown() + } + + override fun onOpen(connection: WebSocket, handshake: ClientHandshake) { + socket = connection + connected.countDown() + } + + override fun onMessage(connection: WebSocket, message: String) { + val echo = message.substringAfter("\"echo\":\"", "").substringBefore('"') + if (echo.isEmpty()) return + connection.send("""{"status":"ok","retcode":0,"data":{"message_id":7},"echo":"$echo"}""") + } + + override fun onClose(connection: WebSocket, code: Int, reason: String, remote: Boolean) { + if (socket === connection) socket = null + } + + override fun onError(connection: WebSocket?, ex: Exception) = Unit + + fun startAndPort(): Int { + start() + check(started.await(5, TimeUnit.SECONDS)) { "the peer did not start" } + return port + } + + fun report(json: String) { + val connection = socket ?: error("nothing is connected") + connection.send(json) + } + + fun awaitConnection(): Boolean = connected.await(5, TimeUnit.SECONDS) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotQuoteIntegrationTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotQuoteIntegrationTest.kt new file mode 100644 index 00000000..ade09855 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/OneBotQuoteIntegrationTest.kt @@ -0,0 +1,258 @@ +package org.foedusprogramme.alexandrite.channel.onebot + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import org.foedusprogramme.alexandrite.sdk.channel.ChannelDirectory +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.Quote +import org.foedusprogramme.alexandrite.sdk.di.key +import org.foedusprogramme.alexandrite.sdk.turn.Submission +import org.foedusprogramme.alexandrite.testkit.PluginHarness +import org.foedusprogramme.alexandrite.testkit.PluginHarness.Running +import org.foedusprogramme.alexandrite.testkit.RecordingTurnSubmitter +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What a message that quotes another one carries. + * + * A report names the message it answers and says nothing about it, so the channel asks the implementation for it. What + * a card says has to survive that ask, or the agent is told that something was quoted and not what. + */ +class OneBotQuoteIntegrationTest { + private val instance = ChannelInstanceId(ChannelType("onebot"), "main") + + private val directory = key() + + @Test + fun `a message that quotes a card carries what the card says`() { + val peer = QuotePeer(card) + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + harness(peer, port, submitter) { + peer.report(quoting("77")) + val message = assertIs(waiting { awaitSubmission(submitter) }).message + val quote = assertIs(message.quote) + assertEquals("someone", quote.senderName) + assertEquals("[music: Supermarket - 许嵩] https://y.qq.com/x", quote.text) + assertEquals("77", quote.target?.id) + assertEquals(false, quote.senderIsBot) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a message that quotes one the implementation cannot report carries its id and no text`() { + val peer = QuotePeer(card, answers = false) + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + harness(peer, port, submitter) { + peer.report(quoting("77")) + val message = assertIs(waiting { awaitSubmission(submitter) }).message + val quote = assertIs(message.quote) + assertEquals("", quote.text) + assertNull(quote.senderName) + assertEquals("77", quote.target?.id) + } + } + } finally { + peer.stop() + } + } + + @Test + fun `a message that quotes one the channel read asks the implementation nothing`() { + // The peer refuses to report a message at all, so a quote that carries anything can only have been kept. + val peer = QuotePeer(card, answers = false) + val port = peer.startAndPort() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + harness(peer, port, submitter) { + peer.report(cardAsMessage) + waiting { peer.awaitSilence() } + peer.report(quoting("77")) + val asked = assertIs(waiting { awaitSubmission(submitter) }).message + val quote = assertIs(asked.quote) + assertEquals("[music: Supermarket - 许嵩] https://y.qq.com/x", quote.text) + assertEquals("someone", quote.senderName) + assertEquals(0, peer.reads.get(), "the implementation was asked about a message already read") + } + } + } finally { + peer.stop() + } + } + + private suspend fun harness( + peer: QuotePeer, + port: Int, + submitter: RecordingTurnSubmitter, + block: suspend Running.() -> Unit, + ) { + PluginHarness.builder(AlexandriteChannelOnebotIndex()) + .config(config(port)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(waiting { peer.awaitConnection() }, "the channel did not connect to the implementation") + block() + } + } + + /** Waits for the agent to be asked for a turn, and returns what it was asked. */ + private fun awaitSubmission(submitter: RecordingTurnSubmitter): Submission { + val deadline = System.nanoTime() + 5_000_000_000L + while (System.nanoTime() < deadline) { + submitter.submissions.firstOrNull()?.let { return it } + Thread.sleep(20) + } + error("the agent was asked for no turn") + } + + /** Waits for a block that blocks its thread, without holding the one the test runs on. */ + private suspend fun waiting(block: () -> T): T = withContext(Dispatchers.IO) { block() } + + /** A group report that answers the message [replyTo] and names the bot. */ + private fun quoting(replyTo: String): String = """ + { + "time": 1515204254, + "self_id": 10001000, + "post_type": "message", + "message_type": "group", + "sub_type": "normal", + "message_id": 12, + "group_id": 987654, + "user_id": 12345678, + "message": [ + {"type": "reply", "data": {"id": "$replyTo"}}, + {"type": "at", "data": {"qq": 10001000}}, + {"type": "text", "data": {"text": "这是什么"}} + ], + "raw_message": "这是什么", + "sender": {"user_id": 12345678, "nickname": "someone", "role": "member"} + } + """.trimIndent() + + private fun config(port: Int): String = """ + { + "settings": {"eventQueueCapacity": 64}, + "instances": { + "main": { + "transport": "ws", + "endpoint": "ws://127.0.0.1:$port", + "connectTimeoutMillis": 5000, + "firstByteTimeoutMillis": 5000, + "idleTimeoutMillis": 5000, + "reconnectIntervalMillis": 200, + "allowedChats": ["group:987654", "private:12345678"] + } + } + } + """.trimIndent() + + /** The card, as a message the implementation reports. */ + private val cardAsMessage: String = """ + { + "time": 1515204254, + "self_id": 10001000, + "post_type": "message", + "message_type": "group", + "sub_type": "normal", + "message_id": 77, + "group_id": 987654, + "user_id": 2222, + "message": [{"type": "music", "data": {"type": "qq", "id": "1", "title": "Supermarket", "content": "许嵩", "url": "https://y.qq.com/x"}}], + "raw_message": "[音乐]", + "sender": {"user_id": 2222, "nickname": "someone", "role": "member"} + } + """.trimIndent() + + /** The message a card is reported as. */ + private val card: String = """ + { + "message_id": 77, + "sender": {"user_id": 2222, "nickname": "someone"}, + "message": [ + {"type": "music", "data": { + "type": "qq", "id": "1", "title": "Supermarket", + "content": "许嵩", "url": "https://y.qq.com/x" + }} + ] + } + """.trimIndent() + + /** An implementation that answers `get_msg` with [card], or refuses to answer for it. */ + private class QuotePeer(private val card: String, private val answers: Boolean = true) : + WebSocketServer(InetSocketAddress("127.0.0.1", 0)) { + private val started = CountDownLatch(1) + private val connected = CountDownLatch(1) + val reads = java.util.concurrent.atomic.AtomicInteger() + + @Volatile + private var socket: WebSocket? = null + + override fun onStart() { + started.countDown() + } + + override fun onOpen(connection: WebSocket, handshake: ClientHandshake) { + socket = connection + connected.countDown() + } + + override fun onMessage(connection: WebSocket, message: String) { + val echo = message.substringAfter("\"echo\":\"", "").substringBefore('"') + if (echo.isEmpty()) return + val reading = message.contains("\"get_msg\"") + if (reading) reads.incrementAndGet() + val answer = when { + reading && !answers -> """{"status":"failed","retcode":100,"data":null,"echo":"$echo"}""" + reading -> """{"status":"ok","retcode":0,"data":$card,"echo":"$echo"}""" + else -> """{"status":"ok","retcode":0,"data":{"message_id":7},"echo":"$echo"}""" + } + connection.send(answer) + } + + override fun onClose(connection: WebSocket, code: Int, reason: String, remote: Boolean) { + if (socket === connection) socket = null + } + + override fun onError(connection: WebSocket?, ex: Exception) = Unit + + fun startAndPort(): Int { + start() + check(started.await(5, TimeUnit.SECONDS)) { "the peer did not start" } + return port + } + + fun report(json: String) { + val connection = socket ?: error("nothing is connected") + connection.send(json) + } + + fun awaitConnection(): Boolean = connected.await(5, TimeUnit.SECONDS) + + /** Waits long enough for the channel to have read what was reported, and returns. */ + fun awaitSilence(millis: Long = 400) = Thread.sleep(millis) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/auth/OneBotAuthTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/auth/OneBotAuthTest.kt new file mode 100644 index 00000000..22f5c248 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/auth/OneBotAuthTest.kt @@ -0,0 +1,74 @@ +package org.foedusprogramme.alexandrite.channel.onebot.auth + +import org.foedusprogramme.alexandrite.sdk.config.Secret +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class OneBotAuthTest { + private val token = Value.of(Secret("s3cr3t-token")) + private val secret = Value.of(Secret("signing-key")) + + @Test + fun `a token is presented as a bearer header and read back from either form`() { + assertEquals("Bearer s3cr3t-token", OneBotAuth.bearer(token)) + assertEquals("s3cr3t-token", OneBotAuth.tokenOf("Bearer s3cr3t-token")) + assertEquals("s3cr3t-token", OneBotAuth.tokenOf("bearer s3cr3t-token")) + assertEquals("s3cr3t-token", OneBotAuth.tokenOf("s3cr3t-token")) + assertEquals(null, OneBotAuth.tokenOf("")) + assertEquals(null, OneBotAuth.tokenOf(null)) + assertTrue(OneBotAuth.matchesToken(token, "s3cr3t-token")) + assertFalse(OneBotAuth.matchesToken(token, "s3cr3t-toke")) + } + + @Test + fun `a report is signed with hmac sha1 over its body`() { + val signature = OneBotAuth.signature(secret, """{"post_type":"meta_event"}""") + assertTrue(signature.startsWith(OneBotAuth.SIGNATURE_PREFIX)) + assertEquals(OneBotAuth.SIGNATURE_PREFIX.length + 40, signature.length) + assertTrue(OneBotAuth.matchesSignature(secret, """{"post_type":"meta_event"}""", signature)) + assertTrue(OneBotAuth.matchesSignature(secret, """{"post_type":"meta_event"}""", signature.uppercase())) + assertFalse(OneBotAuth.matchesSignature(secret, """{"post_type":"meta_event","x":1}""", signature)) + assertFalse(OneBotAuth.matchesSignature(secret, """{"post_type":"meta_event"}""", "sha1=00")) + assertFalse(OneBotAuth.matchesSignature(secret, """{"post_type":"meta_event"}""", null)) + } + + @Test + fun `the documented example signs its body the way the standard spells it`() { + val signature = OneBotAuth.signature(Value.of(Secret("some-secret")), "the body") + assertEquals("sha1=" + hex(hmac("some-secret", "the body")), signature) + } + + @Test + fun `a token is carried as a query parameter as well`() { + assertEquals( + "http://host/send_msg?access_token=a%20b", + OneBotAuth.withToken("http://host/send_msg", Value.of(Secret("a b"))), + ) + assertEquals( + "http://host/x?a=1&access_token=t", + OneBotAuth.withToken("http://host/x?a=1", Value.of(Secret("t"))), + ) + assertEquals("http://host/x", OneBotAuth.withToken("http://host/x", null)) + } + + @Test + fun `neither a token nor a secret reaches a message`() { + val text = "call failed with Bearer s3cr3t-token and signing-key" + val redacted = OneBotAuth.redact(text, listOf(token, secret)) + assertFalse(redacted.contains("s3cr3t-token")) + assertFalse(redacted.contains("signing-key")) + assertTrue(redacted.contains("***")) + assertEquals(text, OneBotAuth.redact(text, emptyList())) + assertFalse(Secret("s3cr3t-token").toString().contains("s3cr3t-token")) + } + + private fun hex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) } + + private fun hmac(key: String, message: String): ByteArray { + val mac = javax.crypto.Mac.getInstance("HmacSHA1") + mac.init(javax.crypto.spec.SecretKeySpec(key.toByteArray(), "HmacSHA1")) + return mac.doFinal(message.toByteArray()) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotActivitiesTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotActivitiesTest.kt new file mode 100644 index 00000000..34ad0343 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotActivitiesTest.kt @@ -0,0 +1,50 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId +import org.foedusprogramme.alexandrite.sdk.tool.ToolActivity +import kotlin.test.Test +import kotlin.test.assertNotSame +import kotlin.test.assertSame + +/** + * What waits to be told is kept by the chat, so every turn working in one chat tells it once. + * + * A run moved to the background opens a reply of its own for the same chat, and the runtime hands each reply only its + * own turn's activity: a chat would hear one stretch of work once per reply without this. + */ +class OneBotActivitiesTest { + private val activities = OneBotActivities() + + private fun chat(group: String): ChatAddress = + ChatAddress(ChannelInstanceId(ChannelType("onebot"), "main"), "group:$group") + + @Test + fun `turns of one chat share what waits to be told`() { + assertSame(activities.of(chat("1")), activities.of(chat("1"))) + } + + @Test + fun `another chat waits on its own`() { + assertNotSame(activities.of(chat("1")), activities.of(chat("2"))) + } + + @Test + fun `a chat that holds nothing is forgotten`() { + val first = activities.of(chat("1")) + activities.forget(chat("1")) + + assertNotSame(first, activities.of(chat("1")), "a chat that holds nothing was kept") + } + + @Test + fun `a chat a call is still running in is kept`() { + val first = activities.of(chat("1")) + first.started(ToolCallId("c1"), ToolActivity.READ, null) + activities.forget(chat("1")) + + assertSame(first, activities.of(chat("1")), "a chat a call runs in was forgotten") + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt new file mode 100644 index 00000000..204c72ac --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandRulesTest.kt @@ -0,0 +1,146 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.onebot.i18n.TextKeys +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.chat.UserAddress +import org.foedusprogramme.alexandrite.sdk.turn.CommandContext +import org.foedusprogramme.alexandrite.sdk.turn.CommandHandler +import org.foedusprogramme.alexandrite.sdk.turn.CommandInvocation +import org.foedusprogramme.alexandrite.sdk.turn.CommandSpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Who may run which command, which nothing but this channel's intake asks. */ +class OneBotCommandRulesTest { + private val instance = ChannelInstanceId(ChannelType("onebot"), "main") + + private val rules = OneBotCommandRules(listOf(handler()), control = null) + + @Test + fun `an admin of the instance runs anything`() { + for (name in CLAIMED) { + assertNull(refusal(name, arguments = "x", direct = false, admin = true), name) + assertNull(refusal(name, arguments = "", direct = true, admin = true), name) + } + assertNull(refusal("trust", arguments = "x", direct = false, admin = true)) + } + + @Test + fun `a member runs settings anywhere`() { + assertNull(refusal("settings", direct = false)) + assertNull(refusal("settings", direct = true)) + } + + @Test + fun `a member runs model bare and is refused it with arguments`() { + assertNull(refusal("model", arguments = "", direct = false)) + assertEquals(TextKeys.REFUSED_ADMIN, refusal("model", arguments = "deepseek/one", direct = false)) + assertEquals(TextKeys.REFUSED_ADMIN, refusal("model", arguments = "deepseek/one", direct = true)) + } + + @Test + fun `a member runs agent bare and is refused it with arguments in a group`() { + assertNull(refusal("agent", arguments = "", direct = false)) + assertEquals(TextKeys.REFUSED_ADMIN_HERE, refusal("agent", arguments = "other", direct = false)) + assertNull(refusal("agent", arguments = "other", direct = true)) + } + + @Test + fun `a member runs context and profiles anywhere`() { + assertNull(refusal("context", direct = false)) + assertNull(refusal("profiles", direct = true)) + } + + @Test + fun `a member runs jobs bare and is refused it with arguments`() { + assertNull(refusal("jobs", direct = false)) + assertEquals(TextKeys.REFUSED_ADMIN, refusal("jobs", arguments = "x", direct = false)) + } + + @Test + fun `a member runs compact in a direct chat linked with no other`() { + assertNull(refusal("compact", direct = true)) + assertEquals(TextKeys.REFUSED_ADMIN_HERE, refusal("compact", direct = false)) + } + + @Test + fun `a member runs cancel only in a direct chat`() { + assertNull(refusal("cancel", direct = true)) + assertEquals(TextKeys.REFUSED_ADMIN_HERE, refusal("cancel", direct = false)) + } + + @Test + fun `a member runs new in a direct chat linked with no other`() { + assertNull(refusal("new", direct = true)) + assertEquals(TextKeys.REFUSED_ADMIN_HERE, refusal("new", direct = false)) + } + + @Test + fun `a member runs a command the matrix does not list only as an admin`() { + assertEquals(TextKeys.REFUSED_ADMIN, refusal("trust", arguments = "", direct = true)) + assertEquals(TextKeys.REFUSED_ADMIN, refusal("trust", arguments = "", direct = false)) + } + + @Test + fun `a command the runtime does not claim is none of this channel's business`() { + assertNull(refusal("approve", arguments = "", direct = false)) + } + + @Test + fun `mayRun answers the matrix for a member`() { + val direct = ChatAddress(instance, "private:12345678") + val group = ChatAddress(instance, "group:987654") + assertTrue(rules.mayRun(direct, admin = false, "model", withArguments = false)) + assertFalse(rules.mayRun(direct, admin = false, "model", withArguments = true)) + assertTrue(rules.mayRun(group, admin = false, "help", withArguments = false)) + assertFalse(rules.mayRun(group, admin = false, "agent", withArguments = true)) + assertTrue(rules.mayRun(group, admin = true, "trust", withArguments = false)) + assertTrue(rules.mayRun(group, admin = false, "not_a_command", withArguments = true)) + } + + /** The key of the refusal of [name] for a member, null when the member may run it. */ + private fun refusal( + name: String, + arguments: String = "", + direct: Boolean = false, + admin: Boolean = false, + ): String? { + val chat = ChatAddress(instance, if (direct) "private:12345678" else "group:987654") + val issuer = ChatUser(UserAddress(instance, "12345678"), "someone", null, isBot = false, isAdmin = admin) + val invocation = CommandInvocation.builder(chat, name, issuer).arguments(arguments).build() + return rules.refusal(invocation, if (direct) ChatKind.DIRECT else ChatKind.GROUP) + } + + /** A handler that claims every command the default ones do, and one the matrix does not list. */ + private fun handler(): CommandHandler = object : CommandHandler { + override val commands: List = + (CLAIMED + "trust").map { CommandSpec.builder(it, "a test command").build() } + + override suspend fun handle(invocation: CommandInvocation, context: CommandContext) = Unit + } + + private companion object { + val CLAIMED = listOf( + "help", + "settings", + "context", + "profiles", + "agent", + "reasoning", + "language", + "model", + "mode", + "jobs", + "new", + "compact", + "cancel", + ) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandsTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandsTest.kt new file mode 100644 index 00000000..a37e4269 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotCommandsTest.kt @@ -0,0 +1,156 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.serialization.json.Json +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEventCodec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The commands a report carries, and the messages the bot is addressed by. */ +class OneBotCommandsTest { + private val botId = "10001000" + + @Test + fun `a command names itself and its arguments after the prefix`() { + val command = assertIs(OneBotCommands.of(group("/model deepseek/one"))) + assertEquals("model", command.name) + assertEquals("deepseek/one", command.arguments) + } + + @Test + fun `a command without arguments carries an empty ones`() { + val command = assertIs(OneBotCommands.of(group("/new"))) + assertEquals("new", command.name) + assertEquals("", command.arguments) + } + + @Test + fun `a mention before a command leaves the command`() { + val command = assertIs( + OneBotCommands.of(group("/model", at = botId)), + ) + assertEquals("model", command.name) + } + + @Test + fun `a mention after a command leaves the command`() { + val command = assertIs( + OneBotCommands.of(groupWith("/model", "at")), + ) + assertEquals("model", command.name) + assertEquals("", command.arguments) + } + + @Test + fun `a bare prefix names no command`() { + assertNull(OneBotCommands.of(group("/"))) + } + + @Test + fun `a token that is no command name names no command`() { + assertNull(OneBotCommands.of(group("/随便说点什么")), "a name is written as the other platform writes one") + assertNull(OneBotCommands.of(group("/model-two"))) + assertNull(OneBotCommands.of(group("/模型"))) + } + + @Test + fun `a command may name the target it belongs to`() { + val command = assertIs(OneBotCommands.of(group("/model@10001000 deepseek/one"))) + assertEquals("model", command.name) + assertEquals("deepseek/one", command.arguments) + } + + @Test + fun `text without a prefix names no command`() { + assertNull(OneBotCommands.of(group("model"))) + } + + @Test + fun `a prefix that does not start the message names no command`() { + assertNull(OneBotCommands.of(group("say /model"))) + } + + @Test + fun `a direct message is the bot's`() { + assertTrue(addressed(private("hello"), direct = true)) + } + + @Test + fun `a group message that names nobody is not the bot's`() { + assertFalse(addressed(group("hello"), direct = false)) + } + + @Test + fun `a group message that names the bot is the bot's`() { + assertTrue(addressed(group("hello", at = botId), direct = false)) + } + + @Test + fun `a group message that names everyone is the bot's`() { + assertTrue(addressed(group("hello", at = "all"), direct = false)) + } + + @Test + fun `a group message that names another member is not the bot's`() { + assertFalse(addressed(group("hello", at = "99999999"), direct = false)) + } + + @Test + fun `a group message answering the bot is the bot's`() { + assertTrue(addressed(group("hello", reply = "42"), direct = false) { it == "42" }) + } + + @Test + fun `a group message answering another message is not the bot's`() { + assertFalse(addressed(group("hello", reply = "42"), direct = false) { it == "77" }) + } + + /** Whether a report of [message] is the bot's, answering what [sent] holds. */ + private fun addressed( + event: OneBotEvent.Message, + direct: Boolean, + sent: (String) -> Boolean = { false }, + ): Boolean = OneBotCommands.addressed( + event = event, + isDirect = direct, + namesTheBot = { selfId -> + event.message.segments.any { + it is org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment.At && + (it.isAll || it.userId == selfId) + } + }, + answersTheBot = sent, + ) + + /** A group report whose message is [text] and one segment of [at], or of [reply], before it. */ + private fun group(text: String, at: String? = null, reply: String? = null): OneBotEvent.Message = + decode(groupJson(text, at, reply)) + + private fun groupWith(text: String, at: String) = decode(groupJson(text, at, null)) + + private fun groupJson(text: String, at: String?, reply: String?): String = buildString { + append("""{"time":1515204254,"self_id":$botId,"post_type":"message","message_type":"group",""") + append(""""sub_type":"normal","message_id":12,"group_id":987654,"user_id":12345678,"message":[""") + at?.let { append("""{"type":"at","data":{"qq":${if (it == "all") "\"all\"" else it}}},""") } + reply?.let { append("""{"type":"reply","data":{"id":$it}},""") } + append("""{"type":"text","data":{"text":"$text"}}],""") + append(""""raw_message":"$text",""") + append(""""sender":{"user_id":12345678,"nickname":"someone","role":"member"}}""") + } + + private fun private(text: String): OneBotEvent.Message = decode( + buildString { + append("""{"time":1515204254,"self_id":$botId,"post_type":"message","message_type":"private",""") + append(""""sub_type":"friend","message_id":12,"user_id":12345678,""") + append(""""message":[{"type":"text","data":{"text":"$text"}}],"raw_message":"$text",""") + append(""""sender":{"user_id":12345678,"nickname":"someone"}}""") + }, + ) + + private fun decode(json: String): OneBotEvent.Message = + assertIs(OneBotEventCodec.decode(Json.parseToJsonElement(json))) +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotHttpTransportTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotHttpTransportTest.kt new file mode 100644 index 00000000..a79bac53 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotHttpTransportTest.kt @@ -0,0 +1,209 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.auth.Value +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotHttpApi +import org.foedusprogramme.alexandrite.channel.onebot.transport.OneBotWire +import org.foedusprogramme.alexandrite.channel.onebot.transport.envelopeResult +import org.foedusprogramme.alexandrite.sdk.config.Secret +import java.io.BufferedInputStream +import java.io.OutputStreamWriter +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.nio.charset.StandardCharsets +import kotlin.concurrent.thread +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The HTTP transport against a peer of this test, which reads the request line, the headers and the body and answers + * with what the test told it to. + */ +class OneBotHttpTransportTest { + private val peer = Peer() + private val token = Value.of(Secret("s3cr3t-token")) + private val client = OneBotHttpApi("http://127.0.0.1:${peer.port}", token, 5_000, 5_000, 5_000) + + @AfterTest + fun close() { + client.close() + peer.close() + } + + @Test + fun `a successful call reads the data of the answer`() { + peer.answer("""{"status":"ok","retcode":0,"data":{"user_id":10001000,"nickname":"someone"}}""") + val result = runBlocking { client.call("get_login_info", JsonObject(emptyMap())) } + val ok = + assertIs>(result) + assertEquals("10001000", ok.data["user_id"]?.jsonPrimitive?.content) + val request = peer.next() + assertTrue(request.startsWith("POST /get_login_info"), "the call was $request") + assertTrue(request.contains("Bearer s3cr3t-token"), "the token was not presented") + assertTrue(request.contains("\"params\"") || request.endsWith("{}"), "the body was $request") + } + + @Test + fun `a call carries its token both ways the standard allows`() { + peer.answer("""{"status":"ok","retcode":0,"data":{}}""") + + runBlocking { client.call("get_login_info", JsonObject(emptyMap())) } + + val request = peer.next() + assertTrue( + request.startsWith("POST /get_login_info?access_token=s3cr3t-token"), + "the token is not in the query, so an implementation that reads only the query would refuse: $request", + ) + assertTrue( + request.contains("Authorization: Bearer s3cr3t-token"), + "the token is not in the header, so one that reads only the header would refuse: $request", + ) + } + + @Test + fun `a refused call keeps the retcode of the implementation`() { + peer.answer("""{"status":"failed","retcode":1004,"message":"too fast"}""") + val result = runBlocking { client.call("send_private_msg", buildJsonObject { put("user_id", "1") }) } + val failed = + assertIs(result) + assertEquals(1004, failed.retcode) + assertEquals("too fast", failed.message) + } + + @Test + fun `an implementation that is not there is unreachable, not a refusal`() { + val absent = OneBotHttpApi("http://127.0.0.1:1", token, 1_000, 1_000, 1_000) + try { + val result = runBlocking { absent.call("get_status", JsonObject(emptyMap())) } + assertIs(result) + } finally { + absent.close() + } + } + + @Test + fun `a missing token is reported as an authentication failure`() { + peer.answer("""{"status":"failed","retcode":1401}""", status = 401) + val result = runBlocking { client.call("get_status", JsonObject(emptyMap())) } + val unreachable = + assertIs(result) + assertEquals( + org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailureKind.AUTHENTICATION, + unreachable.failure.kind, + ) + } + + @Test + fun `an answer this version cannot read is malformed, and the token stays out of it`() { + peer.answer("""{"hello":"world"}""") + val result = runBlocking { client.call("get_status", JsonObject(emptyMap())) } + val malformed = + assertIs(result) + assertTrue(!malformed.detail.contains("s3cr3t-token"), "the token leaked: ${malformed.detail}") + } + + @Test + fun `an answer that carries an array keeps it instead of an empty object`() { + // The standard answers `get_friend_list` and `get_group_list` with an array, so an answer read as an object + // alone became empty and every such call was reported as one this version cannot read. + val answer = OneBotWire.parseToJsonElement( + """{"status":"ok","retcode":0,"data":[{"user_id":10001000,"nickname":"someone"}]}""", + ) as JsonObject + + val ok = assertIs>( + envelopeResult(answer), + ) + assertEquals(1, assertIs(ok.data).size) + } + + @Test + fun `an answer that carries an object keeps it`() { + val answer = OneBotWire.parseToJsonElement("""{"status":"ok","retcode":0,"data":{"message_id":42}}""") + as JsonObject + + val ok = assertIs>( + envelopeResult(answer), + ) + assertEquals("42", (ok.data as? JsonObject)?.get("message_id")?.jsonPrimitive?.content) + } + + /** A peer that answers one request with what the test set. */ + private class Peer { + private val server = ServerSocket(0, 8, InetAddress.getLoopbackAddress()) + private val served = java.util.concurrent.LinkedBlockingQueue() + private val waiting = java.util.concurrent.LinkedBlockingQueue() + + @Volatile + private var status: Int = 200 + + @Volatile + private var answer: String = """{"status":"ok","retcode":0,"data":{}}""" + + val port: Int get() = server.localPort + + init { + thread(isDaemon = true) { + while (!server.isClosed) { + val socket = try { + server.accept() + } catch (e: Exception) { + return@thread + } + socket.use { exchange(it) } + } + } + } + + fun answer(json: String, status: Int = 200) { + this.answer = json + this.status = status + } + + /** The request the peer answered last. */ + fun next(): String = waiting.poll() ?: served.poll() ?: error("the peer was called by nobody") + + private fun exchange(socket: Socket) { + val input = BufferedInputStream(socket.getInputStream()) + val head = StringBuilder() + while (!head.endsWith("\r\n\r\n")) { + val byte = input.read() + if (byte < 0) return + head.append(byte.toChar()) + } + val length = head.lineSequence() + .firstOrNull { it.startsWith("Content-Length:", ignoreCase = true) } + ?.substringAfter(':')?.trim()?.toIntOrNull() ?: 0 + val body = ByteArray(length) + var read = 0 + while (read < length) { + val count = input.read(body, read, length - read) + if (count < 0) break + read += count + } + val request = head.toString().trimEnd() + "\n" + String(body, StandardCharsets.UTF_8) + served += request + waiting += request + val bytes = answer.toByteArray(StandardCharsets.UTF_8) + val writer = OutputStreamWriter(socket.getOutputStream(), StandardCharsets.US_ASCII) + writer.write("HTTP/1.1 $status OK\r\nContent-Type: application/json\r\nContent-Length: ${bytes.size}\r\n") + writer.write("Connection: close\r\n\r\n") + writer.flush() + socket.getOutputStream().write(bytes) + socket.getOutputStream().flush() + } + + fun close() { + runCatching { server.close() } + } + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgressTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgressTest.kt new file mode 100644 index 00000000..5ca8cba1 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotProgressTest.kt @@ -0,0 +1,231 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.channel.onebot.mapping.OneBotMessages +import org.foedusprogramme.alexandrite.sdk.channel.TurnEventKind +import org.foedusprogramme.alexandrite.sdk.chat.AgentId +import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId +import org.foedusprogramme.alexandrite.sdk.tool.ToolActivity +import org.foedusprogramme.alexandrite.testkit.testTexts +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What a chat is told of a turn's work while it works. + * + * The chat of a platform whose messages cannot be rewritten is told in messages of its own, so a step is not worth a + * message on its own: the first one is told as it starts, and the ones after it are counted as they end and told + * together once the interval has passed. What the model says is told once a segment of it is complete. + */ +class OneBotProgressTest { + private var at = 0L + private val progress = OneBotProgress(intervalMillis = 8_000) { at } + private val texts = testTexts(PLUGIN) + + /** What the chat is told as the call [id] starts, or null when it is told nothing. */ + private fun started( + id: String, + activity: ToolActivity, + agent: AgentId? = null, + title: String = "", + tool: String = "", + ): String? = progress.started(ToolCallId(id), activity, agent, tool = tool, title = title)?.let { + startedMessage(it, texts, null) + } + + /** What the chat is told of what ended, or null when nothing is told. */ + private fun told(): String? = progress.take()?.let { summaryMessage(it, texts, null) } + + @Test + fun `the first step of a turn is told as it starts`() { + assertEquals("Reading 1 file", started("c1", ToolActivity.READ)) + } + + @Test + fun `a step after the first is not told as it starts`() { + started("c1", ToolActivity.READ) + + assertNull(started("c2", ToolActivity.SEARCH), "only the first step of a turn is told as it starts") + } + + @Test + fun `steps inside the interval wait, and are told together once it passes`() { + started("c1", ToolActivity.READ) + progress.ended(ToolCallId("c1")) + started("c2", ToolActivity.LIST) + progress.ended(ToolCallId("c2")) + + assertNull(told(), "a step inside the interval is not worth a message") + + at += 8_000 + assertEquals("Read 1 file · Listed 1 directory", told()) + } + + @Test + fun `nothing waiting says nothing`() { + assertNull(told(), "a turn that ended nothing has nothing to say") + } + + @Test + fun `a call that has not ended is not counted`() { + started("c1", ToolActivity.READ) + + assertNull(told(), "a call still running is not told as one that ended") + } + + @Test + fun `only the calls a summary cannot say get a line of their own`() { + started("c1", ToolActivity.READ, title = "notes.md") + started("c2", ToolActivity.READ, AgentId("code-prober"), title = "README.md") + started("c3", ToolActivity.LIST, title = "src") + progress.ended(ToolCallId("c1"), org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus.FAILED) + progress.ended(ToolCallId("c2"), org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus.FAILED) + progress.ended(ToolCallId("c3")) + at += 8_000 + + assertEquals( + "Read 1 file · Listed 1 directory / code-prober · Read 1 file\n" + + "notes.md — failed\ncode-prober · README.md — failed", + told(), + ) + } + + @Test + fun `the work of each agent stands apart from the work of another`() { + started("c1", ToolActivity.READ, title = "notes.md") + started("c2", ToolActivity.READ, AgentId("code-prober"), title = "README.md") + started("c3", ToolActivity.LIST, AgentId("code-prober"), title = "src") + started("c4", ToolActivity.READ, AgentId("risk-auditor"), title = "secrets.md") + listOf("c1", "c2", "c3", "c4").forEach { progress.ended(ToolCallId(it)) } + at += 8_000 + + assertEquals( + "Read 1 file / code-prober · Read 1 file · Listed 1 directory / risk-auditor · Read 1 file", + told(), + ) + } + + @Test + fun `a call whose kind is unknown is told as work with tools`() { + started("c1", ToolActivity.of("frobnicate")) + progress.ended(ToolCallId("c1")) + at += 8_000 + + assertEquals("Used 1 tool", told()) + } + + @Test + fun `a call that sends a file is told as a file rather than as a tool`() { + assertEquals("Sending 1 file", started("c1", ToolActivity.SEND, title = "report.pdf", tool = "chat.send_file")) + + started("c2", ToolActivity.SEND, title = "chart.png", tool = "chat.send_file") + progress.ended(ToolCallId("c2")) + at += 8_000 + + assertEquals("Sent 1 file", told()) + } + + @Test + fun `a call that ended is told by the interval, without a turn ending it`() { + started("c1", ToolActivity.READ) + progress.ended(ToolCallId("c1")) + + assertNull(told(), "the interval has not passed yet") + + at += 8_000 + + assertEquals("Read 1 file", told()) + } + + @Test + fun `each kind of progress has its own switch`() { + val both = OneBotMessages.capabilities(proactive = true) + assertTrue(both.streaming) + assertEquals(setOf(TurnEventKind.TOOL_CALL_STARTED, TurnEventKind.TOOL_CALL_UPDATED), both.events) + + val actions = OneBotMessages.capabilities(proactive = true, streaming = false) + assertFalse(actions.streaming, "the words of the model are off") + assertEquals(setOf(TurnEventKind.TOOL_CALL_STARTED, TurnEventKind.TOOL_CALL_UPDATED), actions.events) + + val words = OneBotMessages.capabilities(proactive = true, progress = false) + assertTrue(words.streaming) + assertEquals(emptySet(), words.events, "the steps are off") + } + + @Test + fun `a call moved to the background is counted as one that ended`() { + started("c1", ToolActivity.READ, title = "notes.md", tool = "fs.read") + progress.ended(ToolCallId("c1"), org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus.BACKGROUNDED) + at += 8_000 + + assertEquals("Read 1 file\nMoved to the background: fs.read notes.md", told()) + } + + @Test + fun `a message past its limit keeps the newest lines and counts the rest`() { + val title = "x".repeat(200) + for (index in 1..25) { + started("c$index", ToolActivity.READ, title = title) + progress.ended(ToolCallId("c$index"), org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus.FAILED) + } + at += 8_000 + + val message = checkNotNull(told()) { "a message was expected" } + + assertTrue(message.startsWith("Read 25 files\n"), "the summary is kept: $message") + assertTrue(message.contains("earlier calls not shown"), "what was left out is counted: $message") + assertTrue(message.length <= MAX_ACTIVITY_CHARS, "the message stays within its limit: ${message.length}") + } + + @Test + fun `a delegation that moved to the background is told once, in the summary`() { + started("c1", ToolActivity.OTHER, title = "risk-auditor", tool = "agent.delegate") + progress.ended( + ToolCallId("c1"), + org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus.BACKGROUNDED, + ) + at += 8_000 + + assertEquals("Delegated to risk-auditor", told()) + } + + @Test + fun `a conversation that keeps working is told less often`() { + started("c1", ToolActivity.READ) + progress.ended(ToolCallId("c1")) + at += 8_000 + assertEquals("Read 1 file", told()) + + started("c2", ToolActivity.READ) + progress.ended(ToolCallId("c2")) + at += 8_000 + assertNull(told(), "the window after a message is longer than the first") + + at += 8_000 + assertEquals("Read 1 file", told()) + } + + @Test + fun `a conversation that fell quiet starts over`() { + started("c1", ToolActivity.READ) + progress.ended(ToolCallId("c1")) + at += 8_000 + assertEquals("Read 1 file", told()) + + started("c2", ToolActivity.READ) + progress.ended(ToolCallId("c2")) + at += 60_000 + assertEquals("Read 1 file", told(), "a long silence is told once the wait has passed") + + started("c3", ToolActivity.READ) + progress.ended(ToolCallId("c3")) + at += 8_000 + assertEquals("Read 1 file", told(), "the wait is the first one again after a silence") + } + + private companion object { + const val PLUGIN = "alexandrite-channel-onebot" + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotReplyRegistryTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotReplyRegistryTest.kt new file mode 100644 index 00000000..b12a7abd --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotReplyRegistryTest.kt @@ -0,0 +1,58 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.TurnId +import org.foedusprogramme.alexandrite.sdk.transcript.AssistantEntry +import kotlin.test.Test +import kotlin.test.assertNull +import kotlin.test.assertSame + +/** + * The words of a turn go to the reply of that turn, and to nothing else. + * + * What this keeps out is a response of a turn this instance does not speak for: a delegated turn reaches the same hook + * but never opened a reply here, so nothing is said to the chat about it. + */ +class OneBotReplyRegistryTest { + private val registry = OneBotReplyRegistry() + private val first = TurnId("t-1") + private val other = TurnId("t-2") + + @Test + fun `the reply of a turn is the one that opened it`() { + val sink = Sink() + + registry.open(first, sink) + + assertSame(sink, registry.of(first)) + assertNull(registry.of(other), "a turn that opened no reply here has none, such as a delegated one") + } + + @Test + fun `a reply that closed is not found again`() { + val sink = Sink() + registry.open(first, sink) + + registry.close(first) + + assertNull(registry.of(first)) + } + + @Test + fun `a reply opened later is the one a turn is told through`() { + val earlier = Sink() + val later = Sink() + registry.open(first, earlier) + + registry.open(first, later) + + assertSame(later, registry.of(first)) + } + + private class Sink : OneBotWords { + val shown = ArrayList() + + override suspend fun showResponse(message: AssistantEntry) { + shown += message + } + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotWordsHookTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotWordsHookTest.kt new file mode 100644 index 00000000..5ef99f0c --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/channel/OneBotWordsHookTest.kt @@ -0,0 +1,99 @@ +package org.foedusprogramme.alexandrite.channel.onebot.channel + +import kotlinx.coroutines.runBlocking +import org.foedusprogramme.alexandrite.sdk.InternalAlexandriteApi +import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId +import org.foedusprogramme.alexandrite.sdk.chat.TurnId +import org.foedusprogramme.alexandrite.sdk.hook.HookDecision +import org.foedusprogramme.alexandrite.sdk.model.FinishKind +import org.foedusprogramme.alexandrite.sdk.model.FinishReason +import org.foedusprogramme.alexandrite.sdk.model.ModelEvent +import org.foedusprogramme.alexandrite.sdk.model.Usage +import org.foedusprogramme.alexandrite.sdk.transcript.AssistantEntry +import org.foedusprogramme.alexandrite.sdk.transcript.TextPart +import org.foedusprogramme.alexandrite.sdk.transcript.ToolCallPart +import org.foedusprogramme.alexandrite.sdk.turn.ModelReply +import org.foedusprogramme.alexandrite.testkit.TEST_MODEL +import org.foedusprogramme.alexandrite.testkit.testTurn +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The words of a response reach the reply of the turn that asked for it, and nothing else. + * + * This is the half [OneBotReplyRegistryTest] cannot show: that a response is handed over as a whole, once, at the + * moment the model finished saying it, which is the point the runtime fires as the request ends. + */ +@OptIn(InternalAlexandriteApi::class) +class OneBotWordsHookTest { + private val replies = OneBotReplyRegistry() + private val hook = OneBotWordsHook(replies) + private val turn = testTurn() + + @Test + fun `a response goes whole to the reply of its own turn`() = runBlocking { + val sink = Sink() + replies.open(turn.id, sink) + + hook.intercept(ModelReply(turn, 0, response("Let me check."))) + + assertEquals(listOf("Let me check."), sink.shown) + } + + @Test + fun `the response of a turn this instance opened no reply for goes nowhere`() = runBlocking { + val sink = Sink() + replies.open(TurnId("t-another"), sink) + + hook.intercept(ModelReply(turn, 0, response("Nobody asked."))) + + assertEquals(emptyList(), sink.shown, "a delegated turn reaches this point too, and speaks to no chat") + } + + @Test + fun `a response after the reply closed goes nowhere`() = runBlocking { + val sink = Sink() + replies.open(turn.id, sink) + replies.close(turn.id) + + hook.intercept(ModelReply(turn, 0, response("Too late."))) + + assertEquals(emptyList(), sink.shown) + } + + @Test + fun `the chain goes on, since this hook only watches`() = runBlocking { + val payload = ModelReply(turn, 0, response("Hi.")) + + assertEquals(HookDecision.Continue, hook.intercept(payload)) + } + + @Test + fun `a response that calls no tool is not told, since the answer carries it`() = runBlocking { + val sink = Sink() + replies.open(turn.id, sink) + + hook.intercept(ModelReply(turn, 0, response("Done.", callsTool = false))) + + assertEquals(emptyList(), sink.shown, "the message that ends the turn says it, and it is not said twice") + } + + private fun response(text: String, callsTool: Boolean = true): ModelEvent.Completed = ModelEvent.Completed( + AssistantEntry( + null, + listOf(TextPart(text)) + + if (callsTool) listOf(ToolCallPart(ToolCallId("c1"), "fs.read", "{}")) else emptyList(), + TEST_MODEL, + ), + FinishReason(FinishKind.END_TURN, null, null), + Usage.builder().build(), + ) + + private class Sink : OneBotWords { + val shown = ArrayList() + + override suspend fun showResponse(message: AssistantEntry) { + shown += message.parts.filterIsInstance().joinToString("\n\n") { it.text } + } + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/i18n/TextKeysTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/i18n/TextKeysTest.kt new file mode 100644 index 00000000..c524a189 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/i18n/TextKeysTest.kt @@ -0,0 +1,34 @@ +package org.foedusprogramme.alexandrite.channel.onebot.i18n + +import org.foedusprogramme.alexandrite.sdk.chat.LanguageTag +import org.foedusprogramme.alexandrite.testkit.assertTextsComplete +import org.foedusprogramme.alexandrite.testkit.testTexts +import java.lang.reflect.Modifier +import java.util.Properties +import kotlin.test.Test +import kotlin.test.assertEquals + +class TextKeysTest { + private val keys = TextKeys::class.java.declaredFields + .filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java } + .map { it.get(null) as String } + + @Test + fun `the channel ships its texts in English`() { + assertEquals(listOf(LanguageTag("en")), assertTextsComplete(PLUGIN)) + } + + @Test + fun `every key the channel uses has an English text, and every English text is used`() { + val english = Properties() + javaClass.getResourceAsStream("/alexandrite/i18n/$PLUGIN/en.properties")!!.use { english.load(it) } + val texts = testTexts(PLUGIN) + + assertEquals(english.stringPropertyNames(), keys.toSet()) + assertEquals(emptyList(), keys.filter { texts.text(it, null) == it }) + } + + private companion object { + const val PLUGIN = "alexandrite-channel-onebot" + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdownTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdownTest.kt new file mode 100644 index 00000000..41d16b57 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMarkdownTest.kt @@ -0,0 +1,91 @@ +package org.foedusprogramme.alexandrite.channel.onebot.mapping + +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * What a chat of this platform reads of a message a model wrote as Markdown. + * + * A message of this platform shows no formatting, so the markers of it would arrive as the characters they are written + * with. What they wrap is kept as the text a chat reads instead. + */ +class OneBotMarkdownTest { + @Test + fun `a heading keeps its title without its marker`() { + assertEquals("Title", text("# Title")) + assertEquals("Title", text("## Title ##")) + } + + @Test + fun `emphasis and strikethrough keep their words without their markers`() { + assertEquals("bold and italic and struck", text("**bold** and *italic* and ~~struck~~")) + assertEquals("bold and italic", text("__bold__ and _italic_")) + assertEquals("snake_case stays as written", text("snake_case stays as written")) + } + + @Test + fun `a code span keeps what it holds without its backticks`() { + assertEquals("x = **1**", text("`x = **1**`")) + } + + @Test + fun `a code block keeps its lines without its fence`() { + assertEquals("val x = 1\nprintln(x)", text("```kotlin\nval x = 1\nprintln(x)\n```")) + assertEquals("plain", text("~~~\nplain\n~~~")) + } + + @Test + fun `a link keeps its text and its address`() { + assertEquals("see the docs (https://example.com/x)", text("see [the docs](https://example.com/x)")) + assertEquals("https://a.b/c", text("[https://a.b/c](https://a.b/c)")) + assertEquals("the docs (https://example.com/x)", text("""[the docs](https://example.com/x "The docs")""")) + } + + @Test + fun `an image becomes an image segment`() { + val segments = OneBotMarkdown.segments("before ![a picture](https://x/1.jpg) after") + + assertEquals( + listOf( + OneBotSegment.Text("before "), + OneBotSegment.Image(file = "https://x/1.jpg"), + OneBotSegment.Text(" after"), + ), + segments, + ) + } + + @Test + fun `a list keeps its marker and loses the markers inside its item`() { + assertEquals("- done\n* first\n1. second", text("- **done**\n* *first*\n1. **second**")) + } + + @Test + fun `a quote loses its marker`() { + assertEquals("quoted line", text("> **quoted** line")) + } + + @Test + fun `a table keeps its cells without the row that lines them up`() { + assertEquals("a | b\n1 | 2", text("| a | b |\n|---|---|\n| 1 | 2 |")) + } + + @Test + fun `a rule becomes a line a chat reads`() { + assertEquals("———", text("---")) + } + + @Test + fun `an escaped marker is the character it stands for`() { + assertEquals("*not emphasis*", text("""\*not emphasis\*""")) + } + + @Test + fun `a message of plain lines keeps them`() { + assertEquals("hello\n\nworld", text("hello\n\nworld")) + } + + private fun text(markdown: String): String = + OneBotMarkdown.segments(markdown).filterIsInstance().joinToString("") { it.text } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessagesTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessagesTest.kt new file mode 100644 index 00000000..5cce31d3 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotMessagesTest.kt @@ -0,0 +1,284 @@ +package org.foedusprogramme.alexandrite.channel.onebot.mapping + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.SendPrivateMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEventCodec +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegmentCodec +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.DeliveryFailure +import org.foedusprogramme.alexandrite.sdk.channel.Markup +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OneBotMessagesTest { + private val instance = ChannelInstanceId(ChannelType("onebot"), "main") + + @Test + fun `a private message becomes a message of a direct chat`() { + val message = OneBotMessages.incoming(instance, privateEvent()) + assertEquals("private:12345678", message.chat.chat) + assertEquals("12", message.ref.id) + assertEquals("12345678", message.sender.address.user) + assertEquals(ChatKind.DIRECT, message.chatInfo.kind) + assertEquals("someone", message.sender.displayName) + assertEquals("hello there", message.text) + assertEquals(0, message.media.size) + } + + @Test + fun `a group message keeps its group and marks an administrator`() { + val message = OneBotMessages.incoming(instance, groupEvent(role = "admin")) + assertEquals("group:100100", message.chat.chat) + assertEquals(ChatKind.GROUP, message.chatInfo.kind) + assertTrue(message.sender.isAdmin) + assertEquals("the card", message.sender.displayName) + } + + @Test + fun `an instance admin is an admin even when the platform reports a plain member`() { + val message = OneBotMessages.incoming(instance, groupEvent(role = "member"), admins = setOf("12345678")) + assertTrue(message.sender.isAdmin) + } + + @Test + fun `an anonymous group message is a fact of the message`() { + val message = OneBotMessages.incoming(instance, groupEvent(anonymous = true)) + assertTrue(message.facts.any { it.label == "anonymous" && it.value == "no one" }) + } + + @Test + fun `the segments the SDK has no shape for become facts`() { + val message = OneBotMessages.incoming(instance, privateEvent(message = "[CQ:napcat_thing,key=value]")) + assertTrue(message.facts.any { it.label == "unsupported segment" && it.value == "napcat_thing" }) + } + + @Test + fun `a reply names the message it answers`() { + val message = OneBotMessages.incoming(instance, privateEvent(message = "[CQ:reply,id=99]hello")) + assertEquals("99", message.quote?.target?.id) + assertEquals("private:12345678", message.quote?.target?.chat?.chat) + } + + @Test + fun `an image becomes a media attachment and a fact`() { + val message = OneBotMessages.incoming( + instance, + privateEvent(message = "[CQ:image,file=1.jpg,url=http://x/1.jpg]"), + ) + assertEquals(1, message.media.size) + assertEquals("1.jpg", message.media.single().name) + assertTrue(message.facts.any { it.label == "image" }) + } + + @Test + fun `a temporary session keeps the group as its chat and the sender as its thread`() { + val event = + assertIs( + OneBotEventCodec.decode(parse(privateJson(subType = "group", groupId = 100100))), + ) + val chat = OneBotChats.addressOf(instance, event) + assertEquals("group:100100", chat.chat) + assertEquals("12345678", chat.thread) + assertEquals("12345678", OneBotChats.addressOf(instance, event).thread) + } + + @Test + fun `a temporary session answers its sender and not its group`() { + val event = + assertIs( + OneBotEventCodec.decode(parse(privateJson(subType = "group", groupId = 100100))), + ) + val chat = OneBotChats.addressOf(instance, event) + + // A reply to this chat belongs to the user who sent it: the group it came through is not where an answer to a + // private message goes, and the address says so by keeping the sender as its thread. + assertEquals(UserId("12345678"), OneBotMessages.privateTemporary(chat)) + assertNull(OneBotMessages.privateUser(chat), "the chat itself names the group, not the sender") + } + + @Test + fun `plain text leaves as one text segment`() { + val message = OutboundMessage.builder( + "hello", + org.foedusprogramme.alexandrite.sdk.channel.MessageKind.REPLY, + ).build() + val segments = OneBotMessages.outgoing(chat(), message).segments + assertEquals(listOf(OneBotSegment.Text("hello")), segments) + } + + @Test + fun `a message keeps the shape it was written in when it asks to be taken literally`() { + // A whole message as one piece of text is sent as text, so that `auto_escape` can say to take the CQ codes + // inside it as the characters they are. A message of segments is sent as segments and asks for no escaping, + // because an implementation that read the text of one as an escape would send something else. + val literal = SendPrivateMessage(UserId("1"), OneBotMessage.StringValue("hello [CQ:face]"), autoEscape = true) + assertEquals("hello [CQ:face]", literal.toJson()["message"]?.jsonPrimitive?.content) + assertEquals(true, literal.toJson()["auto_escape"]?.jsonPrimitive?.boolean) + + val segments = SendPrivateMessage(UserId("1"), OneBotMessage.ArrayValue(listOf(OneBotSegment.Text("hi")))) + assertIs(segments.toJson()["message"]) + assertNull(segments.toJson()["auto_escape"]) + } + + @Test + fun `markdown leaves its images as image segments`() { + val message = OutboundMessage.builder( + "see ![a](http://x/1.jpg) done", + org.foedusprogramme.alexandrite.sdk.channel.MessageKind.REPLY, + ) + .markup(Markup.MARKDOWN) + .build() + val segments = OneBotMessages.outgoing(chat(), message).segments + assertEquals(3, segments.size) + assertEquals("see ", assertIs(segments[0]).text) + assertEquals("http://x/1.jpg", assertIs(segments[1]).file) + assertEquals(" done", assertIs(segments[2]).text) + } + + @Test + fun `markdown leaves as the plain text a chat of this platform reads`() { + val message = OutboundMessage.builder( + "## Result\n\n**done** in `main`, see [docs](https://x/y)", + org.foedusprogramme.alexandrite.sdk.channel.MessageKind.REPLY, + ) + .markup(Markup.MARKDOWN) + .build() + + val segments = OneBotMessages.outgoing(chat(), message).segments + + assertEquals( + listOf(OneBotSegment.Text("Result\n\ndone in main, see docs (https://x/y)")), + segments, + ) + } + + @Test + fun `a reply to a message whose id is not one of the implementation is sent without the reply segment`() { + // The delivery of a message this version could not name is referenced as `sent` or `async`, which names no + // message of the implementation. Answering one has to send the answer rather than fail before sending it. + val message = OutboundMessage.builder("hi", org.foedusprogramme.alexandrite.sdk.channel.MessageKind.REPLY) + .replyTo(org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef(chat(), "sent")) + .build() + + val segments = OneBotMessages.outgoing(chat(), message).segments + assertEquals(listOf(OneBotSegment.Text("hi")), segments) + } + + @Test + fun `a reply to another message leads with the reply segment`() { + val message = OutboundMessage.builder("hi", org.foedusprogramme.alexandrite.sdk.channel.MessageKind.REPLY) + .replyTo(org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef(chat(), "42")) + .build() + val segments = OneBotMessages.outgoing(chat(), message).segments + assertEquals( + OneBotSegment.Reply(org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId("42")), + segments[0], + ) + assertEquals(OneBotSegment.Text("hi"), segments[1]) + } + + @Test + fun `a chat address is read back into the user or the group it names`() { + assertEquals("12345678", OneBotMessages.privateUser(chat())?.value) + assertNull(OneBotMessages.group(chat())) + assertEquals("100100", OneBotMessages.group(ChatAddress(instance, "group:100100"))?.value) + assertNull(OneBotMessages.privateUser(ChatAddress(instance, "group:100100"))) + assertTrue(OneBotMessages.isGroup(ChatAddress(instance, "group:100100"))) + } + + @Test + fun `a delivered message names what the implementation answered`() { + val answer = kotlinx.serialization.json.buildJsonObject { put("message_id", 7) } + val delivery = OneBotMessages.delivery(OneBotResult.Ok(answer), chat()) + assertEquals("7", assertIs(delivery).messages.single().id) + } + + @Test + fun `a refused send becomes the failure the retcode means`() { + val limited = OneBotMessages.delivery(OneBotResult.Failed(1004), chat()) + assertEquals(DeliveryFailure.RATE_LIMITED, assertIs(limited).kind) + val forbidden = OneBotMessages.delivery(OneBotResult.Failed(1003), chat()) + assertEquals(DeliveryFailure.FORBIDDEN, assertIs(forbidden).kind) + } + + @Test + fun `an unreachable peer is a transient failure`() { + val delivery = OneBotMessages.delivery( + OneBotResult.Unreachable( + org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailure( + org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotFailureKind.TIMEOUT, + "no answer", + ), + ), + chat(), + ) + val notDelivered = assertIs(delivery) + assertEquals(DeliveryFailure.TRANSIENT, notDelivered.kind) + assertTrue(notDelivered.retryable) + } + + @Test + fun `the capabilities of the channel name what it can do`() { + val capabilities = OneBotMessages.capabilities(proactive = true) + assertTrue(capabilities.markups.contains(Markup.PLAIN)) + assertTrue(capabilities.markups.contains(Markup.MARKDOWN)) + assertTrue(capabilities.proactive) + assertTrue(!OneBotMessages.capabilities(proactive = false).proactive) + assertTrue(capabilities.streaming) + assertTrue(!OneBotMessages.capabilities(proactive = true, streaming = false).streaming) + assertNull(capabilities.maxPartsPerReply) + } + + private fun chat(): ChatAddress = ChatAddress(instance, "private:12345678") + + /** The JSON of a report, as the codec reads it. */ + private fun parse(json: String): kotlinx.serialization.json.JsonElement = Json.parseToJsonElement(json) + + private fun privateEvent(message: String = "hello there"): OneBotEvent.Message = + assertIs(OneBotEventCodec.decode(parse(privateJson(message = message)))) + + private fun privateJson(subType: String = "friend", groupId: Int? = null, message: String = "hello there"): String = + buildString { + append("""{"time":1515204254,"self_id":10001000,"post_type":"message","message_type":"private",""") + append(""""sub_type":"$subType","message_id":12,"user_id":12345678,""") + groupId?.let { append(""""group_id":$it,""") } + append(""""message":"$message","raw_message":"$message",""") + append(""""sender":{"user_id":12345678,"nickname":"someone","sex":"male","age":18}}""") + } + + private fun groupEvent(role: String = "member", anonymous: Boolean = false): OneBotEvent.Message = assertIs( + OneBotEventCodec.decode( + parse( + buildString { + append( + """{"time":1515204254,"self_id":10001000,"post_type":"message","message_type":"group",""", + ) + append(""""sub_type":"normal","message_id":12,"group_id":100100,"user_id":12345678,""") + append(""""message":"hi","raw_message":"hi",""") + if (anonymous) append(""""anonymous":{"id":1,"name":"no one","flag":"abc"},""") + append( + """"sender":{"user_id":12345678,"nickname":"someone","card":"the card","role":"$role"}}""", + ) + }, + ), + ), + ) +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotNoticesTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotNoticesTest.kt new file mode 100644 index 00000000..679bf160 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/mapping/OneBotNoticesTest.kt @@ -0,0 +1,248 @@ +package org.foedusprogramme.alexandrite.channel.onebot.mapping + +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.SelfId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.sdk.channel.ChannelNoticeKind +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** What a report that is not a message becomes. */ +class OneBotNoticesTest { + private val instance = ChannelInstanceId(ChannelType("onebot"), "main") + private val bot = SelfId("10001000") + private val group = GroupId("987654") + private val member = UserId("2222") + private val other = UserId("3333") + + @Test + fun `a member joining names who joined and who let them in`() { + val notice = notice( + OneBotEvent.Notice.GroupIncrease( + time = 1515204254, + selfId = bot, + groupId = group, + userId = member, + operatorId = other, + subType = OneBotEvent.Notice.APPROVE, + raw = JsonObject(emptyMap()), + ), + ) + assertEquals(ChannelNoticeKind.MEMBER_JOINED, notice.kind) + assertEquals("group:987654", notice.chat.chat) + assertEquals(other.value, notice.actor?.address?.user) + assertEquals(member.value, notice.target?.address?.user) + assertEquals(listOf("how" to "approve"), facts(notice)) + } + + @Test + fun `a member who joined on their own names no actor`() { + val notice = notice( + OneBotEvent.Notice.GroupIncrease(1515204254, bot, group, member, member, null, JsonObject(emptyMap())), + ) + assertNull(notice.actor) + assertEquals(member.value, notice.target?.address?.user) + } + + @Test + fun `a member leaving by a kick names the one who kicked`() { + val notice = notice( + OneBotEvent.Notice.GroupDecrease( + 1515204254, + bot, + group, + member, + other, + OneBotEvent.Notice.KICK, + JsonObject(emptyMap()), + ), + ) + assertEquals(ChannelNoticeKind.MEMBER_LEFT, notice.kind) + assertEquals(other.value, notice.actor?.address?.user) + assertEquals(member.value, notice.target?.address?.user) + } + + @Test + fun `a mute says how long it lasts`() { + val notice = notice( + OneBotEvent.Notice.GroupBan(1515204254, bot, group, member, other, true, 600, null, JsonObject(emptyMap())), + ) + assertEquals(ChannelNoticeKind.MEMBER_BANNED, notice.kind) + assertEquals(listOf("muted" to "yes", "seconds" to "600"), facts(notice)) + } + + @Test + fun `an administrator set says which way it went`() { + val notice = notice( + OneBotEvent.Notice.GroupAdmin(1515204254, bot, group, member, false, JsonObject(emptyMap())), + ) + assertEquals(ChannelNoticeKind.ROLE_CHANGED, notice.kind) + assertEquals(listOf("administrator" to "no"), facts(notice)) + } + + @Test + fun `a recall names the message that was taken back`() { + val notice = notice( + OneBotEvent.Notice.GroupRecall( + 1515204254, + bot, + group, + member, + other, + MessageId("77"), + null, + JsonObject(emptyMap()), + ), + ) + assertEquals(ChannelNoticeKind.MESSAGE_RECALLED, notice.kind) + assertEquals("77", notice.message?.id) + assertEquals(other.value, notice.actor?.address?.user) + assertEquals(member.value, notice.target?.address?.user) + } + + @Test + fun `a friend recalled in a direct chat`() { + val notice = notice( + OneBotEvent.Notice.FriendRecall(1515204254, bot, member, MessageId("88"), null, JsonObject(emptyMap())), + ) + assertEquals(ChannelNoticeKind.MESSAGE_RECALLED, notice.kind) + assertEquals("private:2222", notice.chat.chat) + assertEquals("88", notice.message?.id) + } + + @Test + fun `a new friend is said in the direct chat of that friend`() { + val notice = notice( + OneBotEvent.Notice.FriendAdd(1515204254, bot, member, null, JsonObject(emptyMap())), + ) + assertEquals(ChannelNoticeKind.FRIEND_ADDED, notice.kind) + assertEquals("private:2222", notice.chat.chat) + } + + @Test + fun `a friend request carries what was said and what answers it`() { + val notice = notice( + OneBotEvent.Request.Friend(1515204254, bot, "flag-1", "hello", member, JsonObject(emptyMap())), + ) + assertEquals(ChannelNoticeKind.FRIEND_REQUESTED, notice.kind) + assertEquals("private:2222", notice.chat.chat) + assertEquals(listOf("answer with" to "flag-1", "said" to "hello"), facts(notice)) + } + + @Test + fun `a request to join a group is said in that group`() { + val notice = notice( + OneBotEvent.Request.Group( + 1515204254, + bot, + "flag-2", + "let me in", + member, + group, + OneBotEvent.Request.INVITE, + JsonObject(emptyMap()), + ), + ) + assertEquals(ChannelNoticeKind.JOIN_REQUESTED, notice.kind) + assertEquals("group:987654", notice.chat.chat) + assertEquals(listOf("answer with" to "flag-2", "said" to "let me in", "how" to "invite"), facts(notice)) + } + + @Test + fun `a poke says who was poked`() { + val notice = notice( + OneBotEvent.Notice.Notify( + 1515204254, + bot, + group, + member, + other, + OneBotEvent.Notice.POKE, + null, + JsonObject(emptyMap()), + ), + ) + assertEquals("notice.poke", notice.kind.id) + assertEquals(member.value, notice.actor?.address?.user) + assertEquals(other.value, notice.target?.address?.user) + } + + @Test + fun `an upload says which file and how large`() { + val notice = notice( + OneBotEvent.Notice.GroupUpload( + 1515204254, + bot, + group, + member, + OneBotEvent.Notice.File("f1", "notes.zip", 2048, null), + JsonObject(emptyMap()), + ), + ) + assertEquals("group.file_uploaded", notice.kind.id) + assertEquals(listOf("file" to "notes.zip", "bytes" to "2048"), facts(notice)) + } + + @Test + fun `a notice this version does not model keeps the name the implementation gave it`() { + val notice = notice( + OneBotEvent.Notice.Other( + time = 1515204254, + selfId = bot, + noticeType = "group_msg_emoji_like", + raw = kotlinx.serialization.json.buildJsonObject { + put("group_id", kotlinx.serialization.json.JsonPrimitive(987654)) + put("user_id", kotlinx.serialization.json.JsonPrimitive(2222)) + }, + subType = null, + ), + ) + assertEquals("group_msg_emoji_like", notice.kind.id) + assertEquals("group:987654", notice.chat.chat) + assertEquals(member.value, notice.actor?.address?.user) + } + + @Test + fun `a notice this version does not model and that names no chat is published as nothing`() { + val notice = OneBotNotices.of( + instance, + OneBotEvent.Notice.Other(1515204254, bot, "something_new", JsonObject(emptyMap()), null), + bot.value, + ) + assertNull(notice) + } + + @Test + fun `a report that is not a notice or a request is published as nothing`() { + val heartbeat = OneBotEvent.Meta.Lifecycle( + time = 1515204254, + selfId = bot, + subType = "connect", + raw = JsonObject(emptyMap()), + ) + assertNull(OneBotNotices.of(instance, heartbeat, bot.value)) + } + + /** The notice a report of [event] becomes, which the test expects one of. */ + private fun notice(event: OneBotEvent): org.foedusprogramme.alexandrite.sdk.channel.ChannelNotice = + kotlin.test.assertNotNull(OneBotNotices.of(instance, event, bot.value), "no notice was published") + + /** The facts of [notice] as pairs of label and value. */ + private fun facts(notice: org.foedusprogramme.alexandrite.sdk.channel.ChannelNotice): List> = + notice.facts.map { it.label to it.value } + + @Test + fun `the notice of a group names the group chat`() { + val notice = notice( + OneBotEvent.Notice.GroupAdmin(1515204254, bot, group, member, true, JsonObject(emptyMap())), + ) + assertTrue(notice.chat.chat.startsWith("group:")) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRegistryTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRegistryTest.kt new file mode 100644 index 00000000..47b90e2a --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/api/OneBotRegistryTest.kt @@ -0,0 +1,125 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.api + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OneBotRegistryTest { + /** The actions `api/public.md` lists, in its order. */ + private val documented = listOf( + "send_private_msg", + "send_group_msg", + "send_msg", + "delete_msg", + "get_msg", + "get_forward_msg", + "send_like", + "set_group_kick", + "set_group_ban", + "set_group_anonymous_ban", + "set_group_whole_ban", + "set_group_admin", + "set_group_anonymous", + "set_group_card", + "set_group_name", + "set_group_leave", + "set_group_special_title", + "set_friend_add_request", + "set_group_add_request", + "get_login_info", + "get_stranger_info", + "get_friend_list", + "get_group_info", + "get_group_list", + "get_group_member_info", + "get_group_member_list", + "get_group_honor_info", + "get_cookies", + "get_csrf_token", + "get_credentials", + "get_record", + "get_image", + "can_send_image", + "can_send_record", + "get_status", + "get_version_info", + "set_restart", + "clean_cache", + ) + + @Test + fun `the registry lists every public action of the standard`() { + assertEquals(38, documented.size) + assertEquals(documented, OneBotRegistry.actions.map { it.name }) + assertTrue(OneBotRegistry.actions.none { it.hidden }) + assertTrue(OneBotRegistry.actions.none { it.name.startsWith(".") }) + } + + @Test + fun `the registry lists the hidden action apart from the public ones`() { + assertTrue(OneBotRegistry.QUICK_OPERATION.hidden) + assertEquals(OneBotCategory.HIDDEN, OneBotRegistry.QUICK_OPERATION.category) + assertEquals(".handle_quick_operation", OneBotRegistry.QUICK_OPERATION.name) + assertEquals(listOf(OneBotRegistry.QUICK_OPERATION), OneBotRegistry.hiddenActions) + assertEquals(OneBotRegistry.QUICK_OPERATION, OneBotRegistry.action(".handle_quick_operation")) + } + + @Test + fun `every public action resolves under its own name`() { + for (name in documented) { + val call = OneBotRegistry.resolve(name) + assertTrue(call is OneBotCall.Known, "$name is not known") + assertEquals(name, call.action) + assertEquals(OneBotCallSuffix.NONE, call.suffix) + assertTrue(call.isSupported) + } + } + + @Test + fun `every public action resolves under the async and the rate limited suffix`() { + for (name in documented) { + for (suffix in listOf(OneBotCallSuffix.ASYNC, OneBotCallSuffix.RATE_LIMITED)) { + val call = OneBotRegistry.resolve(name + suffix.suffix) + assertTrue(call is OneBotCall.Known, "${name + suffix.suffix} is not known") + assertEquals(name, call.definition.name) + assertEquals(suffix, call.suffix) + assertTrue(call.isSupported, "${name + suffix.suffix} is not accepted") + } + } + } + + @Test + fun `the hidden action takes no derived suffix`() { + val call = OneBotRegistry.resolve(".handle_quick_operation_async") + assertTrue(call is OneBotCall.Known) + assertEquals(".handle_quick_operation", call.definition.name) + assertEquals(OneBotCallSuffix.ASYNC, call.suffix) + assertFalse(call.isSupported) + } + + @Test + fun `an action of an implementation is kept as it was named`() { + val call = OneBotRegistry.resolve("napcat_get_group_at_all_remain") + assertTrue(call is OneBotCall.Unknown) + assertEquals("napcat_get_group_at_all_remain", call.action) + assertEquals("napcat_get_group_at_all_remain", call.name) + assertEquals(OneBotCallSuffix.NONE, call.suffix) + assertFalse(OneBotRegistry.knows(call.action)) + + val async = OneBotRegistry.resolve("napcat_get_group_at_all_remain_async") + assertTrue(async is OneBotCall.Unknown) + assertEquals("napcat_get_group_at_all_remain_async", async.action) + assertEquals("napcat_get_group_at_all_remain", async.name) + assertEquals(OneBotCallSuffix.ASYNC, async.suffix) + } + + @Test + fun `an action name resolves to the action the registry holds`() { + assertEquals(OneBotRegistry.QUICK_OPERATION, OneBotRegistry.action(".handle_quick_operation")) + assertEquals("send_msg", OneBotRegistry.action("send_msg")?.name) + assertNull(OneBotRegistry.action("send_msg_async")) + assertNull(OneBotRegistry.action("no_such_action")) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodecTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodecTest.kt new file mode 100644 index 00000000..e8f6e09d --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/event/OneBotEventCodecTest.kt @@ -0,0 +1,394 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.event + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.SelfId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotMessage +import org.foedusprogramme.alexandrite.channel.onebot.protocol.message.OneBotSegment +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class OneBotEventCodecTest { + @Test + fun `a private message event keeps its sender and its message`() { + val event = assertIs(OneBotEventCodec.decode(privateMessage())) + assertEquals(1515204254L, event.time) + assertEquals(SelfId("10001000"), event.selfId) + assertEquals(MessageId("12"), event.messageId) + assertEquals(UserId("12345678"), event.userId) + assertEquals(OneBotEvent.Message.FRIEND, event.subType) + assertEquals("hello [CQ:at,qq=1]", event.rawMessage) + assertEquals(listOf(OneBotSegment.Text("hello "), OneBotSegment.At("1")), event.message.segments) + assertEquals("someone", event.sender.nickname) + assertEquals(18L, event.sender.age) + assertFalse(event.sender.isAdmin) + } + + @Test + fun `a group message event keeps the group and who sent it`() { + val raw = base("message") { + put("message_type", "group") + put("sub_type", "normal") + put("message_id", 12) + put("group_id", 100100) + put("user_id", 12345678) + put("message", "hi") + put("raw_message", "hi") + putJsonObject("sender") { + put("nickname", "someone") + put("card", "the card") + put("role", "admin") + } + } + val event = assertIs(OneBotEventCodec.decode(raw)) + assertEquals(GroupId("100100"), event.groupId) + assertEquals("the card", event.sender.card) + assertTrue(event.sender.isAdmin) + assertFalse(event.sender.isOwner) + assertNull(event.anonymous) + assertFalse(event.isAnonymous) + } + + @Test + fun `an anonymous group message keeps the flag the ban API takes`() { + val raw = base("message") { + put("message_type", "group") + put("sub_type", "anonymous") + put("group_id", 100100) + put("user_id", 12345678) + put("message", "hi") + putJsonObject("anonymous") { + put("id", 1) + put("name", "no one") + put("flag", "abc") + } + } + val event = assertIs(OneBotEventCodec.decode(raw)) + assertTrue(event.isAnonymous) + assertEquals("abc", event.anonymous?.flag) + assertEquals("no one", event.anonymous?.name) + } + + @Test + fun `an unknown field of an implementation stays in the raw event`() { + val raw = base("message") { + put("message_type", "private") + put("sub_type", "friend") + put("message_id", 12) + put("user_id", 12345678) + put("message", "hi") + put("napcat_extra", "kept") + } + val event = OneBotEventCodec.decode(raw) + assertEquals("kept", event.raw["napcat_extra"]?.let { assertIs(it).content }) + assertEquals(raw, event.raw) + } + + @Test + fun `every notice the standard lists decodes to its own type`() { + val group = { block: kotlinx.serialization.json.JsonObjectBuilder.() -> Unit -> + base("notice") { + put("group_id", 100100) + put("user_id", 12345678) + block() + } + } + + val upload = + group { + put("notice_type", "group_upload") + putJsonObject("file") { + put("id", "f") + put("name", "n") + put("size", 3) + put("busid", 1) + } + } + val uploadEvent = assertIs(OneBotEventCodec.decode(upload)) + assertEquals("f", uploadEvent.file.id) + assertEquals(3L, uploadEvent.file.size) + + val admin = group { + put("notice_type", "group_admin") + put("sub_type", "set") + } + assertTrue(assertIs(OneBotEventCodec.decode(admin)).isSet) + + val decrease = group { + put("notice_type", "group_decrease") + put("sub_type", "kick") + put("operator_id", 1) + } + assertEquals("kick", assertIs(OneBotEventCodec.decode(decrease)).subType) + + val increase = group { + put("notice_type", "group_increase") + put("sub_type", "approve") + put("operator_id", 1) + } + assertEquals("approve", assertIs(OneBotEventCodec.decode(increase)).subType) + + val ban = + group { + put("notice_type", "group_ban") + put("sub_type", "ban") + put("operator_id", 1) + put("duration", 60) + } + val banEvent = assertIs(OneBotEventCodec.decode(ban)) + assertTrue(banEvent.isBan) + assertEquals(60L, banEvent.duration) + + val lift = group { + put("notice_type", "group_ban") + put("sub_type", "lift_ban") + put("operator_id", 1) + } + assertFalse(assertIs(OneBotEventCodec.decode(lift)).isBan) + + val friend = base("notice") { + put("notice_type", "friend_add") + put("user_id", 1) + } + assertEquals(UserId("1"), assertIs(OneBotEventCodec.decode(friend)).userId) + + val recall = group { + put("notice_type", "group_recall") + put("operator_id", 1) + put("message_id", 7) + } + assertEquals( + MessageId("7"), + assertIs(OneBotEventCodec.decode(recall)).messageId, + ) + + val friendRecall = + base("notice") { + put("notice_type", "friend_recall") + put("user_id", 1) + put("message_id", 7) + } + assertEquals( + MessageId("7"), + assertIs(OneBotEventCodec.decode(friendRecall)).messageId, + ) + + val poke = group { + put("notice_type", "notify") + put("sub_type", "poke") + put("target_id", 2) + } + val pokeEvent = assertIs(OneBotEventCodec.decode(poke)) + assertEquals(OneBotEvent.Notice.POKE, pokeEvent.subType) + assertEquals(UserId("2"), pokeEvent.targetId) + assertNull(pokeEvent.honorType) + + val king = group { + put("notice_type", "notify") + put("sub_type", "lucky_king") + put("target_id", 2) + } + assertEquals( + OneBotEvent.Notice.LUCKY_KING, + assertIs(OneBotEventCodec.decode(king)).subType, + ) + + val honor = group { + put("notice_type", "notify") + put("sub_type", "honor") + put("honor_type", "talkative") + } + assertEquals("talkative", assertIs(OneBotEventCodec.decode(honor)).honorType) + + val other = group { + put("notice_type", "napcat_notice") + put("sub_type", "x") + } + val otherEvent = assertIs(OneBotEventCodec.decode(other)) + assertEquals("napcat_notice", otherEvent.noticeType) + assertEquals("x", otherEvent.subType) + } + + @Test + fun `every request the standard lists decodes to its own type`() { + val friend = base("request") { + put("request_type", "friend") + put("user_id", 1) + put("comment", "hi") + put("flag", "f") + } + val friendEvent = assertIs(OneBotEventCodec.decode(friend)) + assertEquals("f", friendEvent.flag) + assertEquals("hi", friendEvent.comment) + assertEquals(setOf("approve", "remark", "reason"), friendEvent.quickOperations) + + val add = base("request") { + put("request_type", "group") + put("sub_type", "add") + put("group_id", 100100) + put("user_id", 1) + put("flag", "f") + } + val addEvent = assertIs(OneBotEventCodec.decode(add)) + assertEquals(GroupId("100100"), addEvent.groupId) + assertEquals(OneBotEvent.Request.ADD, addEvent.subType) + + val invite = + base("request") { + put("request_type", "group") + put("sub_type", "invite") + put("group_id", 1) + put("user_id", 1) + } + assertEquals( + OneBotEvent.Request.INVITE, + assertIs(OneBotEventCodec.decode(invite)).subType, + ) + + val other = base("request") { + put("request_type", "napcat_request") + put("user_id", 1) + put("flag", "f") + } + assertEquals("napcat_request", assertIs(OneBotEventCodec.decode(other)).requestType) + } + + @Test + fun `every meta event the standard lists decodes to its own type`() { + val lifecycle = base("meta_event") { + put("meta_event_type", "lifecycle") + put("sub_type", "connect") + } + assertEquals( + OneBotEvent.Meta.CONNECT, + assertIs(OneBotEventCodec.decode(lifecycle)).subType, + ) + + val heartbeat = base("meta_event") { + put("meta_event_type", "heartbeat") + putJsonObject("status") { + put("online", true) + put("good", true) + } + put("interval", 15000) + } + val heartbeatEvent = assertIs(OneBotEventCodec.decode(heartbeat)) + assertEquals(15000L, heartbeatEvent.interval) + assertEquals(JsonPrimitive(true), heartbeatEvent.status["online"]) + + val other = base("meta_event") { put("meta_event_type", "napcat_meta") } + assertEquals("napcat_meta", assertIs(OneBotEventCodec.decode(other)).metaEventType) + } + + @Test + fun `an event of a kind this version does not model keeps its JSON`() { + val raw = base("napcat_post") { put("whatever", 1) } + val event = assertIs(OneBotEventCodec.decode(raw)) + assertEquals("napcat_post", event.postType) + assertEquals(raw, event.raw) + } + + @Test + fun `a message event of a type this version does not model stays unknown`() { + val raw = base("message") { + put("message_type", "channel") + put("message", "hi") + } + val event = assertIs(OneBotEventCodec.decode(raw)) + // `postType` is the top-level `post_type`, not the subtype this version cannot read, which stays in the raw + // JSON: a consumer that asks what kind of event this is has to be told that it is a message. + assertEquals("message", event.postType) + assertEquals("channel", (raw["message_type"] as? JsonPrimitive)?.content) + assertEquals(raw, event.raw) + } + + @Test + fun `the quick operations of a group message are the ones the standard lists`() { + val raw = + base("message") { + put("message_type", "group") + put("group_id", 1) + put("user_id", 1) + put("message", "hi") + } + val event = assertIs(OneBotEventCodec.decode(raw)) + assertEquals( + setOf("reply", "auto_escape", "at_sender", "delete", "kick", "ban", "ban_duration"), + event.quickOperations, + ) + } + + @Test + fun `a string message and an array message decode the same way`() { + val asString = + base("message") { + put("message_type", "private") + put("user_id", 1) + put("message", "hi [CQ:at,qq=2]") + } + val asArray = base("message") { + put("message_type", "private") + put("user_id", 1) + putJsonArray("message") { + add( + buildJsonObject { + put("type", "text") + putJsonObject("data") { put("text", "hi ") } + }, + ) + add( + buildJsonObject { + put("type", "at") + putJsonObject("data") { put("qq", "2") } + }, + ) + } + } + val expected = listOf(OneBotSegment.Text("hi "), OneBotSegment.At("2")) + assertEquals( + expected, + assertIs(OneBotEventCodec.decode(asString)).message.segments, + ) + assertEquals( + expected, + assertIs(OneBotEventCodec.decode(asArray)).message.segments, + ) + assertEquals(OneBotMessage.StringValue("hi [CQ:at,qq=2]").segments, expected) + } + + private fun base(postType: String, block: kotlinx.serialization.json.JsonObjectBuilder.() -> Unit): JsonObject = + buildJsonObject { + put("time", 1515204254L) + put("self_id", 10001000L) + put("post_type", postType) + block() + } + + private fun privateMessage(): JsonObject = base("message") { + put("message_type", "private") + put("sub_type", "friend") + put("message_id", 12) + put("user_id", 12345678) + put("message", "hello [CQ:at,qq=1]") + put("raw_message", "hello [CQ:at,qq=1]") + put("font", 456) + putJsonObject("sender") { + put("user_id", 12345678) + put("nickname", "someone") + put("sex", "male") + put("age", 18) + } + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCodeTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCodeTest.kt new file mode 100644 index 00000000..35768e4d --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/CqCodeTest.kt @@ -0,0 +1,273 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.message + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import org.foedusprogramme.alexandrite.channel.onebot.protocol.GroupId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.MessageId +import org.foedusprogramme.alexandrite.channel.onebot.protocol.UserId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class CqCodeTest { + @Test + fun `plain text is one segment and is escaped`() { + assertEquals(listOf(OneBotSegment.Text("hello")), CqCode.decode("hello")) + assertEquals(listOf(OneBotSegment.Text("a[b]c")), CqCode.decode("a[b]c")) + // A comma means nothing in the text outside a code, so the entity is only a comma inside a parameter: plain + // text keeps `,` as the characters it is. + assertEquals(listOf(OneBotSegment.Text("x,y")), CqCode.decode("x,y")) + assertEquals(listOf(OneBotSegment.Text("x,y")), CqCode.decode("x,y")) + assertEquals(listOf(OneBotSegment.Text("a&b")), CqCode.decode("a&b")) + assertEquals(emptyList(), CqCode.decode("")) + } + + @Test + fun `a code becomes its segment and the text around it becomes text segments`() { + assertEquals( + listOf( + OneBotSegment.Text("hi "), + OneBotSegment.At("10001000"), + OneBotSegment.Text(" there"), + ), + CqCode.decode("hi [CQ:at,qq=10001000] there"), + ) + } + + @Test + fun `a code is escaped when it is written again`() { + assertEquals( + "hi [CQ:at,qq=10001000] there", + CqCode.encode(CqCode.decode("hi [CQ:at,qq=10001000] there")), + ) + assertEquals("[CQ:at,qq=all]", CqCode.encode(OneBotSegment.At(OneBotSegment.At.ALL))) + assertEquals("[CQ:face,id=123]", CqCode.encode(OneBotSegment.Face("123"))) + assertEquals("[CQ:reply,id=123456]", CqCode.encode(OneBotSegment.Reply(MessageId("123456")))) + } + + @Test + fun `a text keeps the CQ code entities in it`() { + val text = "look [CQ:face,id=1] and [CQ:not a code], ok&" + val decoded = CqCode.decode(text) + assertEquals(OneBotSegment.Text("look "), decoded[0]) + assertEquals(OneBotSegment.Face("1"), decoded[1]) + assertEquals(" and [CQ:not a code], ok&", assertIs(decoded[2]).text) + // A comma is not an entity of plain text, so writing this text escapes only its `&`, and reading that back + // returns the text the segments hold. + assertEquals(decoded, CqCode.decode(CqCode.encode(decoded))) + } + + @Test + fun `a code that names a type this version does not know keeps its parameters`() { + val decoded = CqCode.decode("[CQ:napcat_thing,key=value]") + val unknown = assertIs(decoded.single()) + assertEquals("napcat_thing", unknown.type) + assertEquals("value", unknown.data["key"]?.jsonPrimitive?.content) + assertEquals("[CQ:napcat_thing,key=value]", CqCode.encode(unknown)) + } + + @Test + fun `an unterminated code stays plain text`() { + assertEquals(listOf(OneBotSegment.Text("[CQ:at,qq=1")), CqCode.decode("[CQ:at,qq=1")) + } + + @Test + fun `the segments of the standard survive a CQ round trip`() { + for (type in OneBotSegment.KNOWN_TYPES) { + if (type == OneBotSegment.TEXT) continue + val segment = CqCode.decode(sample(type)).single() + assertEquals(type, segment.type, "$type did not decode to itself") + val encoded = CqCode.encode(segment) + val again = CqCode.decode(encoded).single() + assertEquals(segment, again, "$type did not survive its CQ code '$encoded'") + } + } + + @Test + fun `every segment type of the standard has a sample, except the plain text one`() { + val expected = OneBotSegment.KNOWN_TYPES.toSet() - OneBotSegment.TEXT + assertEquals(expected, OneBotSegment.KNOWN_TYPES.filter { sample(it).isNotEmpty() }.toSet()) + } + + private fun sample(type: String): String = when (type) { + OneBotSegment.TEXT -> "" + + OneBotSegment.FACE -> "[CQ:face,id=123]" + + OneBotSegment.IMAGE -> + "[CQ:image,file=1.jpg,type=flash,url=http://example.test/1.jpg,cache=1,proxy=0,timeout=5]" + + OneBotSegment.RECORD -> "[CQ:record,file=1.mp3,magic=1,url=http://example.test/1.mp3]" + + OneBotSegment.VIDEO -> "[CQ:video,file=1.mp4,url=http://example.test/1.mp4]" + + OneBotSegment.AT -> "[CQ:at,qq=10001000]" + + OneBotSegment.RPS -> "[CQ:rps]" + + OneBotSegment.DICE -> "[CQ:dice]" + + OneBotSegment.SHAKE -> "[CQ:shake]" + + OneBotSegment.POKE -> "[CQ:poke,type=126,id=2003,name=hello]" + + OneBotSegment.ANONYMOUS -> "[CQ:anonymous,ignore=1]" + + OneBotSegment.SHARE -> "[CQ:share,url=http://baidu.com,title=baidu]" + + OneBotSegment.CONTACT -> "[CQ:contact,type=qq,id=10001000]" + + OneBotSegment.LOCATION -> "[CQ:location,lat=39.9,lon=116.3]" + + OneBotSegment.MUSIC -> "[CQ:music,type=163,id=28949129]" + + OneBotSegment.REPLY -> "[CQ:reply,id=123456]" + + OneBotSegment.FORWARD -> "[CQ:forward,id=abc]" + + OneBotSegment.NODE -> "[CQ:node,user_id=10001000,nickname=someone,content=hello]" + + OneBotSegment.XML -> "[CQ:xml,data=body]" + + OneBotSegment.JSON -> "[CQ:json,data=body]" + + else -> "" + } + + @Test + fun `the codec reads the three shapes of a message`() { + val text = JsonPrimitive("hi [CQ:at,qq=1]") + val array = JsonArray( + listOf( + buildJsonObject { + put("type", "text") + putJsonObject("data") { put("text", "hi ") } + }, + buildJsonObject { + put("type", "at") + putJsonObject("data") { put("qq", "1") } + }, + ), + ) + val single = buildJsonObject { + put("type", "at") + putJsonObject("data") { put("qq", "1") } + } + + val string = assertIs(OneBotSegmentCodec.decodeMessage(text)) + assertEquals(listOf(OneBotSegment.Text("hi "), OneBotSegment.At("1")), string.segments) + + val list = assertIs(OneBotSegmentCodec.decodeMessage(array)) + assertEquals(listOf(OneBotSegment.Text("hi "), OneBotSegment.At("1")), list.segments) + + val one = assertIs(OneBotSegmentCodec.decodeMessage(single)) + assertEquals(OneBotSegment.At("1"), one.segment) + } + + @Test + fun `an optional parameter of a segment is left out when it is absent`() { + // The standard's request contract has no room for a parameter that is present and null: an implementation + // reading `type` as a string meets a null, and one reading `timeout` as a number does too. + val written = OneBotSegmentCodec.encodeSegment(OneBotSegment.Image(file = "1.jpg")) + + val data = assertIs(written["data"]) + assertEquals(setOf("file"), data.keys) + } + + @Test + fun `a segment keeps its data and writes type once`() { + val element = buildJsonObject { + put("type", "image") + putJsonObject("data") { + put("file", "1.jpg") + put("type", "flash") + put("url", "http://example.test/1.jpg") + } + } + val segment = assertIs(OneBotSegmentCodec.decodeSegment(element)) + assertEquals("1.jpg", segment.file) + assertEquals("flash", segment.subtype) + + val written = OneBotSegmentCodec.encodeSegment(segment) + assertEquals("image", written["type"]?.jsonPrimitive?.content) + val data = assertIs(written["data"]) + assertEquals("flash", data["type"]?.jsonPrimitive?.content) + assertEquals("1.jpg", data["file"]?.jsonPrimitive?.content) + } + + @Test + fun `a segment of an unknown type keeps its whole data object`() { + val element = buildJsonObject { + put("type", "napcat_thing") + putJsonObject("data") { + put("key", "value") + putJsonObject("nested") { put("deep", 1) } + } + } + val unknown = assertIs(OneBotSegmentCodec.decodeSegment(element)) + assertEquals("napcat_thing", unknown.type) + assertEquals(element, OneBotSegmentCodec.encodeSegment(unknown)) + + val message = OneBotSegmentCodec.decodeMessage(element) + assertEquals(listOf(unknown), message.segments) + } + + @Test + fun `an image message writes its segments as an array`() { + val message = OneBotMessage.text("hi") + val written = OneBotSegmentCodec.encodeMessageArray(message) + assertEquals(1, written.size) + assertTrue(assertIs(written[0]).containsKey("type")) + } + + @Test + fun `a string message stays a string in the shape it is written in`() { + val message = OneBotMessage.StringValue("[CQ:at,qq=1]") + assertEquals(JsonPrimitive("[CQ:at,qq=1]"), OneBotSegmentCodec.encodeMessage(message)) + assertEquals(listOf(OneBotSegment.At("1")), message.segments) + } + + @Test + fun `a node of a forwarded message keeps the message inside it`() { + val element = buildJsonObject { + put("type", "node") + putJsonObject("data") { + put("user_id", 10001000) + put("nickname", "someone") + put( + "content", + JsonArray( + listOf( + buildJsonObject { + put("type", "text") + putJsonObject("data") { put("text", "hi") } + }, + ), + ), + ) + } + } + val node = assertIs(OneBotSegmentCodec.decodeSegment(element)) + assertEquals(UserId("10001000"), node.userId) + assertEquals("someone", node.nickname) + assertEquals(listOf(OneBotSegment.Text("hi")), node.content?.segments) + } + + @Test + fun `a number of the platform is read as a string and written as a number`() { + val element = buildJsonObject { + put("type", "at") + putJsonObject("data") { put("qq", 10001000) } + } + val at = assertIs(OneBotSegmentCodec.decodeSegment(element)) + assertEquals("10001000", at.userId) + assertEquals(element, OneBotSegmentCodec.encodeSegment(at)) + assertEquals(GroupId("1").number, 1L) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotFileSegmentTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotFileSegmentTest.kt new file mode 100644 index 00000000..b222b7cf --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/protocol/message/OneBotFileSegmentTest.kt @@ -0,0 +1,99 @@ +package org.foedusprogramme.alexandrite.channel.onebot.protocol.message + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The file segment, which the standard does not declare and an implementation may report. + * + * A file reaches a message as a segment of its own where the implementation offers one, so a report carrying it is + * read rather than kept as something unknown, and what the channel was told of it is written back the way it came. + */ +class OneBotFileSegmentTest { + private val file = OneBotSegment.File(file = "a1b2", name = "notes.zip", url = "http://host/notes.zip") + + @Test + fun `a file is written with what it is known by, its name and where it can be read`() { + val written = OneBotSegmentCodec.encodeSegment(file) + assertEquals("file", written.getValue("type").jsonPrimitive.content) + val data = written.getValue("data").jsonObject + assertEquals("a1b2", data.getValue("file").jsonPrimitive.content) + assertEquals("notes.zip", data.getValue("name").jsonPrimitive.content) + assertEquals("http://host/notes.zip", data.getValue("url").jsonPrimitive.content) + } + + @Test + fun `a file that is written is one that is read back`() { + assertEquals(file, OneBotSegmentCodec.decodeSegment(OneBotSegmentCodec.encodeSegment(file))) + } + + @Test + fun `a file that names only what it is known by is read without a name or a place`() { + val bare = OneBotSegmentCodec.decodeSegment( + buildJsonObject { + put("type", JsonPrimitive("file")) + put("data", buildJsonObject { put("file", JsonPrimitive("a1b2")) }) + }, + ) + val read = assertIs(bare) + assertEquals("a1b2", read.file) + assertNull(read.name) + assertNull(read.url) + } + + @Test + fun `what an implementation says of a file that this version does not model is left behind`() { + val read = assertIs( + OneBotSegmentCodec.decodeSegment( + buildJsonObject { + put("type", JsonPrimitive("file")) + put( + "data", + buildJsonObject { + put("file", JsonPrimitive("a1b2")) + put("something_new", JsonPrimitive(1)) + }, + ) + }, + ), + ) + assertEquals("a1b2", read.file) + } + + @Test + fun `a file is spelled as a CQ code of its own`() { + val spelled = CqCode.encode(file) + assertTrue(spelled.startsWith("[CQ:file,"), spelled) + assertTrue(spelled.contains("file=a1b2"), spelled) + assertTrue(spelled.contains("name=notes.zip"), spelled) + } + + @Test + fun `a message that carries a file holds it beside its words`() { + val segments = OneBotSegmentCodec.decodeMessageSegments( + buildJsonArray { + add(OneBotSegmentCodec.encodeSegment(OneBotSegment.Text("here"))) + add(OneBotSegmentCodec.encodeSegment(file)) + }, + ) + assertEquals(listOf("here"), segments.filterIsInstance().map { it.text }) + assertEquals(file, segments.filterIsInstance().single()) + } + + @Test + fun `what is not a file is still unknown`() { + val unknown = OneBotSegmentCodec.decodeSegment( + JsonObject(mapOf("type" to JsonPrimitive("something_else"), "data" to JsonObject(emptyMap()))), + ) + assertIs(unknown) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActDescriptionTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActDescriptionTest.kt new file mode 100644 index 00000000..2df434d4 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActDescriptionTest.kt @@ -0,0 +1,33 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * What the tool that changes something tells a model about a call that ends without an answer. + * + * Such a call may have been carried out: the request can be on its way when the call is ended, and a call ended that + * way is one nobody knows the outcome of. A model that takes it for one that did nothing sends the same thing again, + * which is what a file that reached a group twice came of, so the tool says what to do instead. + */ +class OneBotActDescriptionTest { + private val description: String = OneBotActTool(OneBotApis(), null).definition.description + + @Test + fun `it says that an unanswered call may still have been carried out`() { + assertTrue(description.contains("may still have been carried out"), description.take(240)) + } + + @Test + fun `it says to look at what a chat holds before calling again`() { + assertTrue(description.contains("look at what that chat or group holds"), description.take(240)) + assertTrue(description.contains("before calling again"), description.take(240)) + } + + @Test + fun `the tool that only reads says no such thing, since reading changes nothing`() { + val reading = OneBotReadTool(OneBotApis(), null).definition.description + assertTrue(!reading.contains("may still have been carried out"), reading.take(240)) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionsTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionsTest.kt new file mode 100644 index 00000000..5258594d --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotActionsTest.kt @@ -0,0 +1,144 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import org.foedusprogramme.alexandrite.channel.onebot.protocol.api.OneBotRegistry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What the table of actions lets the agent be asked for, and what it keeps out of reach. + * + * The table is the only thing standing between a call and the platform, so what it holds is worth asserting rather + * than trusting: an action the table leaves out cannot be named at all, and an action it holds is one the platform + * declares, unless the row says the standard does not have it. + */ +class OneBotActionsTest { + @Test + fun `every action the table holds is one the standard declares`() { + OneBotActions.rows.filterNot { it.extension }.forEach { row -> + val declared = assertNotNull(OneBotRegistry.action(row.name), "${row.name} is no declared action") + assertFalse(declared.hidden, "${row.name} is an action the standard hides") + } + } + + @Test + fun `an extension is an action the standard does not declare`() { + val emoji = assertNotNull(OneBotActions.of("set_msg_emoji_like")) + assertTrue(emoji.extension, "the emoji response is not an action of the standard") + assertNull(OneBotRegistry.action("set_msg_emoji_like"), "the standard declares no such action") + } + + @Test + fun `no action is declared twice`() { + val names = OneBotActions.rows.map { it.name } + assertEquals(names.size, names.toSet().size, names.toString()) + } + + @Test + fun `the table hands out nothing of the account`() { + listOf("get_cookies", "get_csrf_token", "get_credentials").forEach { name -> + assertNull(OneBotActions.of(name), "$name must not be reachable") + } + } + + @Test + fun `the table does not carry out anything by name`() { + listOf("raw", ".handle_quick_operation").forEach { name -> + assertNull(OneBotActions.of(name), "$name must not be reachable") + } + } + + @Test + fun `the table restarts nothing and clears nothing`() { + listOf("set_restart", "clean_cache").forEach { name -> + assertNull(OneBotActions.of(name), "$name must not be reachable") + } + } + + @Test + fun `an action that reads is not one that changes`() { + assertFalse(assertNotNull(OneBotActions.of("get_group_member_info")).changes) + assertFalse(assertNotNull(OneBotActions.of("get_msg")).changes) + assertTrue(assertNotNull(OneBotActions.of("delete_msg")).changes) + assertTrue(assertNotNull(OneBotActions.of("set_group_kick")).changes) + } + + @Test + fun `an action about a message lets the id of the message be left out`() { + listOf("delete_msg", "set_msg_emoji_like", "get_msg").forEach { name -> + val row = assertNotNull(OneBotActions.of(name)) + assertTrue(row.takesAMessage, "$name is about a message") + val parameter = assertNotNull(row.parameters.firstOrNull { it.name == OneBotActions.MESSAGE_ID }) + assertTrue(parameter.optional, "$name must take the id of the message of the turn") + } + } + + @Test + fun `an action that is not about a message has no such argument`() { + val row = assertNotNull(OneBotActions.of("set_group_ban")) + assertFalse(row.takesAMessage) + assertTrue(row.parameters.none { it.name == OneBotActions.MESSAGE_ID }) + } + + @Test + fun `every argument of every action is named and explained`() { + OneBotActions.rows.forEach { row -> + assertTrue(row.name.isNotBlank(), "an action needs a name") + assertTrue(row.said.isNotBlank(), "${row.name} says nothing when it is carried out") + assertTrue(row.parameters.isNotEmpty(), "${row.name} takes nothing") + row.parameters.forEach { parameter -> + assertTrue(parameter.name.isNotBlank(), "${row.name} has an argument with no name") + assertTrue(parameter.description.isNotBlank(), "$parameter of ${row.name} is not explained") + } + } + } + + @Test + fun `what is not declared cannot be asked for`() { + assertNull(OneBotActions.of("send_group_msg")) + assertNull(OneBotActions.of("delete_msg_async")) + assertNull(OneBotActions.of("")) + } + + @Test + fun `uploading a file takes a path of the workspace as the file`() { + listOf("upload_group_file", "upload_private_file").forEach { name -> + val row = assertNotNull(OneBotActions.of(name)) + assertTrue(row.extension, "$name is an action the standard does not declare") + val file = assertNotNull(row.parameters.firstOrNull { it.name == "file" }) + assertEquals( + OneBotActions.Kind.FILE, + file.kind, + "$name takes a file of the workspace rather than a name of one", + ) + } + } + + @Test + fun `reading a file of the platform takes its name rather than a path of the workspace`() { + val row = assertNotNull(OneBotActions.of("get_file")) + assertTrue(row.extension) + val file = assertNotNull(row.parameters.firstOrNull { it.name == "file" }) + assertEquals(OneBotActions.Kind.TEXT, file.kind) + assertTrue(row.parameters.none { it.kind == OneBotActions.Kind.FILE }) + } + + @Test + fun `the actions for files name the arguments the implementation asks for`() { + assertEquals( + listOf("group_id", "file", "name", "folder"), + assertNotNull(OneBotActions.of("upload_group_file")).parameters.map { it.name }, + ) + assertEquals( + listOf("user_id", "file", "name"), + assertNotNull(OneBotActions.of("upload_private_file")).parameters.map { it.name }, + ) + assertEquals( + listOf("group_id", "file_id", "busid"), + assertNotNull(OneBotActions.of("get_group_file_url")).parameters.map { it.name }, + ) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotFileArgumentTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotFileArgumentTest.kt new file mode 100644 index 00000000..13818ce7 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotFileArgumentTest.kt @@ -0,0 +1,180 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import org.foedusprogramme.alexandrite.sdk.InternalAlexandriteApi +import org.foedusprogramme.alexandrite.sdk.chat.AgentId +import org.foedusprogramme.alexandrite.sdk.chat.ConversationId +import org.foedusprogramme.alexandrite.sdk.tool.FloorCheck +import org.foedusprogramme.alexandrite.sdk.tool.HardFloor +import org.foedusprogramme.alexandrite.testkit.testToolContext +import java.nio.file.Files +import java.nio.file.Path +import java.util.Base64 +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeBytes +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * What a call that names a file of the workspace does with it. + * + * The platform is usually on another machine, so what such a call carries is the bytes rather than the path, and + * whether they may be read at all is the floor's to say. What is asserted here is that the floor is asked and that + * its answer is acted on, rather than the floor's own policy, which is decided and tested where the floor lives. + */ +class OneBotFileArgumentTest { + private val upload = row("upload_group_file") + + /** What a call of the streaming action answers, which a file small enough to carry never reaches. */ + private val stream: suspend (JsonObject) -> OneBotResult = { OneBotResult.Ok(JsonPrimitive("ok")) } + private val home: Path = createTempDirectory("onebot-file") + + @Test + fun `a file the floor allows is carried as the bytes the platform takes`() = runBlocking { + val file = home.resolve("notes.zip") + file.writeBytes(byteArrayOf(1, 2, 3)) + val context = testToolContext() + val prepared = assertIs(withFiles(upload, given(file), allows(), context, stream)) + val carried = prepared.params.getValue("file").jsonPrimitive.content + assertTrue(carried.startsWith("base64://"), carried) + assertContentEquals(byteArrayOf(1, 2, 3), Base64.getDecoder().decode(carried.removePrefix("base64://"))) + // A file that was read is reported the way the tools for files report one. + assertTrue(context.touchedPaths.contains(file), context.touchedPaths.toString()) + } + + @Test + fun `a file the floor refuses is refused with the reason the floor gave`() = runBlocking { + val file = home.resolve("secret.zip") + file.writeBytes(byteArrayOf(1)) + val refused = + assertIs( + withFiles(upload, given(file), denies("a place the test protects"), testToolContext(), stream), + ) + assertTrue(refused.said.contains("a place the test protects"), refused.said) + assertTrue(refused.said.contains("may not read"), refused.said) + } + + @Test + fun `a file too large to carry in one request goes to the implementation in pieces, which names it`() = + runBlocking { + val file = home.resolve("big.zip") + file.writeBytes(ByteArray((FILE_LIMIT + 1).toInt())) + val calls = mutableListOf() + val prepared = assertIs( + withFiles(upload, given(file), allows(), testToolContext()) { call -> + calls += call + if (call["is_complete"] != null) { + OneBotResult.Ok( + kotlinx.serialization.json.buildJsonObject { + put("file_path", JsonPrimitive("streamed")) + }, + ) + } else { + OneBotResult.Ok(JsonPrimitive("stream")) + } + }, + ) + + assertEquals("streamed", prepared.params.getValue("file").jsonPrimitive.content) + assertTrue(calls.size > 1, "a file this large takes more than one call") + } + + @Test + fun `a file the implementation took no pieces of is refused rather than sent on`() = runBlocking { + val file = home.resolve("big.zip") + file.writeBytes(ByteArray((FILE_LIMIT + 1).toInt())) + + val refused = assertIs( + withFiles(upload, given(file), allows(), testToolContext()) { _ -> OneBotResult.Failed(retcode = 200) }, + ) + + assertTrue(refused.said.contains("took no file in pieces"), refused.said) + } + + @Test + fun `a call that needs a file and a runtime with no floor is refused rather than read on its own`() = runBlocking { + val file = home.resolve("notes.zip") + file.writeBytes(byteArrayOf(1)) + val refused = assertIs(withFiles(upload, given(file), null, testToolContext(), stream)) + assertTrue(refused.said.contains("no file is sent"), refused.said) + } + + @Test + fun `a call that names no file is refused, naming the argument`() = runBlocking { + val given = buildJsonObject { put("group_id", JsonPrimitive("987654")) } + val refused = assertIs(withFiles(upload, given, allows(), testToolContext(), stream)) + assertTrue(refused.said.contains("file"), refused.said) + } + + @Test + fun `a call that needs no file is left as it is, whatever the floor says`() = runBlocking { + val mute = row("set_group_ban") + val given = buildJsonObject { + put("group_id", JsonPrimitive("987654")) + put("user_id", JsonPrimitive("2222")) + put("duration", JsonPrimitive("60")) + } + val prepared = assertIs(withFiles(mute, given, null, testToolContext(), stream)) + assertTrue(prepared.params.containsKey("duration"), prepared.params.toString()) + } + + @Test + fun `a path relative to the workspace is resolved against it, as a file tool resolves one`() = runBlocking { + val file = home.resolve("relative.txt") + file.writeBytes(byteArrayOf(7, 8)) + val context = testToolContext(workspace = home) + + val prepared = assertIs( + withFiles(upload, given(Path.of("relative.txt")), allows(), context, stream), + ) + + val carried = prepared.params.getValue("file").jsonPrimitive.content + assertContentEquals(byteArrayOf(7, 8), Base64.getDecoder().decode(carried.removePrefix("base64://"))) + } + + @Test + fun `a relative path with no workspace is refused rather than resolved against something else`() = runBlocking { + val refused = assertIs( + withFiles(upload, given(Path.of("relative.txt")), allows(), testToolContext(workspace = null), stream), + ) + assertTrue(refused.said.contains("no workspace"), refused.said) + } + + /** What the action was given, which names [file] as a call of a model would. */ + private fun given(file: Path) = buildJsonObject { + put("group_id", JsonPrimitive("987654")) + put("file", JsonPrimitive(file.toString())) + put("name", JsonPrimitive("notes.zip")) + } + + /** A floor that allows every path, which is the answer this test wants. */ + private fun allows(): HardFloor = StubFloor { FloorCheck.Allowed(it) } + + /** A floor that refuses every path, saying [reason]. */ + private fun denies(reason: String): HardFloor = StubFloor { FloorCheck.Denied(reason) } + + /** The row of [name], which the test expects the table to hold. */ + private fun row(name: String): OneBotActions.Row = + checkNotNull(OneBotActions.of(name)) { "the table holds no action $name" } + + /** A floor whose answer is [decision], which stands in for the one the runtime provides. */ + @OptIn(InternalAlexandriteApi::class) + private class StubFloor(private val decision: (Path) -> FloorCheck) : HardFloor { + override fun checkRead(path: Path, agent: AgentId?, conversation: ConversationId?): FloorCheck = decision(path) + + override fun checkWrite(path: Path, agent: AgentId?, conversation: ConversationId?): FloorCheck = + FloorCheck.Denied("a test floor writes none") + + override fun checkTree(path: Path, agent: AgentId?, conversation: ConversationId?): FloorCheck = + FloorCheck.Denied("a test floor walks none") + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotGroupFilesTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotGroupFilesTest.kt new file mode 100644 index 00000000..2e5d23cd --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotGroupFilesTest.kt @@ -0,0 +1,53 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * What a group's files are, and taking one of them away. + * + * A file that was sent to a group can only be checked or taken back by asking what the group holds, which gives the id + * and the part a file is known by, and then naming that file. Both halves are here for that reason: the actions that + * list are of no use without the one that deletes, and the one that deletes cannot be given what it takes without the + * actions that list. + */ +class OneBotGroupFilesTest { + @Test + fun `what a group's files are is read from the group`() { + listOf("get_group_root_files", "get_group_files_by_folder", "get_group_file_system_info").forEach { name -> + val row = assertNotNull(OneBotActions.of(name), "the table holds no $name") + assertTrue(row.extension, "$name is an action the standard does not declare") + assertTrue(!row.changes, "$name only reads") + assertEquals("group_id", row.parameters.first().name, "$name names the group") + assertEquals(OneBotActions.Kind.NUMBER, row.parameters.first().kind) + } + } + + @Test + fun `the folder a group's files are read from is named by the group`() { + val row = assertNotNull(OneBotActions.of("get_group_files_by_folder")) + assertEquals(listOf("group_id", "folder_id"), row.parameters.map { it.name }) + assertTrue(row.parameters.none { it.kind == OneBotActions.Kind.FILE }, "a folder of the group is no file here") + } + + @Test + fun `a file of a group is taken away by what listing the group's files gives`() { + val row = assertNotNull(OneBotActions.of("delete_group_file")) + assertTrue(row.extension, "the standard declares no such action") + assertTrue(row.changes, "taking a file away changes something") + assertEquals(listOf("group_id", "file_id", "busid"), row.parameters.map { it.name }) + assertEquals(OneBotActions.Kind.NUMBER, row.parameters.first { it.name == "group_id" }.kind) + assertEquals(OneBotActions.Kind.NUMBER, row.parameters.first { it.name == "busid" }.kind) + assertEquals(OneBotActions.Kind.TEXT, row.parameters.first { it.name == "file_id" }.kind) + } + + @Test + fun `taking a file away is asked of the tool that changes, and what a group holds of the one that reads`() { + assertTrue(assertNotNull(OneBotActions.of("delete_group_file")).changes) + OneBotActions.rows.filter { it.name.startsWith("get_group_") && it.name != "get_group_file_url" }.forEach { + assertTrue(!it.changes, "${it.name} belongs to the tool that reads") + } + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotOutcomeTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotOutcomeTest.kt new file mode 100644 index 00000000..27c6977b --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotOutcomeTest.kt @@ -0,0 +1,67 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * What the model is told a call answered. + * + * A read is answered with what the platform gave, because that answer is what the call was for: a read answered only + * with the sentence that the action was carried out leaves the model with nothing to read, which is what the call of + * a message that read as "The message was read." amounted to. + */ +class OneBotOutcomeTest { + private val message = buildJsonObject { + put("message_id", 1811467524L) + put("message", "不会") + } + + @Test + fun `a read is answered with what the platform gave`() { + val said = OneBotResult.Ok(message).outcome("The message was read.", answered = true) + assertTrue(said.contains("1811467524"), said) + assertTrue(said.contains("不会"), said) + } + + @Test + fun `a change is answered with what it did rather than with the platform's answer`() { + val said = OneBotResult.Ok(message).outcome("The message was taken back.", answered = false) + assertEquals("The message was taken back.", said) + } + + @Test + fun `an answer longer than a turn should carry stops, and says that it did`() { + val long = OneBotResult.Ok(JsonPrimitive("x".repeat(ANSWER_LIMIT * 2))) + .outcome("The message was read.", answered = true) + assertTrue(long.contains("stops here"), long.takeLast(60)) + assertTrue(long.length < ANSWER_LIMIT * 2, "an answer of ${long.length} characters was not cut") + } + + @Test + fun `a read the platform answered with nothing says so`() { + val said = OneBotResult.Ok(JsonNull).outcome("The message was read.", answered = true) + assertEquals("The implementation answered with nothing.", said) + } + + @Test + fun `an answer that holds no field is still shown as it is`() { + val said = OneBotResult.Ok(kotlinx.serialization.json.JsonObject(emptyMap())) + .outcome("The message was read.", answered = true) + assertEquals("{}", said) + } + + @Test + fun `an answer that did not come says why, whichever kind of action asked`() { + listOf(true, false).forEach { answered -> + val said = OneBotResult.Failed(retcode = 100, message = "no such group").outcome("said", answered) + assertTrue(said.contains("no such group"), said) + assertTrue(said.contains("100"), said) + } + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotPrepareTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotPrepareTest.kt new file mode 100644 index 00000000..25699e68 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotPrepareTest.kt @@ -0,0 +1,133 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What a call to an action sends, and what it is refused for. + * + * This is where a call reaches the platform or does not, so what it forwards is worth asserting: an action sends the + * arguments it declares and nothing else, so nothing a call writes can reach the platform by a name the action does + * not take, and an argument that must be given is refused rather than sent as nothing. + */ +class OneBotPrepareTest { + private val delete = row("delete_msg") + private val ban = row("set_group_ban") + private val mute = mapOf("group_id" to "987654", "user_id" to "2222", "duration" to "600") + + @Test + fun `the arguments a call gives are the ones that are sent`() { + val prepared = assertIs(prepare(ban, params(mute), null)) + assertEquals(JsonPrimitive(987654L), prepared.params["group_id"]) + assertEquals(JsonPrimitive(2222L), prepared.params["user_id"]) + assertEquals(JsonPrimitive(600L), prepared.params["duration"]) + } + + @Test + fun `an action about a message takes the message of the turn when none is given`() { + val prepared = assertIs(prepare(delete, JsonObject(emptyMap()), "1534828408")) + assertEquals(JsonPrimitive(1534828408L), prepared.params["message_id"]) + } + + @Test + fun `an argument given by a call is used rather than the message of the turn`() { + val prepared = assertIs(prepare(delete, params(mapOf("message_id" to "77")), "1534828408")) + assertEquals(JsonPrimitive(77L), prepared.params["message_id"]) + } + + @Test + fun `an action about a message and no message of the turn refuses rather than guesses`() { + val prepared = assertIs(prepare(delete, JsonObject(emptyMap()), null)) + assertTrue(prepared.said.contains("message_id"), prepared.said) + } + + @Test + fun `an argument that must be given and was not is refused, naming it and the action`() { + val prepared = assertIs( + prepare(ban, params(mapOf("group_id" to "987654")), null), + ) + assertTrue(prepared.said.contains("user_id"), prepared.said) + assertTrue(prepared.said.contains("set_group_ban"), prepared.said) + } + + @Test + fun `an argument a call writes under a name the action does not take reaches nothing`() { + val prepared = assertIs( + prepare(ban, params(mute + mapOf("action" to "set_restart", "group_id_extra" to "1")), null), + ) + assertEquals(setOf("group_id", "user_id", "duration"), prepared.params.keys) + assertNull(prepared.params["action"]) + } + + @Test + fun `the message of the turn is not sent to an action that does not take it`() { + val prepared = assertIs(prepare(ban, params(mute), "1534828408")) + assertEquals(setOf("group_id", "user_id", "duration"), prepared.params.keys) + assertNull(prepared.params[OneBotActions.MESSAGE_ID]) + } + + @Test + fun `an argument that may be left out and was not given is not sent at all`() { + val prepared = assertIs( + prepare(row("set_group_kick"), params(mapOf("group_id" to "987654", "user_id" to "2222")), null), + ) + assertEquals(setOf("group_id", "user_id"), prepared.params.keys) + } + + @Test + fun `an argument given as nothing counts as one that was not given`() { + val prepared = assertIs( + prepare(ban, params(mute).withNothing("user_id"), null), + ) + assertTrue(prepared.said.contains("user_id"), prepared.said) + } + + @Test + fun `an argument that asks whether to do something is sent as yes or no, however a call wrote it`() { + listOf("true" to true, "yes" to true, "false" to false, "no" to false).forEach { (written, expected) -> + val prepared = assertIs( + prepare(row("set_friend_add_request"), params(mapOf("flag" to "f", "approve" to written)), null), + ) + assertEquals(JsonPrimitive(expected), prepared.params["approve"], written) + } + } + + @Test + fun `an argument that asks whether to do something is not sent when it was not given`() { + val prepared = assertIs( + prepare(row("set_group_kick"), params(mapOf("group_id" to "987654", "user_id" to "2222")), null), + ) + assertNull(prepared.params["reject_add_request"]) + } + + @Test + fun `an argument that asks whether to do something and cannot be read as one is refused`() { + val prepared = assertIs( + prepare(row("set_friend_add_request"), params(mapOf("flag" to "f", "approve" to "maybe")), null), + ) + assertTrue(prepared.said.contains("approve"), prepared.said) + assertTrue(prepared.said.contains("yes or no"), prepared.said) + } + + /** The row of [name], which the test expects the table to hold. */ + private fun row(name: String): OneBotActions.Row = + checkNotNull(OneBotActions.of(name)) { "the table holds no action $name" } + + /** The arguments a call wrote, as text, which is how a model writes them. */ + private fun params(given: Map): JsonObject = buildJsonObject { + given.forEach { (name, value) -> put(name, JsonPrimitive(value)) } + } + + /** The same arguments, with [name] given as nothing rather than left out. */ + private fun JsonObject.withNothing(name: String): JsonObject = buildJsonObject { + this@withNothing.forEach { (key, value) -> put(key, value) } + put(name, JsonNull) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolDefinitionTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolDefinitionTest.kt new file mode 100644 index 00000000..d8f7e56b --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotToolDefinitionTest.kt @@ -0,0 +1,28 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import org.foedusprogramme.alexandrite.channel.onebot.channel.OneBotApis +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes + +/** + * How long the tools of this channel may take, which a file of any size makes long. + * + * A call that changes something may have to send a file of the workspace to the implementation in pieces and then + * send it on. The timeout a turn takes by default ends such a call before it is done, and a call that ends that way + * is one whose outcome nobody knows, which is what a file sent twice came of. + */ +class OneBotToolDefinitionTest { + @Test + fun `a tool that changes something may take as long as a file of the workspace can need`() { + val timeout = assertNotNull(OneBotActTool(OneBotApis(), null).definition.timeout, "no timeout is declared") + assertTrue(timeout >= 5.minutes, "a change may take only $timeout") + } + + @Test + fun `a tool that only reads needs no time of its own`() { + assertNull(OneBotReadTool(OneBotApis(), null).definition.timeout) + } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUploadTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUploadTest.kt new file mode 100644 index 00000000..be4d965f --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/tools/OneBotUploadTest.kt @@ -0,0 +1,170 @@ +package org.foedusprogramme.alexandrite.channel.onebot.tools + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.onebot.protocol.result.OneBotResult +import java.security.MessageDigest +import java.util.Base64 +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeBytes +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A file handed to an implementation in pieces, which is what a file too large to send in one request needs. + * + * What is asserted here is the protocol an implementation takes: the pieces, each with what it is checked by, the + * answer that ends the sending, and that a file only partly sent is never answered with as if it were whole. + */ +class OneBotUploadTest { + private val home = createTempDirectory("onebot-stream") + private val bytes = ByteArray(STREAM_CHUNK * 2 + 5) { (it % 251).toByte() } + + @Test + fun `a file goes in pieces, each with what the implementation checks it by`() = runBlocking { + val file = home.resolve("notes.bin") + file.writeBytes(bytes) + val calls = mutableListOf() + + val reference = streamFile(file, "notes.bin") { call -> + calls += call + answer(call) + } + + assertEquals("file-path", reference) + assertEquals(4, calls.size, "three pieces and the answer that ends the sending") + val pieces = calls.dropLast(1) + assertEquals(listOf(0, 1, 2), pieces.map { it["chunk_index"]!!.jsonPrimitive.contentOrNull?.toInt() }) + assertEquals(listOf(3, 3, 3), pieces.map { it["total_chunks"]!!.jsonPrimitive.contentOrNull?.toInt() }) + assertEquals(1, calls.map { it["stream_id"]!!.jsonPrimitive.content }.toSet().size, "one stream") + pieces.forEach { call -> + assertEquals(bytes.size.toString(), call["file_size"]!!.jsonPrimitive.content) + assertEquals(digest(bytes), call["expected_sha256"]!!.jsonPrimitive.content) + assertEquals("notes.bin", call["filename"]!!.jsonPrimitive.content) + } + val sent = pieces.map { Base64.getDecoder().decode(it["chunk_data"]!!.jsonPrimitive.content) } + assertContentEquals(bytes, sent.reduce { all, piece -> all + piece }) + assertTrue(sent.dropLast(1).all { it.size == STREAM_CHUNK }, "every piece but the last is a whole one") + } + + @Test + fun `the sending ends with the call that says the file is complete, and carries no piece`() = runBlocking { + val file = home.resolve("small.bin") + file.writeBytes(bytes) + val calls = mutableListOf() + + streamFile(file, "small.bin") { call -> + calls += call + answer(call) + } + + val last = calls.last() + assertEquals("true", last["is_complete"]!!.jsonPrimitive.content) + assertNull(last["chunk_data"]) + assertEquals(calls.first()["stream_id"]!!.jsonPrimitive.content, last["stream_id"]!!.jsonPrimitive.content) + } + + @Test + fun `a file that is only partly sent is not answered with as if it were whole`() = runBlocking { + val file = home.resolve("broken.bin") + file.writeBytes(bytes) + val calls = mutableListOf() + + val reference = streamFile(file, "broken.bin") { call -> + calls += call + if (calls.size == 1) OneBotResult.Failed(retcode = 200) else answer(call) + } + + assertNull(reference) + assertEquals(1, calls.size, "nothing is sent after a piece the implementation refused") + } + + @Test + fun `an answer that names the file under another of its names is read as well`() = runBlocking { + val file = home.resolve("a.bin") + file.writeBytes(bytes) + + val reference = streamFile(file, "a.bin") { call -> + if (call["is_complete"] != null) { + OneBotResult.Ok(buildJsonObject { put("url", "http://host/a") }) + } else { + OneBotResult.Ok(JsonPrimitive("ok")) + } + } + + assertEquals("http://host/a", reference) + } + + @Test + fun `an answer that names no file leaves nothing to send it on by`() = runBlocking { + val file = home.resolve("b.bin") + file.writeBytes(bytes) + + val reference = streamFile(file, "b.bin") { call -> + if (call["is_complete"] != + null + ) { + OneBotResult.Ok(JsonPrimitive("done")) + } else { + OneBotResult.Ok(JsonPrimitive("ok")) + } + } + + assertNull(reference) + } + + @Test + fun `a file that holds nothing is still ended, with no piece sent`() = runBlocking { + val file = home.resolve("empty.bin") + file.writeBytes(ByteArray(0)) + val calls = mutableListOf() + + val reference = streamFile(file, "empty.bin") { call -> + calls += call + answer(call) + } + + assertEquals(1, calls.size) + val only = calls.single() + assertEquals("true", only["is_complete"]!!.jsonPrimitive.content) + assertFalse(only.containsKey("chunk_data")) + assertEquals("file-path", reference) + } + + @Test + fun `a call cancelled while a piece is being sent ends cancelled rather than answered`() = runBlocking { + val file = home.resolve("cancelled.bin") + file.writeBytes(bytes) + + // The file is read on another dispatcher so that reading it does not hold the one the turn runs on, and a + // call ended while it waits is cancelled rather than answered, since nothing is known of what it did. + assertFailsWith { + withTimeout(50L) { streamFile(file, "cancelled.bin") { awaitCancellation() } } + } + } + + /** What an implementation answers to [call], naming the file it was sent when [call] ends the sending. */ + private fun answer(call: JsonObject): OneBotResult = if (call["is_complete"] != null) { + OneBotResult.Ok(buildJsonObject { put("file_path", "file-path") }) + } else { + OneBotResult.Ok(buildJsonObject { put("type", "stream") }) + } + + /** The digest of [bytes], which is what the implementation is told to check what it was sent by. */ + private fun digest(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiverTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiverTest.kt new file mode 100644 index 00000000..e3b0a842 --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/OneBotHttpPostReceiverTest.kt @@ -0,0 +1,237 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.foedusprogramme.alexandrite.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.auth.Value +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.sdk.config.Secret +import java.net.InetAddress +import java.net.Socket +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class OneBotHttpPostReceiverTest { + private val secret = Value.of(Secret("signing-key")) + private val receiver = OneBotHttpPostReceiver(settings()).also { it.start() } + private val client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build() + + @AfterTest + fun close() { + receiver.close() + } + + @Test + fun `a signed report is taken and answered with no content`() { + val body = privateMessage() + val response = post(body, OneBotAuth.signature(secret, body), "/") + assertEquals(204, response.statusCode()) + assertEquals("", response.body()) + val event = runBlocking { withTimeout(5_000) { first() } } + assertEquals(1515204254L, event.time) + assertIs(event) + } + + @Test + fun `a report without a signature is refused and nothing is reported`() { + val response = post(privateMessage(), null, "/") + assertEquals(403, response.statusCode()) + assertTrue(response.body().contains("signature")) + assertEquals(0, receiver.reported()) + } + + @Test + fun `a report whose body changed after signing is refused`() { + val signature = OneBotAuth.signature(secret, privateMessage()) + val response = post("""{"time":1,"post_type":"meta_event","meta_event_type":"lifecycle"}""", signature, "/") + assertEquals(403, response.statusCode()) + assertEquals(0, receiver.reported()) + } + + @Test + fun `a report on another path is not this instance's`() { + val body = privateMessage() + val response = post(body, OneBotAuth.signature(secret, body), "/other") + assertEquals(404, response.statusCode()) + assertEquals(0, receiver.reported()) + } + + @Test + fun `a report that is no event is refused`() { + val body = "{ not json" + val response = post(body, OneBotAuth.signature(secret, body), "/") + assertEquals(400, response.statusCode()) + assertEquals(0, receiver.reported()) + } + + @Test + fun `a report that names no account is refused when the instance names one`() { + val body = privateMessage().replace("\"self_id\": 10001000,", "\"self_id\": 0,") + val response = postTo(receiver.port, body, OneBotAuth.signature(secret, body), "/", selfId = null) + + assertEquals(403, response.statusCode()) + assertEquals(0, receiver.reported()) + } + + @Test + fun `a report that names its account only in the header is taken`() { + // The header names of a request are folded to lower case when its head is read, so a lookup by the spelling of + // the standard finds nothing. The body therefore names no account: were the header ignored, this report would + // be refused, so being taken is the header path working and nothing else. + val body = privateMessage().replace("\"self_id\": 10001000,", "\"self_id\": 0,") + val response = post(body, OneBotAuth.signature(secret, body), "/") + + assertEquals(204, response.statusCode()) + assertEquals(1, receiver.reported()) + } + + @Test + fun `a report of an account is taken when the instance names none`() { + val open = OneBotHttpPostReceiver(settings(selfId = null)).also { it.start() } + try { + val body = privateMessage() + val response = postTo(open.port, body, OneBotAuth.signature(secret, body), "/", selfId = null) + + assertEquals(204, response.statusCode()) + assertEquals(1, open.reported()) + } finally { + open.close() + } + } + + @Test + fun `a report of another account is refused when the instance names one`() { + val named = OneBotHttpPostReceiver(settings(selfId = "10001000")).also { it.start() } + try { + val body = privateMessage().replace("\"self_id\": 10001000", "\"self_id\": 20002000") + val response = postTo( + named.port, + body, + OneBotAuth.signature(secret, body), + "/", + selfId = "20002000", + ) + assertEquals(403, response.statusCode()) + assertEquals(0, named.reported()) + } finally { + named.close() + } + } + + @Test + fun `a report never answers with the secret or the token`() { + val response = post(privateMessage(), "sha1=00", "/") + assertEquals(403, response.statusCode()) + assertFalse(response.body().contains("signing-key")) + assertFalse(response.body().contains("s3cr3t-token")) + } + + @Test + fun `a report larger than the limit is refused with its status rather than closed`() { + // The declared length is over the limit, so the body is never read. The implementation is still told why, + // which is what makes the refusal something its author can act on rather than a connection that dropped. + val body = privateMessage().padEnd(8 * 1024 * 1024 + 1, ' ') + val response = post(body, OneBotAuth.signature(secret, body), "/") + + assertEquals(413, response.statusCode()) + assertEquals(0, receiver.reported()) + } + + private fun OneBotHttpPostReceiver.reported(): Int = reportedCount + + @Test + fun `a report that arrives while nothing reads is dropped and counted`() { + // The fixture holds room for sixteen events and reads none of them, so a listener that keeps taking reports + // has to say how many it could not keep. A count that stayed at zero here would be the silence the class + // promises not to keep. + val body = privateMessage() + val signature = OneBotAuth.signature(secret, body) + repeat(20) { post(body, signature, "/") } + + assertEquals(20, receiver.reported(), "every report was taken") + assertEquals(4L, receiver.droppedEvents, "the reports past the room of the queue were dropped") + } + + @Test + fun `a report that is sent a byte at a time is cut off at the deadline`() { + // The bytes arrive faster than the idle timeout, so an idle timeout alone would read this for as long as the + // client keeps sending. The whole request carries a deadline, and past it its connection is closed. + val started = System.nanoTime() + Socket(InetAddress.getLoopbackAddress(), receiver.port).use { socket -> + val writer = socket.getOutputStream() + writer.write("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 100000\r\n\r\n".toByteArray()) + writer.flush() + runCatching { + // Sent for longer than the deadline, so that a listener without one would read it to the end and + // this case would fail rather than pass for the wrong reason. + repeat(120) { + Thread.sleep(100) + writer.write('x'.code) + writer.flush() + } + } + } + + val elapsed = (System.nanoTime() - started) / 1_000_000 + assertTrue(elapsed < 8_000, "the listener read a slow report for ${elapsed}ms") + assertEquals(0, receiver.reported()) + } + + private fun post(body: String, signature: String?, path: String): HttpResponse = + postTo(receiver.port, body, signature, path, selfId = "10001000") + + private fun postTo( + port: Int, + body: String, + signature: String?, + path: String, + selfId: String?, + ): HttpResponse { + val builder = HttpRequest.newBuilder(URI.create("http://127.0.0.1:$port$path")) + .header("Content-Type", "application/json") + .apply { selfId?.let { header(OneBotAuth.SELF_ID, it) } } + .POST(HttpRequest.BodyPublishers.ofString(body)) + signature?.let { builder.header(OneBotAuth.SIGNATURE, it) } + return client.send(builder.build(), HttpResponse.BodyHandlers.ofString()) + } + private suspend fun first(): OneBotEvent = receiver.events.first() + + private fun settings(selfId: String? = "10001000"): OneBotSettings = OneBotSettings( + endpoint = null, + listenHost = "127.0.0.1", + listenPort = 0, + path = "/", + token = Value.of(Secret("s3cr3t-token")), + secret = secret, + selfId = selfId, + connectTimeoutMillis = 5_000, + firstByteTimeoutMillis = 5_000, + idleTimeoutMillis = 5_000, + reconnectIntervalMillis = 100, + eventCapacity = 16, + ) + + private fun privateMessage(): String = """ + { + "time": 1515204254, + "self_id": 10001000, + "post_type": "message", + "message_type": "private", + "sub_type": "friend", + "message_id": 12, + "user_id": 12345678, + "message": "hello", + "raw_message": "hello", + "sender": {"nickname": "someone"} + } + """.trimIndent() +} diff --git a/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/WsTransportTest.kt b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/WsTransportTest.kt new file mode 100644 index 00000000..4f297d8d --- /dev/null +++ b/libraries/channels/onebot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/onebot/transport/WsTransportTest.kt @@ -0,0 +1,256 @@ +package org.foedusprogramme.alexandrite.channel.onebot.transport + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.foedusprogramme.alexandrite.channel.onebot.auth.OneBotAuth +import org.foedusprogramme.alexandrite.channel.onebot.auth.Value +import org.foedusprogramme.alexandrite.channel.onebot.protocol.event.OneBotEvent +import org.foedusprogramme.alexandrite.sdk.config.Secret +import org.java_websocket.WebSocket +import org.java_websocket.client.WebSocketClient +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.handshake.ServerHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.net.URI +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The two Websocket transports, each against a peer built from the other side of the library. */ +class WsTransportTest { + private val token = Value.of(Secret("s3cr3t-token")) + + @Test + fun `a forward connection reads the answer to its call and the event beside it`() { + val peer = ImplementationServer() + val port = peer.startAndPort() + val connection = OneBotForwardWebSocket(settings("ws://127.0.0.1:" + port)) + try { + runBlocking { + connection.start() + val answer = connection.send("get_login_info", JsonObject(emptyMap())) + assertEquals( + "10001000", + answer["data"]?.let { + it as? JsonObject + }?.get("user_id")?.jsonPrimitive?.content, + "answer was " + answer, + ) + assertTrue(peer.called().contains("get_login_info"), "the peer was not called") + val event = withTimeout(5_000) { connection.events.first() } + assertIs(event) + } + } finally { + connection.close() + peer.stop() + } + } + + @Test + fun `a forward connection brings the token of its instance`() { + val peer = ImplementationServer() + val port = peer.startAndPort() + val connection = OneBotForwardWebSocket(settings("ws://127.0.0.1:" + port)) + try { + runBlocking { + connection.start() + assertEquals("Bearer s3cr3t-token", peer.offered) + } + } finally { + connection.close() + peer.stop() + } + } + + @Test + fun `a reverse connection takes the implementation that dials it`() { + val connection = OneBotReverseWebSocket(settings(null, port = 0)) + try { + runBlocking { + connection.start() + val peer = DialingImplementation(URI("ws://127.0.0.1:" + connection.port)) + assertTrue(peer.connectBlocking(5, TimeUnit.SECONDS), "the implementation did not connect") + assertTrue(connection.awaitPeer(), "not taken; state=" + connection.state.value) + val answer = connection.send("get_version_info", JsonObject(emptyMap())) + assertEquals( + "v11", + answer["data"]?.let { it as? JsonObject }?.get("protocol_version")?.jsonPrimitive?.content, + "answer was " + answer, + ) + peer.close() + } + } finally { + connection.close() + } + } + + @Test + fun `a reverse connection ends a waiting call when the implementation leaves`() { + val connection = OneBotReverseWebSocket(settings(null, port = 0)) + try { + runBlocking { + connection.start() + val peer = SilentImplementation(URI("ws://127.0.0.1:" + connection.port)) + assertTrue(peer.connectBlocking(5, TimeUnit.SECONDS), "the implementation did not connect") + assertTrue(connection.awaitPeer(), "not taken; state=" + connection.state.value) + + // The call goes out and is never answered, and then the implementation leaves. Its caller has to hear + // about that rather than wait for a timeout that would blame a slow implementation for a lost one. + val call = async { runCatching { connection.send("get_version_info", JsonObject(emptyMap())) } } + peer.awaitCall() + peer.close() + val ended = withTimeout(2_000) { call.await() } + assertTrue(ended.isFailure, "the call was answered by a connection that had left") + } + } finally { + connection.close() + } + } + + @Test + fun `a reverse listener serves the roles that carry calls and events both`() { + assertNull(refusedClientRole(null), "an implementation that names no role must keep working") + assertNull(refusedClientRole("Universal"), "universal carries the calls and the events both") + assertNull(refusedClientRole("Service"), "service does too") + assertNotNull(refusedClientRole("API"), "an API client sends no events this instance could take") + assertNotNull(refusedClientRole("Event"), "an Event client answers no call this instance would make") + } + + private fun settings(endpoint: String?, port: Int = 0): OneBotSettings = OneBotSettings( + endpoint = endpoint, + listenHost = "127.0.0.1", + listenPort = port, + path = "/", + token = token, + secret = null, + selfId = null, + connectTimeoutMillis = 5_000, + firstByteTimeoutMillis = 5_000, + idleTimeoutMillis = 5_000, + reconnectIntervalMillis = 100, + eventCapacity = 16, + ) + + /** An implementation that serves the API and pushes one event, as the standard's server does. */ + private class ImplementationServer : WebSocketServer(InetSocketAddress("127.0.0.1", 0)) { + private val ready = CountDownLatch(1) + private val calls = CountDownLatch(1) + private val received = CopyOnWriteArrayList() + + @Volatile + var offered: String? = null + private set + + override fun onStart() { + ready.countDown() + } + + override fun onOpen(connection: WebSocket, handshake: ClientHandshake) { + offered = handshake.getFieldValue(OneBotAuth.AUTHORIZATION) + } + + override fun onMessage(connection: WebSocket, message: String) { + received += message + calls.countDown() + val action = message.substringAfter("\"action\":\"", "").substringBefore('"') + val echo = message.substringAfter("\"echo\":\"", "").substringBefore('"') + val data = if (action == "get_login_info") "{\"user_id\":10001000}" else "{\"protocol_version\":\"v11\"}" + connection.send("{\"status\":\"ok\",\"retcode\":0,\"data\":" + data + ",\"echo\":\"" + echo + "\"}") + if (action == "get_login_info") connection.send(LIFECYCLE) + } + + override fun onClose(connection: WebSocket, code: Int, reason: String, remote: Boolean) = Unit + + override fun onError(connection: WebSocket?, ex: Exception) = Unit + + /** Starts listening and returns the port this peer took. */ + fun startAndPort(): Int { + start() + check(ready.await(5, TimeUnit.SECONDS)) { "the peer did not start" } + return port + } + + /** Waits for the first call and returns it. */ + fun called(): String { + check(calls.await(5, TimeUnit.SECONDS)) { "the peer was called by nobody" } + return received.first() + } + } + + /** An implementation that dials Alexandrite, as the standard's universal client does. */ + private class DialingImplementation(uri: URI) : + WebSocketClient( + uri, + mapOf( + OneBotAuth.AUTHORIZATION to OneBotAuth.bearer(Value.secretOf("s3cr3t-token")), + OneBotAuth.SELF_ID to "10001000", + OneBotAuth.CLIENT_ROLE to "Universal", + ), + ) { + @Volatile + var offered: String? = null + private set + + override fun onOpen(handshake: ServerHandshake) { + offered = handshake.getFieldValue(OneBotAuth.AUTHORIZATION) + } + + override fun onMessage(message: String) { + val echo = message.substringAfter("\"echo\":\"", "").substringBefore('"') + if (echo.isEmpty()) return + send("{\"status\":\"ok\",\"retcode\":0,\"data\":{\"protocol_version\":\"v11\"},\"echo\":\"" + echo + "\"}") + } + + override fun onClose(code: Int, reason: String, remote: Boolean) = Unit + + override fun onError(ex: Exception) = Unit + } + + /** An implementation that dials in, takes a call and never answers it, so that a caller is left waiting. */ + private class SilentImplementation(uri: URI) : + WebSocketClient( + uri, + mapOf( + OneBotAuth.AUTHORIZATION to OneBotAuth.bearer(Value.secretOf("s3cr3t-token")), + OneBotAuth.SELF_ID to "10001000", + OneBotAuth.CLIENT_ROLE to "Universal", + ), + ) { + private val called = CountDownLatch(1) + + override fun onOpen(handshake: ServerHandshake) = Unit + + override fun onMessage(message: String) { + if (message.contains("\"echo\"")) called.countDown() + } + + override fun onClose(code: Int, reason: String, remote: Boolean) = Unit + + override fun onError(ex: Exception) = Unit + + /** Waits until a call has arrived, and returns whether one did. */ + fun awaitCall(): Boolean = called.await(5, TimeUnit.SECONDS) + } + + private companion object { + const val LIFECYCLE = + "{\"time\":1,\"self_id\":10001000,\"post_type\":\"meta_event\"," + + "\"meta_event_type\":\"lifecycle\",\"sub_type\":\"connect\"}" + + const val EVENT = + "{\"time\":1,\"self_id\":10001000,\"post_type\":\"meta_event\"," + + "\"meta_event_type\":\"heartbeat\",\"interval\":15000}" + } +} diff --git a/libraries/channels/qqbot/build.gradle.kts b/libraries/channels/qqbot/build.gradle.kts new file mode 100644 index 00000000..f0394314 --- /dev/null +++ b/libraries/channels/qqbot/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("alexandrite.kotlin-library") + id("alexandrite.kotlin-serialization") +} + +dependencies { + implementation(project(":libraries:plugin-sdk")) + implementation(project(":libraries:internal")) + + // The client of the gateway, of which the JDK serves none. + implementation(libs.java.websocket) + + testImplementation(project(":libraries:testkit")) + testImplementation(libs.logback.classic) +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotPlugin.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotPlugin.kt new file mode 100644 index 00000000..314e8613 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotPlugin.kt @@ -0,0 +1,7 @@ +package org.foedusprogramme.alexandrite.channel.qqbot + +import org.foedusprogramme.alexandrite.sdk.plugin.Plugin + +/** The QQ official bot channel plugin, which talks to the open platform of the QQ bots. */ +@Plugin(name = "QQ Bot", description = "QQ official bot channel") +public class QqBotPlugin diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/auth/QqBotTokens.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/auth/QqBotTokens.kt new file mode 100644 index 00000000..cefa16e3 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/auth/QqBotTokens.kt @@ -0,0 +1,97 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.auth + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotAccessToken +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.asTextOrNull +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.failureOf +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.number +import org.foedusprogramme.alexandrite.internal.http.HttpCall +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.slf4j.LoggerFactory +import java.time.Clock +import java.time.Instant + +/** + * The access token of one bot, which the open platform sells for its AppID and AppSecret and expires after two hours. + * + * The platform answers the same token while it is valid, so the token is kept until shortly before it expires and + * bought again then. A call that fails because the token expired asks for a new one rather than failing the turn. + */ +@ChannelInstanceScoped +internal class QqBotTokens( + private val config: QqBotInstanceConfig, + private val http: QqBotHttp, + private val clock: Clock, +) { + private val mutex = Mutex() + private var token: QqBotAccessToken? = null + private var expiresAt: Instant = Instant.EPOCH + + /** The token to present, bought again when it is time or when [refresh] asks for one. */ + suspend fun token(refresh: Boolean = false): String = mutex.withLock { + val kept = token + if (!refresh && kept != null && clock.instant().isBefore(expiresAt)) return kept.value + buy().also { + token = it + expiresAt = clock.instant().plusSeconds((it.expiresInSeconds - SKEW_SECONDS).coerceAtLeast(1)) + }.value + } + + /** The value the platform's authorization header carries: `QQBot `. */ + suspend fun authorization(refresh: Boolean = false): String = "QQBot ${token(refresh)}" + + /** Buys a token with the AppID and AppSecret of the instance. */ + private suspend fun buy(): QqBotAccessToken { + val call = HttpCall( + method = "POST", + uri = http.uri("/app/getAppAccessToken"), + headers = mapOf("Content-Type" to "application/json; charset=utf-8"), + body = buildJsonObject { + put("appId", config.appId) + put("clientSecret", config.appSecret.reveal()) + }.toString(), + secrets = listOf(config.appSecret.reveal()), + ) + val text = http.send(call) + val body = runCatching { QqBotWire.json.parseToJsonElement(text) as? JsonObject }.getOrNull() + val failure = failureOf(body) + if (failure.code != null && failure.code != 0) { + logger.warn( + "{}: the platform gave no access token for AppID {}: {} {}", + PLUGIN, + config.appId, + failure.code, + failure.message, + ) + throw IllegalStateException( + "The open platform gave no access token for AppID ${config.appId}: " + + "${failure.message} (code ${failure.code})", + ) + } + val value = body?.get("access_token")?.asTextOrNull() + if (value == null) { + logger.warn("{}: no access token came back for AppID {}: {}", PLUGIN, config.appId, text.take(200)) + throw IllegalStateException("The open platform gave no access token for AppID ${config.appId}.") + } + val expires = body.number("expires_in") ?: DEFAULT_EXPIRES_SECONDS + return QqBotAccessToken(value, expires) + } + + private companion object { + const val PLUGIN = "alexandrite-channel-qqbot" + + /** The platform expires a token in two hours; a new one is had this long before that. */ + const val SKEW_SECONDS = 60L + + const val DEFAULT_EXPIRES_SECONDS = 7_200L + + val logger = LoggerFactory.getLogger(QqBotTokens::class.java) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotApprovals.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotApprovals.kt new file mode 100644 index 00000000..99617b57 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotApprovals.kt @@ -0,0 +1,218 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.qqbot.i18n.TextKeys +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalAnswer +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalRequest +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.chat.LanguageTag +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.foedusprogramme.alexandrite.sdk.i18n.Texts +import org.slf4j.LoggerFactory +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.minutes + +/** + * Asks administrators for approval with a message whose buttons carry the answer. + * + * A button is an instruction button: a press writes `/approve ` or `/deny ` as a message, and the platform may + * send it by itself where it supports that. The message comes back through the inbox like any other message, which + * answers the waiting request if the sender is an administrator. A message of this platform cannot be changed after it + * is sent, so the outcome is said in a second message. + */ +@ChannelInstanceScoped +internal class QqBotApprovals( + private val instance: ChannelInstance, + private val config: QqBotInstanceConfig, + private val sender: QqBotSender, + private val progress: QqBotProgress, + private val texts: Texts, +) : Lifecycle { + private val pending = ConcurrentHashMap() + + /** The answer of the first administrator who presses a button of [request], else how the request ended. */ + suspend fun ask(request: ApprovalRequest): ApprovalAnswer { + val waiting = Pending(request) + if (pending.putIfAbsent(request.id, waiting) != null) { + return ApprovalAnswer.Unavailable("another request has the id ${request.id}") + } + try { + val shown = show(waiting) + if (shown.isEmpty()) { + logger.warn("{} could not show approval {} in any chat", instance.id, request.id) + return ApprovalAnswer.Unavailable(text(TextKeys.APPROVAL_UNREACHABLE, request.turn.language)) + } + // The answer comes after the approval message, so the turn's stream ends here rather than showing it above + // the request. + progress.defer(request.turn.id) + logger.info( + "{} asks for approval {} of {} in turn {} in {} chat(s)", + instance.id, + request.id, + request.tool, + request.turn.id, + shown.size, + ) + val answered = withTimeoutOrNull(config.approvalMinutes.minutes) { waiting.ending.await() } + val answer = answered ?: ApprovalAnswer.Expired + waiting.ending.complete(answer) + conclude(waiting, answer) + return answer + } catch (e: CancellationException) { + waiting.ending.complete(ApprovalAnswer.Unavailable("the turn was withdrawn")) + conclude(waiting, ApprovalAnswer.Unavailable("the turn was withdrawn")) + throw e + } finally { + pending.remove(request.id) + } + } + + /** Whether [name] is a command this desk answers. */ + fun answers(name: String): Boolean = name == QqBotApprovalCommands.APPROVE || name == QqBotApprovalCommands.DENY + + /** Takes [name] and [id] that [by] wrote in [chat], returning what to reply, null when nothing should be said. */ + suspend fun answer(name: String, id: String, always: Boolean, by: ChatUser, chat: ChatAddress): String? { + val waiting = pending[id] + ?: return text(TextKeys.APPROVAL_NOTHING_WAITING, null) + if (chat !in waiting.chats) return null + if (!by.isAdmin) return text(TextKeys.APPROVAL_ADMINS_ONLY, waiting.request.turn.language) + val answer = if (name == QqBotApprovalCommands.DENY) { + ApprovalAnswer.Denied(by) + } else { + ApprovalAnswer.Allowed(by, always && waiting.request.always != null) + } + if (!waiting.ending.complete(answer)) return null + logger.info("{}: approval {} of {} answered by {}", instance.id, id, waiting.request.tool, by.address) + return null + } + + override fun onStop() { + pending.values.forEach { it.ending.complete(ApprovalAnswer.Unavailable("the bot stopped")) } + } + + /** Shows the request of [waiting] in each chat it is meant for, returning the chats that took it. */ + private suspend fun show(waiting: Pending): List { + val request = waiting.request + val shown = mutableListOf() + for (chat in recipients(request)) { + // Register the chat before the platform can send its press: the button can be pressed as soon as the + // message is delivered, which may be before sendApproval returns here. + waiting.chats += chat + val delivery = sender.sendApproval( + chat, + render(request), + request.id, + text(TextKeys.APPROVAL_ALLOW, request.turn.language), + text(TextKeys.APPROVAL_DENY, request.turn.language), + request.always?.let { text(TextKeys.APPROVAL_ALWAYS, request.turn.language, "rule" to it.display) }, + ) + if (delivery is Delivery.Delivered) { + shown += chat + } else { + waiting.chats.remove(chat) + logger.warn("{}: approval {} was not shown in {}: {}", instance.id, request.id, chat, delivery) + } + } + return shown + } + + /** The chats [request] is shown in: the chat of the turn, and the administrators' own chats for a member's turn. */ + private fun recipients(request: ApprovalRequest): List { + val chats = mutableListOf(request.turn.chat) + if (!request.turn.actorIsAdmin) { + config.admins.mapTo(chats) { ChatAddress(instance.id, "private:$it") } + } + return chats.distinct() + } + + /** Says [answer] in every chat that showed [waiting]. */ + private suspend fun conclude(waiting: Pending, answer: ApprovalAnswer) { + val request = waiting.request + val said = when (answer) { + is ApprovalAnswer.Allowed -> text(TextKeys.APPROVAL_ALLOWED, request.turn.language, "id" to request.id) + is ApprovalAnswer.Denied -> text(TextKeys.APPROVAL_DENIED, request.turn.language, "id" to request.id) + ApprovalAnswer.Expired -> text(TextKeys.APPROVAL_EXPIRED, request.turn.language, "id" to request.id) + else -> return + } + for (chat in waiting.chats) { + try { + sender.send(chat, OutboundMessage.builder(said, MessageKind.NOTICE).build()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn( + "{}: could not say the outcome of approval {} in {}: {}", + instance.id, + request.id, + chat, + e.message, + ) + } + } + } + + /** The text of [request] shown with its buttons. */ + private fun render(request: ApprovalRequest): String = buildString { + appendLine(text(TextKeys.APPROVAL_TITLE, request.turn.language, "id" to request.id)) + appendLine( + text( + TextKeys.APPROVAL_TOOL, + request.turn.language, + "tool" to request.tool, + "risk" to request.risk.name.lowercase(), + ), + ) + appendLine(text(TextKeys.APPROVAL_REASON, request.turn.language, "reason" to request.reason)) + appendLine(text(TextKeys.APPROVAL_ARGUMENTS, request.turn.language)) + append(request.arguments.take(MAX_ARGUMENTS)) + } + + private fun text(key: String, language: LanguageTag?, vararg args: Pair): String = + texts.text(key, language, *args) + + private class Pending(val request: ApprovalRequest) { + val ending = CompletableDeferred() + + val chats = CopyOnWriteArrayList() + } + + private companion object { + const val MAX_ARGUMENTS = 1_500 + + val logger = LoggerFactory.getLogger(QqBotApprovals::class.java) + } +} + +/** The approval commands a message of this channel may carry. */ +internal object QqBotApprovalCommands { + const val APPROVE = "approve" + + const val DENY = "deny" + + private val PRESS = Regex( + "^/($APPROVE|$DENY)(?:@[A-Za-z0-9_]+)?\\s+([A-Za-z0-9_-]{1,32})(?:\\s+(always))?\\s*$", + RegexOption.IGNORE_CASE, + ) + + /** The command [text] carries, null when it is no approval command. */ + fun of(text: String): Parsed? { + // A group may put the mention of the bot before the data of a button; the command starts after it. + val command = text.trim().replace(LEADING_MENTIONS, "") + val match = PRESS.matchEntire(command) ?: return null + return Parsed(match.groupValues[1].lowercase(), match.groupValues[2], match.groupValues[3].isNotEmpty()) + } + + private val LEADING_MENTIONS = Regex("^(@\\S+\\s+)+") + + class Parsed(val name: String, val id: String, val always: Boolean) +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotChannel.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotChannel.kt new file mode 100644 index 00000000..604f5b12 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotChannel.kt @@ -0,0 +1,310 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotChats +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalAnswer +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalRequest +import org.foedusprogramme.alexandrite.sdk.channel.Channel +import org.foedusprogramme.alexandrite.sdk.channel.ChannelCapabilities +import org.foedusprogramme.alexandrite.sdk.channel.DelegatedRun +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.Markup +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.channel.ReplyActivity +import org.foedusprogramme.alexandrite.sdk.channel.ReplyEnd +import org.foedusprogramme.alexandrite.sdk.channel.ReplyRequest +import org.foedusprogramme.alexandrite.sdk.channel.ReplySink +import org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus +import org.foedusprogramme.alexandrite.sdk.channel.TurnEvent +import org.foedusprogramme.alexandrite.sdk.channel.TurnEventKind +import org.foedusprogramme.alexandrite.sdk.channel.rebuild +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.chat.RunId +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.transcript.AssistantEntry +import org.foedusprogramme.alexandrite.sdk.transcript.TextPart +import org.slf4j.LoggerFactory + +/** + * The QQ official bot front end of one channel instance. + * + * A message the platform pushes becomes an [org.foedusprogramme.alexandrite.sdk.channel.IncomingMessage] and goes to + * the agent through the submitter, and what the agent answers goes back through [QqBotSender]. A message in a group + * cannot be changed after it is sent, so a reply there is one message of its own; a single chat serves the platform's + * streaming endpoint, so the growing answer is shown in one message that later pieces replace. + */ +@ChannelInstanceScoped +@Contribute(Channel::class, name = "qqbot") +internal class QqBotChannel( + private val sender: QqBotSender, + private val approvals: QqBotApprovals, + private val typing: QqBotTyping, + private val progress: QqBotProgress, + private val replies: QqBotReplyRegistry, + private val rules: QqBotCommandRules, + private val config: QqBotInstanceConfig, +) : Channel { + override suspend fun capabilities(chat: ChatAddress): ChannelCapabilities = + if (config.streaming && QqBotChats.kind(chat) == ChatKind.DIRECT) { + STREAMING_CAPABILITIES + } else { + CAPABILITIES + } + override suspend fun askApproval(request: ApprovalRequest): ApprovalAnswer = approvals.ask(request) + + override fun mayRun(chat: ChatAddress, issuer: ChatUser, command: String, withArguments: Boolean): Boolean = + rules.mayRun(chat, issuer.isAdmin, command, withArguments) + + override suspend fun partsNeeded(chat: ChatAddress, text: String, markup: Markup): Int = + if (text.isEmpty()) 0 else 1 + + override suspend fun openReply(request: ReplyRequest): ReplySink { + typing.start(request.turn.chat) + return Sink(request).also { replies.open(request.turn.id, it) } + } + + override suspend fun send(chat: ChatAddress, message: OutboundMessage): Delivery = sender.send(chat, message) + + /** The reply of one turn, which streams in a single chat and sends one message in a group. */ + private inner class Sink(private val request: ReplyRequest) : + ReplySink, + QqBotWords { + /** + * The one streaming message of this reply, which every segment replaces. + * + * The platform cannot delete a streaming message, so a segment that started its own message would leave the + * previous segment's text behind as a stray message. Replacing the same message across segments keeps the + * conversation to what the answer says. + */ + private var stream: QqBotStream? = null + + /** The tool calls seen so far, which the streaming message shows until the answer replaces them. */ + private val activities = LinkedHashMap() + + /** What the model has said so far, apart from the tool lines the stream shows before it. */ + private var answer: String = "" + + /** The tool lines of a non-streaming chat that wait for their next notice. */ + private val waiting = ArrayList() + + /** When the last notice of this reply was sent, and whether one was sent. */ + private var noticedAt: Long = 0 + private var noticed: Boolean = false + + /** The background runs whose activity this reply still shows after its own answer. */ + private val detached = LinkedHashMap() + + /** The answer this reply sent while a background run still writes to its stream. */ + private var parentFinal: String? = null + + private val streaming = config.streaming && QqBotChats.kind(request.turn.chat) == ChatKind.DIRECT && + request.trigger != null + + override suspend fun detachActivity(run: DelegatedRun): ReplyActivity? { + if (progress.isDeferred(request.turn.id) || run.call.value !in activities) return null + return Detached(run.run).also { detached[run.run] = it } + } + + /** The activity of a background run, shown after the reply that delegated it ended. */ + private inner class Detached(private val run: RunId) : ReplyActivity { + private var closed = false + + override suspend fun event(event: TurnEvent) { + if (closed) return + val started = event as? TurnEvent.ToolCallStarted ?: return + if (started.run != run) return + val line = activityLine(started) + val current = stream + if (streaming && current != null && !progress.isDeferred(request.turn.id)) { + if (!current.append("\n\n" + line)) { + sender.send( + request.turn.chat, + OutboundMessage.builder(line, MessageKind.NOTICE).build(), + ) + } + } else { + sender.send( + request.turn.chat, + OutboundMessage.builder(line, MessageKind.NOTICE).build(), + ) + } + } + + override suspend fun close(status: ToolCallStatus) { + if (closed) return + closed = true + detached.remove(run) + if (detached.isEmpty() && parentFinal != null) { + stream?.finishAppended() + parentFinal = null + } + } + } + + override suspend fun preview(segment: Int, text: String) { + if (!streaming) { + logger.debug( + "The preview of segment {} of {} is not sent: a group message of this platform is not changed", + segment, + request.turn.id, + ) + return + } + if (text.isEmpty() || progress.isDeferred(request.turn.id)) return + val trigger = request.trigger ?: return + val current = streamOf(trigger) + val chunk = next(answer, text) + answer = text + if (chunk.isNotEmpty()) current.append(chunk) + } + + override suspend fun startSegment(segment: Int) { + // The platform has one streaming message per reply, not one per segment; the next preview replaces it. + } + + /** Sends the words of a model response that calls a tool, which a non-streaming chat would otherwise lose. */ + override suspend fun showResponse(message: AssistantEntry) { + if (streaming) return + val said = message.parts.filterIsInstance().joinToString("\n\n") { it.text } + if (said.isBlank()) return + sender.send( + request.turn.chat, + OutboundMessage.builder(said, MessageKind.NOTICE).markup(Markup.MARKDOWN).build(), + ) + } + + override suspend fun event(event: TurnEvent) { + if (!config.progress || progress.isDeferred(request.turn.id)) return + val started = event as? TurnEvent.ToolCallStarted ?: return + activities[started.id.value] = activityLine(started) + if (streaming) { + val trigger = request.trigger ?: return + val current = streamOf(trigger) + val line = if (current.shownText.isEmpty()) activityLine(started) else "\n\n" + activityLine(started) + current.append(line) + } else { + waiting += activityLine(started) + val now = System.currentTimeMillis() + if (!noticed || now - noticedAt >= NOTICE_INTERVAL_MS) tellWaiting() + } + } + + override suspend fun complete(message: OutboundMessage): Delivery = try { + if (!streaming) tellWaiting() + if (streaming && !progress.isDeferred(request.turn.id)) { + val trigger = request.trigger + if (trigger != null) { + val current = streamOf(trigger) + val chunk = next(answer, message.text) + if (chunk.isNotEmpty()) current.append(chunk) + if (detached.isNotEmpty()) { + parentFinal = message.text + val id = current.messageId + if (id != null) return Delivery.Delivered(listOf(ChannelMessageRef(request.turn.chat, id))) + } else { + val finished = current.finishAppended() + if (finished != null) return finished + } + } + } + sender.send(request.turn.chat, withTrigger(message)) + } finally { + replies.close(request.turn.id) + progress.done(request.turn.id) + typing.stop(request.turn.chat) + } + + override suspend fun abandon(end: ReplyEnd) { + logger.info("The reply of {} was abandoned: {}", request.turn.id, end) + replies.close(request.turn.id) + progress.abandon(request.turn.id) + detached.clear() + parentFinal = null + stream?.abandon() + typing.stop(request.turn.chat) + } + + /** Sends the tool lines a non-streaming chat has not been told yet, as one notice. */ + private suspend fun tellWaiting() { + if (waiting.isEmpty()) return + val lines = waiting.toList() + waiting.clear() + noticed = true + noticedAt = System.currentTimeMillis() + sender.send( + request.turn.chat, + OutboundMessage.builder(lines.joinToString("\n"), MessageKind.NOTICE).build(), + ) + } + + /** What [text] adds to [previous]: its suffix, or a new paragraph when it does not continue it. */ + private fun next(previous: String, text: String): String = when { + text.isEmpty() -> "" + previous.isEmpty() -> text + text.startsWith(previous) -> text.removePrefix(previous) + else -> "\n\n" + text + } + + /** The streaming message of this reply, which [trigger] answers, registered for its background jobs. */ + private suspend fun streamOf(trigger: ChannelMessageRef): QqBotStream { + stream?.let { return it } + val created = QqBotStream(sender, request.turn.chat, trigger.id, sender.sequenceFor(trigger.id)) + stream = created + progress.attach(request.turn.id, request.turn.chat, created) + return created + } + + /** One line of the activity message, which the answer replaces when it arrives. */ + private fun activityLine(event: TurnEvent.ToolCallStarted): String { + val title = event.title.takeIf { it.isNotBlank() && it != event.tool } + val line = if (title == null) "⏳ ${event.tool}" else "⏳ ${event.tool} — $title" + return event.agent?.let { "⏳ $it · ${line.removePrefix("⏳ ")}" } ?: line + } + + /** [message] as an answer to the message the turn was started by, where it answers nothing yet. */ + private fun withTrigger(message: OutboundMessage): OutboundMessage { + val trigger = request.trigger ?: return message + if (message.replyTo != null) return message + return message.rebuild { replyTo(trigger) } + } + } + + private companion object { + val logger = LoggerFactory.getLogger(QqBotChannel::class.java) + + val TOOL_EVENTS: Set = + setOf(TurnEventKind.TOOL_CALL_STARTED, TurnEventKind.TOOL_CALL_UPDATED) + + /** How long tool lines of a non-streaming chat wait before they are told again; the first is told at once. */ + const val NOTICE_INTERVAL_MS: Long = 8_000L + + /** What this channel can do in a group: text and markdown, no streaming, no files and no live events. */ + val CAPABILITIES: ChannelCapabilities = ChannelCapabilities.builder() + .markups(setOf(Markup.PLAIN, Markup.MARKDOWN)) + .streaming(false) + .finalReplacesPreview(false) + .proactive(true) + .events(TOOL_EVENTS) + .files(true) + .maxFileBytes(QQ_BOT_MAX_FILE_BYTES) + .maxPartsPerReply(null) + .build() + + /** What this channel can do in a single chat, where the platform streams a message while it is written. */ + val STREAMING_CAPABILITIES: ChannelCapabilities = ChannelCapabilities.builder() + .markups(setOf(Markup.PLAIN, Markup.MARKDOWN)) + .streaming(true) + .finalReplacesPreview(true) + .proactive(true) + .events(TOOL_EVENTS) + .files(true) + .maxFileBytes(QQ_BOT_MAX_FILE_BYTES) + .maxPartsPerReply(null) + .build() + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRules.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRules.kt new file mode 100644 index 00000000..8c227620 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRules.kt @@ -0,0 +1,96 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.channel.qqbot.i18n.TextKeys +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotChats +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.turn.AgentControl +import org.foedusprogramme.alexandrite.sdk.turn.CommandHandler +import org.foedusprogramme.alexandrite.sdk.turn.CommandInvocation +import java.util.Locale + +/** Who may run a command. */ +internal enum class QqBotWho { + ANYONE, + + /** Anyone in a direct chat, admins elsewhere. */ + DIRECT, + + /** Anyone in a direct chat linked with no other chat, admins elsewhere. */ + UNLINKED_DIRECT, + + ADMIN, +} + +/** Who may run a command: [bare] without arguments, [withArguments] with them. */ +internal class QqBotCommandRule(val bare: QqBotWho, val withArguments: QqBotWho = bare) + +/** + * Who may run each command that a handler claims, after [MATRIX]: admins alone for a claimed command it does not list. + * + * This is a copy of the rules the other channels apply, made for this channel until the shared ones are moved to + * `channel-common`; it is a channel's own decision, since the agent's control takes the operator as a parameter and + * does not judge it. + */ +@Singleton +internal class QqBotCommandRules(handlers: List, private val control: AgentControl?) { + private val claimed = handlers.flatMap { it.commands }.mapTo(HashSet()) { it.name.lowercase(Locale.ROOT) } + + /** The key of the refusal of [invocation] in a chat of [kind], null when its issuer may run it. */ + fun refusal(invocation: CommandInvocation, kind: ChatKind): String? { + val name = invocation.name.lowercase(Locale.ROOT) + if (invocation.issuer.isAdmin || name !in claimed) return null + val rule = MATRIX[name] ?: ADMIN_ONLY + val direct = kind == ChatKind.DIRECT + return when (if (invocation.arguments.isBlank()) rule.bare else rule.withArguments) { + QqBotWho.ANYONE -> null + QqBotWho.DIRECT -> TextKeys.REFUSED_ADMIN_HERE.takeUnless { direct } + QqBotWho.UNLINKED_DIRECT -> TextKeys.REFUSED_ADMIN_HERE.takeUnless { direct && !linked(invocation.chat) } + QqBotWho.ADMIN -> TextKeys.REFUSED_ADMIN + } + } + + /** Whether an admin, or a member when [admin] is false, may run [command] in [chat]. */ + fun mayRun(chat: ChatAddress, admin: Boolean, command: String, withArguments: Boolean): Boolean { + val name = command.lowercase(Locale.ROOT) + if (admin || name !in claimed) return true + val rule = MATRIX[name] ?: ADMIN_ONLY + val direct = QqBotChats.kind(chat) == ChatKind.DIRECT + return when (if (withArguments) rule.withArguments else rule.bare) { + QqBotWho.ANYONE -> true + QqBotWho.DIRECT -> direct + QqBotWho.UNLINKED_DIRECT -> direct && !linked(chat) + QqBotWho.ADMIN -> false + } + } + + private fun linked(chat: ChatAddress): Boolean = control?.links().orEmpty().any { group -> + (group.members + group.anchor).any { it.parent == chat.parent } + } + + internal companion object { + /** The rules of the default commands, the same table the other channels apply. */ + val MATRIX: Map = mapOf( + "help" to QqBotCommandRule(QqBotWho.ANYONE), + "settings" to QqBotCommandRule(QqBotWho.ANYONE), + "context" to QqBotCommandRule(QqBotWho.ANYONE), + "profiles" to QqBotCommandRule(QqBotWho.ANYONE), + "agent" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.DIRECT), + "reasoning" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.UNLINKED_DIRECT), + "language" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.UNLINKED_DIRECT), + "model" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.ADMIN), + "mode" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.ADMIN), + "jobs" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.ADMIN), + "new" to QqBotCommandRule(QqBotWho.UNLINKED_DIRECT), + "compact" to QqBotCommandRule(QqBotWho.UNLINKED_DIRECT), + "cancel" to QqBotCommandRule(QqBotWho.DIRECT), + "trust" to QqBotCommandRule(QqBotWho.ADMIN), + "untrust" to QqBotCommandRule(QqBotWho.ADMIN), + "allow" to QqBotCommandRule(QqBotWho.ADMIN), + "streaming" to QqBotCommandRule(QqBotWho.ANYONE, QqBotWho.DIRECT), + ) + + private val ADMIN_ONLY = QqBotCommandRule(QqBotWho.ADMIN) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommands.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommands.kt new file mode 100644 index 00000000..f4265166 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommands.kt @@ -0,0 +1,23 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +/** + * The commands a message of this channel carries. + * + * The platform has no command entity, so a command is the same shape the Telegram channel reads from `bot_command`: + * a slash, then a name of 1 to 32 ASCII letters, digits or underscores, optionally followed by `@target`, and the + * arguments after it. A slash that does not start such a name is ordinary text. + */ +internal object QqBotCommands { + private val TOKEN = Regex("^/([A-Za-z0-9_]{1,32})(?:@[A-Za-z0-9_]{1,32})?$") + + /** The command [text] carries, null when its slash does not start one. */ + fun of(text: String): Parsed? { + val trimmed = text.trimStart() + val token = trimmed.takeWhile { !it.isWhitespace() } + val name = TOKEN.matchEntire(token)?.groupValues?.get(1) ?: return null + return Parsed(name, trimmed.removePrefix(token).trim()) + } + + /** The parts of a command: its [name] and its [arguments], which may be empty. */ + class Parsed(val name: String, val arguments: String) +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotInbox.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotInbox.kt new file mode 100644 index 00000000..c3b604da --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotInbox.kt @@ -0,0 +1,253 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.qqbot.gateway.QqBotGateway +import org.foedusprogramme.alexandrite.channel.qqbot.i18n.TextKeys +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotMedia +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotMessages +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFrame +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotMessageEvent +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.asTextOrNull +import org.foedusprogramme.alexandrite.channel.qqbot.tools.QqBotApis +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.foedusprogramme.alexandrite.sdk.i18n.Texts +import org.foedusprogramme.alexandrite.sdk.turn.Admission +import org.foedusprogramme.alexandrite.sdk.turn.Capacity +import org.foedusprogramme.alexandrite.sdk.turn.CommandInvocation +import org.foedusprogramme.alexandrite.sdk.turn.Submission +import org.foedusprogramme.alexandrite.sdk.turn.TurnSubmitter +import org.slf4j.LoggerFactory +import java.util.concurrent.ConcurrentHashMap + +/** + * What the gateway pushes, taken into the agent. + * + * The platform pushes a message of a single chat, and of a group only when the message names the bot, so both are the + * bot's to answer. Everything else a bot of the platform may subscribe to is left for a version that has a shape to + * hand it to another plugin. + * + * The platform may push the same message twice: a message id already read is dropped rather than becoming a second + * turn. + */ +@ChannelInstanceScoped +internal class QqBotInbox( + private val instance: ChannelInstance, + private val config: QqBotInstanceConfig, + private val gateway: QqBotGateway, + private val media: QqBotMedia, + private val approvals: QqBotApprovals, + private val sender: QqBotSender, + private val rules: QqBotCommandRules, + private val texts: Texts, + private val apis: QqBotApis, + private val api: QqBotApi, + private val index: QqBotMessageIndex, + /** The agent, null while the runtime has none. */ + private val submitter: TurnSubmitter?, +) : Lifecycle { + private var reading: Job? = null + + /** The openid the platform knows this bot by, which a full-mode group message names it by when it is addressed. */ + @Volatile + private var botOpenid: String? = null + + /** The bot's own member openid of each group, which `bot_state` or its own messages name. */ + private val botMemberOpenids = ConcurrentHashMap() + + /** The groups whose `bot_state` was already asked for, so a refusal is not asked for again and again. */ + private val botStateAsked = ConcurrentHashMap.newKeySet() + + private val seen = object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > KEPT + } + + override suspend fun onOpen() { + apis.register(instance.id, api) + identify() + if (submitter == null) { + logger.warn("{}: the runtime has no agent, so the messages of {} reach nobody", PLUGIN, instance.id) + return + } + reading = instance.scope.launch { read() } + } + + /** Asks the platform for the bot's own id, which a full-mode group message may mention it by. */ + private suspend fun identify() { + val answer = api.get("/users/@me") + val body = (answer as? QqBotAnswer.Ok)?.body as? JsonObject + botOpenid = body?.get("id")?.asTextOrNull() + if (botOpenid == null) logger.debug("{}: the platform did not name the bot itself", PLUGIN) + } + + override suspend fun onClose() { + reading?.cancel() + reading = null + apis.forget(instance.id) + } + + private suspend fun read() { + gateway.frames.collect { frame -> read(frame) } + } + + /** Takes one frame of the gateway, and the message it dispatches when it dispatches one. */ + private suspend fun read(frame: QqBotFrame) { + if (frame.op != OP_DISPATCH || frame.t == null) return + if (frame.t != C2C_MESSAGE_CREATE && frame.t != GROUP_AT_MESSAGE_CREATE && frame.t != GROUP_MESSAGE_CREATE) { + logger.debug("{}: the platform dispatched {}, which this version leaves alone", PLUGIN, frame.t) + return + } + val event = QqBotWire.message(frame.d) ?: run { + logger.warn("{}: an event {} carries no message this version knows", PLUGIN, frame.t) + return + } + if (event.author.bot && event.groupOpenid != null) { + event.author.memberOpenid?.let { botMemberOpenids[event.groupOpenid] = it } + } + if (frame.t == GROUP_MESSAGE_CREATE && !addressed(event)) { + logger.debug( + "{}: a group message of the full mode, {} in {}, is not addressed to this bot", + PLUGIN, + event.id, + event.groupOpenid, + ) + return + } + if (event.author.bot) return + if (!first(event.id)) { + logger.debug("{}: the platform pushed the message {} again", PLUGIN, event.id) + return + } + if (QqBotMessages.text(event).isBlank() && event.attachments.isEmpty() && event.msgElements.isEmpty()) { + logger.info( + "{}: the message {} was only a mention of the bot or was empty; no turn is asked", + PLUGIN, + event.id, + ) + return + } + val message = QqBotMessages.incoming(instance.id, event, config.admins, media, index) + // A diagnostic that works before any tool: the person can read the id the bot knows them by, which is what + // the instance's admins list and the agent's tool rules are keyed by. + if (message.text.trim().equals(WHOAMI, ignoreCase = true)) { + sender.send( + message.chat, + OutboundMessage.builder( + "chat=${message.chat.chat}\nsender=${message.sender.address}", + MessageKind.NOTICE, + ).build(), + ) + return + } + val press = QqBotApprovalCommands.of(message.text) + if (press != null) { + logger.info("{}: {} answers an approval of {}", PLUGIN, message.sender.address, press.id) + approvals.answer(press.name, press.id, press.always, message.sender, message.chat)?.let { said -> + sender.send(message.chat, OutboundMessage.builder(said, MessageKind.NOTICE).build()) + } + return + } + val command = QqBotCommands.of(message.text) + if (command != null) { + val invocation = CommandInvocation.builder(message.chat, command.name, message.sender) + .arguments(command.arguments) + .trigger(message.ref) + .build() + val refusal = rules.refusal(invocation, message.chatInfo.kind) + if (refusal != null) { + val said = texts.text(refusal, null, "command" to command.name.lowercase()) + sender.send(message.chat, OutboundMessage.builder(said, MessageKind.NOTICE).build()) + return + } + val capacity = if (message.sender.isAdmin) Capacity.EXEMPT else Capacity.COUNTED + logger.info("{}: the command {} of {} becomes a turn for the agent", PLUGIN, command.name, message.chat) + report(message, submitter?.submit(Submission.Command(invocation, message, capacity))) + return + } + logger.info("{}: the message {} of {} becomes a turn for the agent", PLUGIN, event.id, message.chat) + report(message, submitter?.submit(Submission.Message(message))) + } + + /** Whether [id] is a message this inbox reads for the first time. */ + private fun first(id: String): Boolean = synchronized(seen) { seen.put(id, Unit) == null } + + /** + * Whether a full-mode group message names this bot. + * + * The platform pushes every group message in the full mode, and only the mention of the bot makes one a message + * for the bot. The mention list names the bot by its id, when the platform keeps it, and the text may still start + * with `<@…>`, which the bot's own id is compared against. + */ + private suspend fun addressed(event: QqBotMessageEvent): Boolean { + val group = event.groupOpenid ?: return false + val me = botOpenid + val member = botMemberOpenids[group] + if (event.mentions.any { mention -> mention.bot && namesBot(mention, me, member) }) return true + val token = LEADING_MENTION.find(event.content)?.groupValues?.get(1) ?: return false + if (token == me || token == member) return true + if (member == null && botStateAsked.add(group)) { + val answer = api.get("/v2/groups/$group/bot_state") + val body = (answer as? QqBotAnswer.Ok)?.body as? JsonObject + val openid = body?.get("member_openid")?.asTextOrNull() + if (openid != null) { + botMemberOpenids[group] = openid + return token == openid + } + logger.debug("{}: the platform did not name the bot's member openid of {}", PLUGIN, group) + } + return false + } + + /** Whether [mention] is the bot, by the global id or the member openid of its group. */ + private fun namesBot( + mention: org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotAuthor, + me: String?, + member: String?, + ): Boolean = (me == null && member == null) || mention.id == me || mention.memberOpenid == me || + mention.userOpenid == me || mention.id == member || mention.memberOpenid == member || + mention.userOpenid == member + + /** Reports a submission the agent refused, which would otherwise be silence nobody can explain. */ + private fun report(message: org.foedusprogramme.alexandrite.sdk.channel.IncomingMessage, admission: Admission?) { + if (admission !is Admission.Refused) return + logger.warn( + "{}: instance {} took a message of {} no further: {}", + PLUGIN, + instance.id, + message.chat, + admission.reason, + ) + } + + private companion object { + const val PLUGIN = "alexandrite-channel-qqbot" + + const val OP_DISPATCH = 0 + + const val C2C_MESSAGE_CREATE = "C2C_MESSAGE_CREATE" + + const val GROUP_AT_MESSAGE_CREATE = "GROUP_AT_MESSAGE_CREATE" + + /** The full-mode event of the groups that the bot takes every message of, not only the ones naming it. */ + const val GROUP_MESSAGE_CREATE = "GROUP_MESSAGE_CREATE" + + /** The command that tells a person the address this instance knows them by, for `admins` and tool rules. */ + const val WHOAMI = "/whoami" + + /** How many message ids the inbox keeps to tell a repeated push from a new message. */ + const val KEPT = 1024 + + val LEADING_MENTION = Regex("^\\s*<@!?([^>]+)>") + + val logger = LoggerFactory.getLogger(QqBotInbox::class.java) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt new file mode 100644 index 00000000..d81767d2 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMenuSync.kt @@ -0,0 +1,156 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.sdk.chat.LanguageTag +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.foedusprogramme.alexandrite.sdk.turn.CommandHandler +import org.foedusprogramme.alexandrite.sdk.turn.CommandSpec +import org.slf4j.LoggerFactory + +/** + * Publishes the bot's slash commands as the single-chat menu and as command panels of the open platform. + * + * The platform calls these configurations the global custom menu (`/v2/menu`, C2C only) and command panels + * (`/v2/panels`, C2C and groups). Both are what a user sees and clicks; a click only fills the command into the input + * box, so the usual command handling still answers it. + */ +@ChannelInstanceScoped +internal class QqBotMenuSync( + private val api: QqBotApi, + private val config: QqBotInstanceConfig, + handlers: List, +) : Lifecycle { + private val entries: List> = + handlers.flatMap { handler -> handler.commands.map { handler to it } }.sortedBy { (_, it) -> it.name } + private val commands: List = entries.map { it.second } + private val language: LanguageTag? = config.syncLanguage?.let(LanguageTag::of) + + override suspend fun onOpen() { + if (!config.syncCommands || commands.isEmpty()) return + putMenu() + putPanel("c2c") + putPanel("group") + } + + private suspend fun putMenu() { + val body = menuBody(commands) + when (val answer = api.put("/v2/menu", body)) { + is QqBotAnswer.Ok -> logger.info("{}: published the menu of {} commands", PLUGIN, commands.size) + + is QqBotAnswer.Failed -> + logger.warn( + "{}: could not publish the menu: {} {}", + PLUGIN, + answer.failure.code, + answer.failure.message, + ) + } + } + + private suspend fun putPanel(scope: String) { + val body = panelBody(scope, entries, language) + val panel = body["panel"] as JsonObject + val existing = panelId(scope) + val answer = if (existing == null) { + api.post("/v2/panels", body) + } else { + api.put("/v2/panels/$existing", buildJsonObject { put("panel", panel) }) + } + when (answer) { + is QqBotAnswer.Ok -> + logger.info("{}: published the {} command panel", PLUGIN, scope) + + is QqBotAnswer.Failed -> + logger.warn( + "{}: could not publish the {} command panel: {} {}", + PLUGIN, + scope, + answer.failure.code, + answer.failure.message, + ) + } + } + + /** The id of the panel this bot published for [scope], null when it has none yet. */ + private suspend fun panelId(scope: String): String? { + val answer = api.get("/v2/panels", mapOf("scope" to scope, "limit" to "50")) as? QqBotAnswer.Ok ?: return null + val records = (answer.body as? JsonObject)?.get("records") as? JsonArray ?: return null + return records.asSequence() + .mapNotNull { it as? JsonObject } + .firstOrNull { ((it["panel"] as? JsonObject)?.get("remark") as? JsonPrimitive)?.content == REMARK } + ?.let { (it["panel_id"] as? JsonPrimitive)?.content } + } + + companion object { + private const val PLUGIN = "alexandrite-channel-qqbot" + + private const val REMARK = "alexandrite commands" + + /** The platform's menu of [commands], a C2C menu of at most ten send-message items. */ + internal fun menuBody(commands: List): JsonObject = buildJsonObject { + put( + "menu", + buildJsonObject { + put( + "items", + buildJsonArray { + for (command in commands.take(10)) { + add( + buildJsonObject { + put("type", JsonPrimitive("send_message")) + put("name", JsonPrimitive(command.name.take(10))) + put("send_message", JsonPrimitive("/${command.name}")) + }, + ) + } + }, + ) + }, + ) + } + + /** The platform's [scope] panel of [entries], with at most twenty command items. */ + internal fun panelBody( + scope: String, + entries: List>, + language: LanguageTag?, + ): JsonObject = buildJsonObject { + put("scope", JsonPrimitive(scope)) + put("target_type", JsonPrimitive("all")) + put( + "panel", + buildJsonObject { + put( + "items", + buildJsonArray { + for ((handler, command) in entries.take(20)) { + val description = runCatching { handler.description(command.name, language) } + .getOrElse { command.description } + .trim() + .ifEmpty { command.description } + add( + buildJsonObject { + put("type", JsonPrimitive("command")) + put("name", JsonPrimitive("/${command.name}".take(14))) + put("desc", JsonPrimitive(description.take(30))) + }, + ) + } + }, + ) + put("remark", JsonPrimitive(REMARK)) + }, + ) + } + + private val logger = LoggerFactory.getLogger(QqBotMenuSync::class.java) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMessageIndex.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMessageIndex.kt new file mode 100644 index 00000000..b1e7a5e6 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMessageIndex.kt @@ -0,0 +1,43 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import java.util.concurrent.ConcurrentHashMap + +/** + * The message ids behind the reference indexes the platform uses for quotes. + * + * A quote event names the quoted message by a `msg_idx` or `ref_msg_idx`, not by the message id the recall endpoint + * takes. This keeps both the indexes of the messages the bot received and the `ref_idx` of the messages it sent, so a + * quoted message can be resolved to the real id. + */ +@Singleton +internal class QqBotMessageIndex { + private val ids = ConcurrentHashMap() + private val references = ConcurrentHashMap() + private val quotes = ConcurrentHashMap() + + private data class Key(val chat: ChatAddress, val reference: String) + + /** Notes that the message [id] of [chat] is named [reference]. */ + fun record(chat: ChatAddress, reference: String?, id: String) { + if (reference.isNullOrBlank() || id.isBlank()) return + ids[Key(chat, reference)] = id + references[Key(chat, id)] = reference + } + + /** Notes that the message [trigger] of [chat] quotes the message [target]. */ + fun recordQuote(chat: ChatAddress, trigger: String?, target: String?) { + if (trigger.isNullOrBlank() || target.isNullOrBlank()) return + quotes[Key(chat, trigger)] = target + } + + /** The message id [reference] names in [chat], null when it was not seen. */ + fun resolve(chat: ChatAddress, reference: String?): String? = reference?.let { ids[Key(chat, it)] } + + /** The reference that names the message [id] of [chat], null when it was not seen. */ + fun referenceOf(chat: ChatAddress, id: String?): String? = id?.let { references[Key(chat, it)] } + + /** The message id that the message [trigger] of [chat] quotes, null when it quotes none this process saw. */ + fun quotedTarget(chat: ChatAddress, trigger: String?): String? = trigger?.let { quotes[Key(chat, it)] } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgress.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgress.kt new file mode 100644 index 00000000..f6fc10c4 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgress.kt @@ -0,0 +1,62 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.TurnId +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import java.util.concurrent.ConcurrentHashMap + +/** + * The open streaming message of each qqbot turn, which an approval closes before the reply continues. + * + * A reply's stream belongs to the turn that opened it. Once an approval message is shown, the reply's final answer + * cannot be moved below it; this registry lets the approval desk close the stream and remember that the answer is to + * be sent as a message of its own. + */ +@Singleton +internal class QqBotProgress { + private class Open(val chat: ChatAddress, val stream: QqBotStream) + + private val open = ConcurrentHashMap() + private val gates = ConcurrentHashMap() + private val deferred = ConcurrentHashMap.newKeySet() + + private fun gate(turn: TurnId): Mutex = gates.computeIfAbsent(turn) { Mutex() } + + /** Makes [stream] the streaming message of [turn]. */ + suspend fun attach(turn: TurnId, chat: ChatAddress, stream: QqBotStream) { + gate(turn).withLock { + if (turn in deferred) return@withLock + open.getOrPut(turn) { Open(chat, stream) } + } + } + + /** + * Closes the stream of [turn] before an approval message, whose answer then arrives as an ordinary message after it. + * + * A streaming message cannot be moved below the approval request once more of it is written, so the turn's final + * answer is sent as a message of its own instead. + */ + suspend fun defer(turn: TurnId) { + gate(turn).withLock { + open.remove(turn)?.let { entry -> entry.stream.finish(entry.stream.shownText) } + deferred += turn + } + } + + /** Whether the final answer of [turn] is to be sent outside its stream, after an approval request. */ + fun isDeferred(turn: TurnId): Boolean = turn in deferred + + /** Done with [turn], whose deferred answer was sent or whose stream is its own from here. */ + fun done(turn: TurnId) { + deferred -= turn + open.remove(turn) + } + + /** Forgets [turn], which ended without a stream to close. */ + suspend fun abandon(turn: TurnId) { + deferred -= turn + gate(turn).withLock { open.remove(turn) } + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotPromptHook.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotPromptHook.kt new file mode 100644 index 00000000..b0db7c5f --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotPromptHook.kt @@ -0,0 +1,44 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.hook.Hook +import org.foedusprogramme.alexandrite.sdk.hook.HookDecision +import org.foedusprogramme.alexandrite.sdk.hook.InterceptorHook +import org.foedusprogramme.alexandrite.sdk.hook.InterceptorPoint +import org.foedusprogramme.alexandrite.sdk.model.PromptSection +import org.foedusprogramme.alexandrite.sdk.turn.PromptSections +import org.foedusprogramme.alexandrite.sdk.turn.TurnPoints + +/** + * Tells the model what the QQ client renders, so it writes what the channel can show. + * + * The adapter of the channel still turns what the client only shows as raw text into a form it renders; this section + * only makes the model less likely to write it in the first place. A turn of another channel is left alone. + */ +@Singleton +@Contribute(Hook::class) +internal class QqBotPromptHook : InterceptorHook { + override val point: InterceptorPoint get() = TurnPoints.PROMPT_SECTIONS + + override suspend fun intercept(payload: PromptSections): HookDecision { + if (payload.turn.chat.instance.type != QQ_BOT) return HookDecision.Continue + if (payload.sections.any { it.id == SECTION_ID }) return HookDecision.Continue + val section = PromptSection(SECTION_ID, PROMPT, stable = true) + return HookDecision.Replace(payload.withSections(payload.sections + section)) + } + + private companion object { + val QQ_BOT = ChannelType("qqbot") + + const val SECTION_ID = "qqbot.platform" + + const val PROMPT = "This chat is a QQ official bot. Write the Markdown the QQ client renders: headings, " + + "paragraphs, emphasis, lists, blockquotes, fenced code, GFM tables, task lists and links. Do not use " + + "HTML blocks such as
, , or , Mermaid diagrams, [TOC], ==highlight== or " + + "definition lists; the client shows those as raw text. An image needs a public http(s) URL; a local path " + + "is not shown. In a group, do not make a link the only way to say something: the platform may refuse a " + + "message holding a URL. A command starts with a slash, such as /model." + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSender.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSender.kt new file mode 100644 index 00000000..b95ad5ec --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSender.kt @@ -0,0 +1,454 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotChats +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotMarkdown +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFailure +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotMessageType +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotSent +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.asTextOrNull +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.DeliveryFailure +import org.foedusprogramme.alexandrite.sdk.channel.Markup +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.slf4j.LoggerFactory + +/** + * What this instance sends, as the open platform takes it. + * + * A message written to a chat as an answer to one the bot was sent names that message as its `msg_id` and a sequence + * number, which is what makes it a passive reply the platform takes within the minutes it allows. A message written + * with nothing to answer is an active one, which the platform limits by the chat and by the bot: the limits are its + * own and the channel sends nothing beyond what it is told. + * + * The sequence number of one message a chat is answered counts up: the same pair of a message and a sequence is sent + * once, and a second answer to the same message sends the next number. A reply the platform no longer takes because + * the minutes passed is sent once more as an active message, so a slow answer still reaches the chat. + */ +@ChannelInstanceScoped +internal class QqBotSender( + private val api: QqBotApi, + private val uploads: QqBotUploads, + private val sent: QqBotSentMessages, + private val index: QqBotMessageIndex, +) { + private val sequences = Seqs() + + /** + * Sends [message] to [chat]. + * + * The platform has no caption on a media message: a text and its files are separate messages. A message with files + * uploads all of them first, so a file the platform refuses is known before the chat is told anything, then sends + * the text when it has one and a `msg_type=7` message for each file. The ids of everything it sent are delivered + * together. + */ + suspend fun send(chat: ChatAddress, message: OutboundMessage): Delivery { + val path = QqBotChats.sendPath(chat) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + if (message.files.isEmpty()) return sendText(path, chat, message) + message.files.firstOrNull { it.size > QQ_BOT_MAX_FILE_BYTES }?.let { file -> + return Delivery.NotDelivered( + DeliveryFailure.UNKNOWN, + "'${file.name}' holds ${file.size} bytes, more than the $QQ_BOT_MAX_FILE_BYTES bytes a message of " + + "this platform may take.", + ) + } + val uploaded = mutableListOf() + for (file in message.files) { + when (val answer = uploads.upload(chat, file)) { + is QqBotUpload.Failed -> return Delivery.NotDelivered( + DeliveryFailure.UNKNOWN, + "'${file.name}' was not sent: ${answer.reason}", + ) + + is QqBotUpload.Uploaded -> uploaded += answer.fileInfo + } + } + val refs = mutableListOf() + val replyTo = message.replyTo?.id?.takeIf { it.isNotEmpty() } + for (fileInfo in uploaded) { + val delivery = sendMedia(path, chat, fileInfo, replyTo) + if (delivery !is Delivery.Delivered) return delivery + refs += delivery.messages + } + return Delivery.Delivered(refs) + } + + /** Sends [message] as one text or markdown message, [path] being the messages endpoint of [chat]. */ + private suspend fun sendText(path: String, chat: ChatAddress, message: OutboundMessage): Delivery { + val replyTo = message.replyTo?.id?.takeIf { it.isNotEmpty() } + val reference = replyTo?.let { index.referenceOf(chat, it) } + var markdown = message.markup.id == Markup.MARKDOWN.id + var text = if (markdown) QqBotMarkdown.render(message.text) else message.text + var answer = post(path, text, markdown, replyTo, replyTo?.let(sequences::next), reference) + var failure = (answer as? QqBotAnswer.Failed)?.failure + if (failure != null && failure.code in MARKDOWN_REFUSED && markdown) { + logger.info( + "{}: the platform takes no markdown in {} ({}); sending the message as plain text", + PLUGIN, + chat, + failure.code, + ) + markdown = false + text = message.text + answer = post(path, text, markdown, replyTo, replyTo?.let(sequences::next), reference) + failure = (answer as? QqBotAnswer.Failed)?.failure + } + if (failure != null && failure.code in URL_REFUSED) { + logger.info("{}: the platform refuses a message of {} with a URL; sending it without links", PLUGIN, chat) + markdown = false + text = QqBotMarkdown.withoutUrls(message.text) + answer = post(path, text, markdown, replyTo, replyTo?.let(sequences::next), reference) + failure = (answer as? QqBotAnswer.Failed)?.failure + } + if (failure != null && failure.code in PASSIVE_GONE && replyTo != null) { + logger.info( + "{}: the message {} is no longer answered passively; sending as an active message", + PLUGIN, + replyTo, + ) + answer = post(path, text, markdown, msgId = null, msgSeq = null, reference = reference) + } + return delivery(chat, answer) + } + + /** Sends one uploaded file as the platform's media message, answering [replyTo] when it names one. */ + private suspend fun sendMedia(path: String, chat: ChatAddress, fileInfo: String, replyTo: String?): Delivery { + var answer = postMedia(path, fileInfo, replyTo, replyTo?.let(sequences::next)) + var failure = (answer as? QqBotAnswer.Failed)?.failure + if (failure != null && failure.code in PASSIVE_GONE && replyTo != null) { + logger.info( + "{}: the media reply {} is no longer answered passively; sending it as an active message", + PLUGIN, + replyTo, + ) + answer = postMedia(path, fileInfo, msgId = null, msgSeq = null) + failure = (answer as? QqBotAnswer.Failed)?.failure + } + if (failure != null) { + logger.warn( + "{}: the platform took no media message in {}: {} {}", + PLUGIN, + chat, + failure.code, + failure.message, + ) + } + return delivery(chat, answer) + } + + /** Sends the uploaded [fileInfo] as a `msg_type=7` message. */ + private suspend fun postMedia(path: String, fileInfo: String, msgId: String?, msgSeq: Int?): QqBotAnswer { + val body = buildJsonObject { + put("msg_type", QqBotMessageType.MEDIA) + putJsonObject("media") { put("file_info", fileInfo) } + if (msgId != null) { + put("msg_id", msgId) + put("msg_seq", msgSeq ?: 1) + } + } + return api.post(path, body) + } + + /** Sends the approval message [text] of [id] to [chat], with the buttons that answer it. */ + internal suspend fun sendApproval( + chat: ChatAddress, + text: String, + id: String, + allow: String, + deny: String, + always: String? = null, + ): Delivery { + val path = QqBotChats.sendPath(chat) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + var answer = api.post(path, approvalBody(text, id, allow, deny, always, markdown = true)) + var failure = (answer as? QqBotAnswer.Failed)?.failure + // The client shows a keyboard on a markdown message; where this bot has no markdown permission, a text + // message is sent instead, which is at least the request itself. + if (failure != null && failure.code in MARKDOWN_REFUSED) { + logger.info("{}: the platform takes no markdown approval; sending the text without it", PLUGIN) + answer = api.post(path, approvalBody(text, id, allow, deny, always, markdown = false)) + failure = (answer as? QqBotAnswer.Failed)?.failure + } + if (failure != null) { + logger.warn("{}: the platform took no approval in {}: {} {}", PLUGIN, chat, failure.code, failure.message) + } + return delivery(chat, answer) + } + + /** The body of an approval message, with the keyboard that answers it. */ + private fun approvalBody( + text: String, + id: String, + allow: String, + deny: String, + always: String?, + markdown: Boolean, + ): JsonObject = buildJsonObject { + if (markdown) { + put("msg_type", QqBotMessageType.MARKDOWN) + putJsonObject("markdown") { put("content", text.trim()) } + } else { + put("msg_type", QqBotMessageType.TEXT) + put("content", text) + } + putJsonObject("keyboard") { + putJsonObject("content") { + putJsonArray("rows") { + addJsonObject { + putJsonArray("buttons") { + add(approvalButton("allow", allow, "/approve $id")) + add(approvalButton("deny", deny, "/deny $id")) + } + } + if (always != null) { + addJsonObject { + putJsonArray("buttons") { + add(approvalButton("always", always.take(10), "/approve $id always")) + } + } + } + } + } + } + } + + /** A button whose press writes [data] as a message, which the platform may send by itself. */ + private fun approvalButton(id: String, label: String, data: String): JsonObject = buildJsonObject { + put("id", id) + putJsonObject("render_data") { + put("label", label) + put("style", if (id == "allow") 4 else 0) + } + putJsonObject("action") { + put("type", 2) + putJsonObject("permission") { put("type", 2) } + put("data", data) + put("enter", true) + put("reply", false) + } + } + + /** Tells [chat] that the bot is writing, where the platform has such a state. */ + internal suspend fun sendTyping(chat: ChatAddress): Delivery { + if (QqBotChats.kind(chat) != org.foedusprogramme.alexandrite.sdk.chat.ChatKind.DIRECT) { + return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance types in no $chat.") + } + val path = QqBotChats.sendPath(chat) + ?: return Delivery.NotDelivered(DeliveryFailure.UNKNOWN, "This instance serves no $chat.") + val body = buildJsonObject { + put("msg_type", QqBotMessageType.TYPING) + putJsonObject("input_notify") { + put("input_type", 1) + put("input_second", TYPING_SECONDS) + } + } + return when (val answer = api.post(path, body)) { + is QqBotAnswer.Ok -> Delivery.Delivered(listOf(ChannelMessageRef(chat, sentId(answer) ?: "typing"))) + + is QqBotAnswer.Failed -> Delivery.NotDelivered( + failureKind(answer.failure, answer.status), + answer.failure.message, + ) + } + } + + /** The sequence number that answers [msgId] next. */ + internal fun sequenceFor(msgId: String): Int = sequences.next(msgId) + + /** + * One piece of the streaming message that answers [msgId]. + * + * The platform serves streaming for single chats only. A piece either starts the message, when [streamId] is null, + * or replaces its content with the whole [text] so far; [last] ends it. + */ + internal suspend fun stream( + chat: ChatAddress, + text: String, + msgId: String, + msgSeq: Int, + streamId: String?, + index: Int, + last: Boolean, + append: Boolean = false, + ): QqBotAnswer { + val openid = QqBotChats.openid(chat) + ?.takeIf { QqBotChats.kind(chat) == org.foedusprogramme.alexandrite.sdk.chat.ChatKind.DIRECT } + ?: return QqBotAnswer.Failed(QqBotFailure(null, "This instance streams no $chat.", null), null) + val body = buildJsonObject { + put("input_mode", if (append) "append" else "replace") + put("input_state", if (last) STREAM_FINISHED else STREAM_GENERATING) + put("index", index) + put("content_type", "markdown") + if (text.isNotEmpty()) put("content_raw", QqBotMarkdown.render(text)) + put("msg_id", msgId) + put("msg_seq", msgSeq) + if (streamId != null) put("stream_msg_id", streamId) + } + val answer = api.post("/v2/users/$openid/stream_messages", body) + if (answer is QqBotAnswer.Failed) { + logger.warn( + "{}: could not stream a piece to {}: {} {}", + PLUGIN, + chat, + answer.failure.code, + answer.failure.message, + ) + } + return answer + } + + /** The id of the message [answer] reports, null when it reports none. */ + internal fun sentId(answer: QqBotAnswer): String? = when (answer) { + is QqBotAnswer.Ok -> answer.body?.let { + runCatching { QqBotWire.json.decodeFromJsonElement(QqBotSent.serializer(), it) }.getOrNull() + }?.id ?: (answer.body as? JsonObject)?.get("id")?.asTextOrNull() + + is QqBotAnswer.Failed -> null + } + + /** The body that sends [text] as a reply to [msgId] with [msgSeq], or as an active message with neither. */ + private fun body(text: String, markdown: Boolean, msgId: String?, msgSeq: Int?, reference: String?): JsonObject = + buildJsonObject { + if (markdown) { + put("msg_type", QqBotMessageType.MARKDOWN) + putJsonObject("markdown") { put("content", text.trim()) } + } else { + put("msg_type", QqBotMessageType.TEXT) + put("content", text) + } + if (msgId != null) { + put("msg_id", msgId) + put("msg_seq", msgSeq ?: 1) + } + if (reference != null) { + putJsonObject("message_reference") { put("message_id", reference) } + } + } + + /** Sends [text] as [markdown] or as plain text, answering [msgId] with [msgSeq] when it names one. */ + private suspend fun post( + path: String, + text: String, + markdown: Boolean, + msgId: String?, + msgSeq: Int?, + reference: String? = null, + ): QqBotAnswer = api.post(path, body(text, markdown, msgId, msgSeq, reference)) + + /** What [answer] means for [chat]. */ + internal fun delivery(chat: ChatAddress, answer: QqBotAnswer): Delivery = when (answer) { + is QqBotAnswer.Ok -> { + val parsed = answer.body?.let { + runCatching { QqBotWire.json.decodeFromJsonElement(QqBotSent.serializer(), it) }.getOrNull() + } + val id = parsed?.id ?: (answer.body as? JsonObject)?.get("id")?.asTextOrNull() ?: "sent" + logger.info("{}: sent a message to {} as {}", PLUGIN, chat, id) + this.sent.record(chat, id) + index.record(chat, parsed?.extInfo?.refIdx, id) + Delivery.Delivered(listOf(ChannelMessageRef(chat, id))) + } + + is QqBotAnswer.Failed -> { + val kind = failureKind(answer.failure, answer.status) + val detail = answer.failure.message.ifBlank { "the platform gave no reason" } + logger.warn( + "{}: could not send to {}: {} {} {}", + PLUGIN, + chat, + kind, + answer.failure.code, + detail, + ) + Delivery.NotDelivered( + kind, + "The platform did not take the message: $detail" + + (answer.failure.code?.let { " (code $it)" } ?: ""), + ) + } + } + + /** What a failure of the platform means for a caller that may try again. */ + private fun failureKind(failure: QqBotFailure, status: Int?): DeliveryFailure { + val code = failure.code + return when { + status == 429 || code in RATE_LIMITED_CODES -> DeliveryFailure.RATE_LIMITED + code in TOO_LONG_CODES -> DeliveryFailure.TOO_LONG + status == 401 || status == 403 || code in FORBIDDEN_CODES -> DeliveryFailure.FORBIDDEN + status != null && status >= 500 -> DeliveryFailure.TRANSIENT + else -> DeliveryFailure.UNKNOWN + } + } + + /** The sequence numbers of the messages this channel answered, oldest forgotten first. */ + private class Seqs { + private val byMessage = object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > KEPT + } + + /** The sequence number of the next reply to [id]. */ + fun next(id: String): Int = synchronized(byMessage) { + val next = (byMessage[id] ?: 0) + 1 + byMessage[id] = next + next + } + + private companion object { + /** How many messages a sequence is kept for. */ + const val KEPT = 512 + } + } + + private companion object { + const val PLUGIN = "alexandrite-channel-qqbot" + + /** The codes with which the platform says the minutes or replies of a passive answer ran out. */ + val PASSIVE_GONE = setOf(304103, 40034005, 40034128) + + /** The codes with which the platform says it takes no markdown of this bot, which plain text may pass. */ + val MARKDOWN_REFUSED = setOf(304036, 40034008, 40034009, 40034011, 40034124, 40034127) + + /** The codes with which the platform says it refuses a message holding a URL. */ + val URL_REFUSED = setOf(40054010) + + /** What the streaming endpoint calls a message still being written, and the call that ends it. */ + const val STREAM_GENERATING = 1 + const val STREAM_FINISHED = 10 + + /** How long one typing notification lasts, as the platform counts it. */ + const val TYPING_SECONDS = 60 + + /** The codes with which the platform says it takes no more messages as fast as they are sent. */ + val RATE_LIMITED_CODES = setOf(1100100, 1100308, 40034100, 40034101, 40054002) + + val TOO_LONG_CODES = setOf(40054007, 40054018) + + /** The codes with which the platform says this bot may not write here at all. */ + val FORBIDDEN_CODES = setOf( + 11253, + 11254, + 11265, + 304004, + 304036, + 340067, + 40034105, + 40054003, + 40054004, + 40054013, + 40054016, + ) + + val logger = LoggerFactory.getLogger(QqBotSender::class.java) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSentMessages.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSentMessages.kt new file mode 100644 index 00000000..990d6d85 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSentMessages.kt @@ -0,0 +1,30 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import java.util.concurrent.ConcurrentHashMap + +/** + * The last message the bot sent to each chat, by the id the platform reported for it. + * + * The platform recalls a message by the id its send answered with, which is not the id of the message a turn answers. + * Keeping it here lets a recall without an explicit id take back the bot's own last message instead of the user's. + */ +@Singleton +internal class QqBotSentMessages { + private val last = ConcurrentHashMap() + + /** Notes that the bot sent [id] to [chat], where the platform reported a real id. */ + fun record(chat: ChatAddress, id: String) { + if (id.isBlank() || id == "sent" || id == "typing") return + last[chat] = id + } + + /** The id of the last message the bot sent to [chat], null when it sent none this process knows. */ + fun lastOf(chat: ChatAddress): String? = last[chat] + + /** Forgets the last message of [chat]. */ + fun forget(chat: ChatAddress) { + last.remove(chat) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotStream.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotStream.kt new file mode 100644 index 00000000..96e928b0 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotStream.kt @@ -0,0 +1,105 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress + +/** + * One streaming message of a single chat, which the platform lets a reply grow in place. + * + * The first piece starts the message and gives the id the later pieces name; every piece replaces the shown text with + * the whole text so far, and the last one ends the message. A platform that takes no streaming, or a piece it refuses, + * leaves the reply to the ordinary message path: the parts are then not shown, and only the final message is sent. + */ +internal class QqBotStream( + private val sender: QqBotSender, + private val chat: ChatAddress, + private val msgId: String, + private val msgSeq: Int, +) { + private var streamId: String? = null + private var index = 0 + private var shown = "" + private var failed = false + + /** The id of the platform message this stream writes, null before the first piece was taken. */ + val messageId: String? get() = streamId + + /** What the platform has accepted as the message so far. */ + val shownText: String get() = shown + + /** Shows [text], the whole text of the streaming message so far; false once the platform refuses. */ + suspend fun show(text: String): Boolean { + if (failed || text.isEmpty()) return false + val delta = delta(text) + if (delta.isEmpty()) return true + val answer = sender.stream(chat, delta, msgId, msgSeq, streamId, index, last = false, append = true) + if (answer !is QqBotAnswer.Ok) { + failed = true + return false + } + val id = sender.sentId(answer) ?: run { + failed = true + return false + } + streamId = id + shown += delta + index++ + return true + } + + /** Ends the streaming message with [text], the whole answer; null when it was never started or is refused. */ + suspend fun finish(text: String): Delivery? { + if (failed) return null + val id = streamId ?: return null + val delta = delta(text.ifBlank { shown }) + val answer = sender.stream(chat, delta, msgId, msgSeq, id, index, last = true, append = true) + return if (answer is QqBotAnswer.Ok) sender.delivery(chat, answer) else null + } + + /** Appends [text] as the next piece, without comparing it with what was shown. */ + suspend fun append(text: String): Boolean { + if (failed || text.isEmpty()) return false + val answer = sender.stream(chat, text, msgId, msgSeq, streamId, index, last = false, append = true) + if (answer !is QqBotAnswer.Ok) { + failed = true + return false + } + val id = sender.sentId(answer) ?: run { + failed = true + return false + } + streamId = id + shown += text + index++ + return true + } + + /** Ends the streaming message after its pieces were appended. */ + suspend fun finishAppended(): Delivery? { + if (failed) return null + val id = streamId ?: return null + val answer = sender.stream(chat, "", msgId, msgSeq, id, index, last = true, append = true) + return if (answer is QqBotAnswer.Ok) sender.delivery(chat, answer) else null + } + + /** + * The part of [text] the platform does not hold yet. + * + * A stream is appended in pieces: a preview that grows contributes its suffix, and text that does not continue + * what was shown (an answer replacing a tool activity, a rewritten segment) starts a new paragraph. Append mode + * has no prefix restriction, unlike replace, which refused such a body with `40113003`. + */ + private fun delta(text: String): String = when { + shown.isEmpty() -> text + text.startsWith(shown) -> text.removePrefix(shown) + else -> "\n\n" + text + } + + /** Ends a stream that is not carried on, so the chat does not show it as still being written. */ + suspend fun abandon() { + val id = streamId ?: return + if (failed) return + runCatching { sender.stream(chat, shown, msgId, msgSeq, id, index, last = true) } + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotTyping.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotTyping.kt new file mode 100644 index 00000000..a1a8135c --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotTyping.kt @@ -0,0 +1,93 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotChats +import org.foedusprogramme.alexandrite.sdk.channel.ChannelInstance +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.slf4j.LoggerFactory +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Shows that the bot is writing in each single chat with an open reply, one ticker per chat. + * + * The platform serves the input state for single chats only; a group has none, and no ticker runs there. A tick says + * the state lasts a minute, and the next one renews it while the reply is open. + */ +@ChannelInstanceScoped +internal class QqBotTyping(private val instance: ChannelInstance, private val sender: QqBotSender) : Lifecycle { + private val tickers = HashMap() + private val ticking = SupervisorJob(instance.scope.coroutineContext[Job]) + private val scope = CoroutineScope(instance.scope.coroutineContext + ticking) + + /** Starts typing in [chat] for one more reply. */ + fun start(chat: ChatAddress) { + if (QqBotChats.kind(chat) != ChatKind.DIRECT) return + synchronized(tickers) { + val ticker = tickers.getOrPut(chat, ::Ticker) + ticker.replies++ + if (ticker.job?.isActive != true) { + ticker.job = scope.launch(CoroutineName("$chat typing")) { tick(chat) } + } + } + } + + /** Stops typing in [chat] for one reply, and for good after the last. */ + fun stop(chat: ChatAddress) { + synchronized(tickers) { + val ticker = tickers[chat] ?: return + if (--ticker.replies > 0) return + tickers.remove(chat) + ticker.job?.cancel() + } + } + + override fun onStop() { + ticking.cancel() + synchronized(tickers) { tickers.clear() } + } + + private suspend fun tick(chat: ChatAddress) { + withTimeoutOrNull(LIMIT) { + while (true) { + try { + val delivery = sender.sendTyping(chat) + if (delivery is Delivery.NotDelivered) { + logger.debug("{}: no typing state in {}: {}", instance.id, chat, delivery) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.debug("{}: could not show typing in {}: {}", instance.id, chat, e.toString()) + } + delay(INTERVAL) + } + } + } + + private class Ticker { + var replies = 0 + var job: Job? = null + } + + private companion object { + /** The platform's input state lasts a minute; a tick renews it before it runs out. */ + val INTERVAL = 50.seconds + + /** How long a ticker types at most. */ + val LIMIT = 30.minutes + + val logger = LoggerFactory.getLogger(QqBotTyping::class.java) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotUploads.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotUploads.kt new file mode 100644 index 00000000..112dc379 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotUploads.kt @@ -0,0 +1,312 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotChats +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.asTextOrNull +import org.foedusprogramme.alexandrite.sdk.channel.OutboundFile +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.security.MessageDigest +import java.time.Duration +import java.util.Base64 + +/** The largest file the platform takes in a message, whatever its type. */ +internal const val QQ_BOT_MAX_FILE_BYTES: Long = 200L * 1024 * 1024 + +/** The answer to uploading one file: the platform's `file_info`, or why it gave none. */ +internal sealed class QqBotUpload { + class Uploaded(val fileInfo: String) : QqBotUpload() + + class Failed(val reason: String, val code: Int?) : QqBotUpload() +} + +/** + * Uploads a file of a message to the platform, which sends it in a later `msg_type=7` message. + * + * The platform takes a file in one of three ways. A small one goes as base64 in the upload call, whose data must stay + * under 5 MiB. A larger one is cut into the blocks the pre-upload call names, each block is PUT to its presigned URL, + * the block is confirmed, and the upload call merges them. Single chats and groups have their own endpoints, and a + * file uploaded for one cannot be sent to the other. + */ +@ChannelInstanceScoped +internal class QqBotUploads(private val api: QqBotApi, private val config: QqBotInstanceConfig) { + private val client: HttpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(config.connectTimeoutMillis)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + + /** Uploads [file] for a message to [chat], or says why the platform did not take it. */ + suspend fun upload(chat: ChatAddress, file: OutboundFile): QqBotUpload { + val kind = QqBotChats.kind(chat) + ?: return QqBotUpload.Failed("This instance serves no $chat.", null) + val openid = QqBotChats.openid(chat) + ?: return QqBotUpload.Failed("This instance serves no $chat.", null) + if (file.size > QQ_BOT_MAX_FILE_BYTES) { + return QqBotUpload.Failed( + "'${file.name}' holds ${file.size} bytes, more than the $QQ_BOT_MAX_FILE_BYTES bytes a message of " + + "this platform may take.", + null, + ) + } + val endpoints = endpoints(kind, openid) + val type = fileType(file) + val first = uploadAs(endpoints, file, type) + // A format the platform will not show can still be sent as an ordinary file, which it takes as it is. + if (first is QqBotUpload.Failed && first.code == UNSUPPORTED_FORMAT && type != FILE) { + return uploadAs(endpoints, file, FILE) + } + return first + } + + /** Uploads [file] as [type], in the call the file's size allows. */ + private suspend fun uploadAs(endpoints: Endpoints, file: OutboundFile, type: Int): QqBotUpload = + if (file.size <= INLINE_LIMIT) { + inline(endpoints.files, type, file) + } else { + chunked(endpoints, type, file) + } + + /** Uploads [file] as base64 in the upload call, which the platform takes for at most [INLINE_LIMIT] bytes. */ + private suspend fun inline(path: String, type: Int, file: OutboundFile): QqBotUpload { + val body = buildJsonObject { + put("file_type", type) + put("file_name", file.name) + put("file_data", Base64.getEncoder().encodeToString(file.bytes())) + put("srv_send_msg", false) + } + return uploaded(api.post(path, body)) + } + + /** Uploads [file] in the blocks the pre-upload call names, then has the platform merge them. */ + private suspend fun chunked(endpoints: Endpoints, type: Int, file: OutboundFile): QqBotUpload { + val bytes = file.bytes() + val prepare = api.post( + endpoints.prepare, + buildJsonObject { + put("file_type", type) + put("file_size", bytes.size.toString()) + put("file_name", file.name) + put("md5", digest("MD5", bytes)) + put("sha1", digest("SHA-1", bytes)) + put("md5_10m", digest("MD5", bytes.copyOf(minOf(bytes.size, MD5_10M_BYTES)))) + }, + ) + val plan = prepare.objectOrNull() ?: return failed(prepare, "the pre-upload answered no plan") + val uploadId = plan.text("upload_id") ?: return QqBotUpload.Failed("The pre-upload named no upload id.", null) + val parts = plan["parts"] as? JsonArray + ?: return QqBotUpload.Failed("The pre-upload named no parts.", null) + if (parts.isEmpty()) return QqBotUpload.Failed("The pre-upload named no parts.", null) + val fallbackBlock = plan.long("block_size") + val ordered = parts.mapNotNull { it as? JsonObject } + .sortedBy { it.int("index") ?: Int.MAX_VALUE } + var offset = 0L + val blocks = mutableListOf() + for (part in ordered) { + val index = part.int("index") ?: return QqBotUpload.Failed("A part named no index.", null) + val url = part.text("presigned_url") ?: return QqBotUpload.Failed("A part named no URL.", null) + val size = part.long("block_size") ?: fallbackBlock + ?: return QqBotUpload.Failed("A part named no block size.", null) + if (offset + size > bytes.size) { + return QqBotUpload.Failed("A part of '${file.name}' reaches past the file.", null) + } + blocks += Part(index, url, offset, offset + size) + offset += size + } + if (offset != bytes.size.toLong()) { + return QqBotUpload.Failed("The blocks of '${file.name}' did not cover the file.", null) + } + val concurrency = (plan["upload_config"] as? JsonObject)?.int("concurrency") + ?.coerceIn(1, blocks.size) + ?: 1 + val outcomes = if (concurrency == 1) { + blocks.map { sendPart(endpoints, uploadId, it, bytes) } + } else { + coroutineScope { + val gate = Semaphore(concurrency) + blocks.map { part -> + async { gate.withPermit { sendPart(endpoints, uploadId, part, bytes) } } + }.awaitAll() + } + } + outcomes.firstOrNull { it is QqBotUpload.Failed }?.let { return it } + return uploaded( + api.post( + endpoints.files, + buildJsonObject { + put("file_type", type) + put("upload_id", uploadId) + put("file_name", file.name) + put("srv_send_msg", false) + }, + ), + ) + } + + /** Sends [part], the bytes of [bytes] it covers, and tells the platform the part is complete. */ + private suspend fun sendPart(endpoints: Endpoints, uploadId: String, part: Part, bytes: ByteArray): QqBotUpload { + val block = bytes.copyOfRange(part.start.toInt(), part.end.toInt()) + when (val put = put(part.url, block)) { + is QqBotUpload.Failed -> return put + is QqBotUpload.Uploaded -> Unit + } + val finish = api.post( + endpoints.partFinish, + buildJsonObject { + put("upload_id", uploadId) + put("part_index", part.index) + put("block_size", (part.end - part.start).toString()) + put("md5", digest("MD5", block)) + }, + ) + if (finish !is QqBotAnswer.Ok) return failed(finish, "the platform did not take part ${part.index}") + return QqBotUpload.Uploaded("") + } + + /** PUTs [block] to the presigned [url], which is the platform's own storage rather than its API. */ + private suspend fun put(url: String, block: ByteArray): QqBotUpload = withContext(Dispatchers.IO) { + try { + val request = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofMillis(config.firstByteTimeoutMillis)) + .PUT(HttpRequest.BodyPublishers.ofByteArray(block)) + .build() + val response = client.send(request, HttpResponse.BodyHandlers.discarding()) + if (response.statusCode() in 200..299) { + QqBotUpload.Uploaded("") + } else { + QqBotUpload.Failed("The platform's storage answered HTTP ${response.statusCode()} for a block.", null) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + QqBotUpload.Failed("The block could not be sent to the platform's storage: $e", null) + } + } + + /** What the platform answered to an upload call, as the file info it gave or the failure it named. */ + private fun uploaded(answer: QqBotAnswer): QqBotUpload = when (answer) { + is QqBotAnswer.Ok -> { + val fileInfo = (answer.body as? JsonObject)?.text("file_info") + if (fileInfo.isNullOrBlank()) { + QqBotUpload.Failed("The platform took the file but named no file_info.", null) + } else { + QqBotUpload.Uploaded(fileInfo) + } + } + + is QqBotAnswer.Failed -> failed(answer, "the platform did not take the file") + } + + /** [answer] as a failure, with the platform's code when it named one. */ + private fun failed(answer: QqBotAnswer, what: String): QqBotUpload.Failed { + val failure = (answer as? QqBotAnswer.Failed)?.failure + val said = failure?.message?.takeIf { it.isNotBlank() } ?: "the platform gave no reason" + return QqBotUpload.Failed("$what: $said" + (failure?.code?.let { " (code $it)" } ?: ""), failure?.code) + } + + /** The endpoints of [kind] for [openid], which the platform keeps separate between chats and groups. */ + private fun endpoints(kind: ChatKind, openid: String): Endpoints = when (kind) { + ChatKind.DIRECT -> Endpoints( + files = "/v2/users/$openid/files", + prepare = "/v2/users/$openid/upload_prepare", + partFinish = "/v2/users/$openid/upload_part_finish", + ) + + ChatKind.GROUP -> Endpoints( + files = "/v2/groups/$openid/files", + prepare = "/v2/groups/$openid/upload_prepare", + partFinish = "/v2/groups/$openid/upload_part_finish", + ) + + else -> error("A chat this channel serves is a direct or a group chat.") + } + + /** + * The platform's `file_type` for [file]: an image, video or voice it shows as such, otherwise a file. + * + * A kind of file the platform shows may be sent as that kind only within its soft limit; past it the platform + * turns the upload into a file, which is the type this names from the start. + */ + private fun fileType(file: OutboundFile): Int { + if (file.asFile) return FILE + val name = file.name.lowercase() + val mediaType = file.mediaType.lowercase() + val type = when { + name.endsWithAny(IMAGE_EXTENSIONS) || mediaType in IMAGE_TYPES -> IMAGE + name.endsWithAny(VIDEO_EXTENSIONS) || mediaType in VIDEO_TYPES -> VIDEO + name.endsWithAny(AUDIO_EXTENSIONS) || mediaType in AUDIO_TYPES -> VOICE + else -> null + } ?: return FILE + val soft = when (type) { + IMAGE -> IMAGE_SOFT_BYTES + VIDEO -> VIDEO_SOFT_BYTES + VOICE -> VOICE_SOFT_BYTES + else -> QQ_BOT_MAX_FILE_BYTES + } + return if (file.size > soft) FILE else type + } + + private fun String.endsWithAny(extensions: Set): Boolean = extensions.any { endsWith(it) } + + private fun digest(name: String, bytes: ByteArray): String = + MessageDigest.getInstance(name).digest(bytes).joinToString("") { "%02x".format(it) } + + private fun QqBotAnswer.objectOrNull(): JsonObject? = (this as? QqBotAnswer.Ok)?.body as? JsonObject + + private fun JsonObject.text(name: String): String? = this[name]?.asTextOrNull()?.takeIf { it.isNotEmpty() } + + private fun JsonObject.long(name: String): Long? = this[name]?.asTextOrNull()?.toLongOrNull() + + private fun JsonObject.int(name: String): Int? = this[name]?.asTextOrNull()?.toIntOrNull() + + private class Endpoints(val files: String, val prepare: String, val partFinish: String) + + /** One block of a file: where it starts in the file, where it ends, and where it is PUT. */ + private class Part(val index: Int, val url: String, val start: Long, val end: Long) + + private companion object { + const val IMAGE = 1 + const val VIDEO = 2 + const val VOICE = 3 + const val FILE = 4 + + /** The platform's code for a format it will not show, which an ordinary file may still pass. */ + const val UNSUPPORTED_FORMAT = 850019 + + /** The largest file the upload call takes as base64 data. */ + const val INLINE_LIMIT: Long = 5L * 1024 * 1024 + + /** The bytes of a file whose MD5 is the `md5_10m` the pre-upload asks for. */ + const val MD5_10M_BYTES = 10_002_432 + + const val IMAGE_SOFT_BYTES = 20L * 1024 * 1024 + const val VIDEO_SOFT_BYTES = 30L * 1024 * 1024 + const val VOICE_SOFT_BYTES = 20L * 1024 * 1024 + + val IMAGE_EXTENSIONS = setOf(".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") + val VIDEO_EXTENSIONS = setOf(".mp4") + val AUDIO_EXTENSIONS = setOf(".silk", ".mp3", ".wav", ".ogg") + + val IMAGE_TYPES = setOf("image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp") + val VIDEO_TYPES = setOf("video/mp4") + val AUDIO_TYPES = setOf("audio/silk", "audio/mpeg", "audio/wav", "audio/ogg") + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotWords.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotWords.kt new file mode 100644 index 00000000..b50badd8 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotWords.kt @@ -0,0 +1,58 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.TurnId +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.hook.Hook +import org.foedusprogramme.alexandrite.sdk.hook.HookDecision +import org.foedusprogramme.alexandrite.sdk.hook.InterceptorHook +import org.foedusprogramme.alexandrite.sdk.hook.InterceptorPoint +import org.foedusprogramme.alexandrite.sdk.transcript.AssistantEntry +import org.foedusprogramme.alexandrite.sdk.transcript.ToolCallPart +import org.foedusprogramme.alexandrite.sdk.turn.ModelReply +import org.foedusprogramme.alexandrite.sdk.turn.TurnPoints +import java.util.concurrent.ConcurrentHashMap + +/** The reply of a turn, as the words of a model's request reach it. */ +internal interface QqBotWords { + /** Shows [message], the response of one request of this turn. */ + suspend fun showResponse(message: AssistantEntry) +} + +/** The replies of the turns of this instance that are open, by turn. */ +@Singleton +internal class QqBotReplyRegistry { + private val open = ConcurrentHashMap() + + /** Remembers [words] as the reply of [turn]. */ + fun open(turn: TurnId, words: QqBotWords) { + open[turn] = words + } + + /** Forgets the reply of [turn]. */ + fun close(turn: TurnId) { + open.remove(turn) + } + + /** The reply of [turn], null when this instance speaks for no such turn. */ + fun of(turn: TurnId): QqBotWords? = open[turn] +} + +/** + * Tells the reply of a turn the words of each of its requests, as OneBot does for a chat whose messages cannot be + * rewritten. A streaming single chat already shows those words in its preview, so this only sends the responses of a + * non-streaming chat. + */ +@Singleton +@Contribute(Hook::class) +internal class QqBotWordsHook(private val replies: QqBotReplyRegistry) : InterceptorHook { + override val point: InterceptorPoint get() = TurnPoints.LLM_RESPONSE + + override suspend fun intercept(payload: ModelReply): HookDecision { + val words = replies.of(payload.turn.id) ?: return HookDecision.Continue + // A response that calls no tool is the answer of the turn, which the message that ends it carries. + if (payload.response.message.parts.none { it is ToolCallPart }) return HookDecision.Continue + words.showResponse(payload.response.message) + return HookDecision.Continue + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/config/QqBotConfig.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/config/QqBotConfig.kt new file mode 100644 index 00000000..c17bd2a5 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/config/QqBotConfig.kt @@ -0,0 +1,101 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.config + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.LanguageTag +import org.foedusprogramme.alexandrite.sdk.config.ConfigSection +import org.foedusprogramme.alexandrite.sdk.config.Secret +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped + +/** + * One bot of the QQ open platform, below `instances` of the plugin's config root. + * + * The platform gives every bot an AppID and an AppSecret, which the channel exchanges for an access token it refreshes + * as the platform expires it. What the bot receives is chosen by [intents], a bit set of event groups the platform + * documented: the group and single chat messages of the QQ chats are `GROUP_AND_C2C_EVENT` (1 << 25). + */ +@ConfigSection +@ChannelInstanceScoped +@Serializable +public class QqBotInstanceConfig( + /** The AppID of the bot on the open platform. */ + @SerialName("appId") public val appId: String, + /** The AppSecret of the bot, which buys its access tokens. */ + @SerialName("appSecret") public val appSecret: Secret, + /** The base of the open platform's API, which a test points at a server of its own. */ + @SerialName("apiBase") public val apiBase: String = DEFAULT_API_BASE, + /** The WebSocket address of the gateway, null to ask the platform for one. */ + @SerialName("gatewayUrl") public val gatewayUrl: String? = null, + /** The event groups this connection subscribes to, as the platform numbers them. */ + public val intents: Long = DEFAULT_INTENTS, + /** Whether a chat is told what a turn is doing while it works. */ + public val progress: Boolean = true, + /** Whether a single chat is answered with the platform's streaming message, false to send one message instead. */ + public val streaming: Boolean = true, + /** The accounts that may operate this instance, by their open id in the chat they write in. */ + public val admins: Set = emptySet(), + /** Whether the bot publishes its slash commands as the single-chat menu and command panels when it opens. */ + @SerialName("syncCommands") public val syncCommands: Boolean = true, + /** + * The language the published menu and panels use, null for the host's language. + * + * The platform shows one menu and one panel to everyone, so this is one language for the whole instance rather + * than the language of a chat. + */ + @SerialName("syncLanguage") public val syncLanguage: String? = null, + /** How long an approval request waits for an administrator, in minutes. */ + @SerialName("approvalMinutes") public val approvalMinutes: Long = DEFAULT_APPROVAL_MINUTES, + /** How long reaching the platform may take. */ + @SerialName("connectTimeoutMillis") public val connectTimeoutMillis: Long = DEFAULT_CONNECT_TIMEOUT, + /** How long the platform may take to answer a call before the call fails. */ + @SerialName("firstByteTimeoutMillis") public val firstByteTimeoutMillis: Long = DEFAULT_FIRST_BYTE_TIMEOUT, + /** How long a call may stay silent before it is cut off. */ + @SerialName("idleTimeoutMillis") public val idleTimeoutMillis: Long = DEFAULT_IDLE_TIMEOUT, + /** How long to wait before connecting to the gateway again, in milliseconds. */ + @SerialName("reconnectIntervalMillis") public val reconnectIntervalMillis: Long = DEFAULT_RECONNECT, +) { + init { + require(appId.isNotBlank()) { "appId may not be blank." } + require(appId.all { it.isLetterOrDigit() }) { "appId is the number the open platform gave, was '$appId'." } + require(apiBase.startsWith("http://") || apiBase.startsWith("https://")) { + "apiBase is an HTTP or HTTPS URL, was '$apiBase'." + } + require(gatewayUrl == null || gatewayUrl.startsWith("ws://") || gatewayUrl.startsWith("wss://")) { + "gatewayUrl is a WS or WSS URL, was '$gatewayUrl'." + } + require(intents > 0) { "intents names at least one event group, was $intents." } + require(connectTimeoutMillis > 0) { "A connect timeout is positive, was $connectTimeoutMillis." } + require(firstByteTimeoutMillis > 0) { "A first byte timeout is positive, was $firstByteTimeoutMillis." } + require(idleTimeoutMillis > 0) { "An idle timeout is positive, was $idleTimeoutMillis." } + require(reconnectIntervalMillis > 0) { "A reconnect interval is positive, was $reconnectIntervalMillis." } + require(approvalMinutes > 0) { "An approval wait is at least a minute, was $approvalMinutes." } + syncLanguage?.let { language -> + require(runCatching { LanguageTag.of(language) }.isSuccess) { + "syncLanguage is a BCP 47 language tag, was '$language'." + } + } + } + + public companion object { + /** Where the open platform serves its API. */ + public const val DEFAULT_API_BASE: String = "https://api.bot.qq.com" + + /** The event groups of the single chat and group messages of the QQ chats. */ + public const val DEFAULT_INTENTS: Long = 1L shl 25 + + public const val DEFAULT_CONNECT_TIMEOUT: Long = 10_000L + + public const val DEFAULT_FIRST_BYTE_TIMEOUT: Long = 30_000L + + public const val DEFAULT_IDLE_TIMEOUT: Long = 60_000L + + public const val DEFAULT_RECONNECT: Long = 3_000L + + public const val DEFAULT_APPROVAL_MINUTES: Long = 10L + } +} + +/** The config path of [key] in the config of [instance], of the instance itself when [key] is null. */ +internal fun configPath(instance: ChannelInstanceId, key: String? = null): String = + listOfNotNull("channels.qqbot.instances.${instance.name}", key).joinToString(".") diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGateway.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGateway.kt new file mode 100644 index 00000000..364253b2 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGateway.kt @@ -0,0 +1,356 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.gateway + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.foedusprogramme.alexandrite.channel.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFrame +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.asIntOrNull +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.asTextOrNull +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.foedusprogramme.alexandrite.sdk.di.Lifecycle +import org.java_websocket.client.WebSocketClient +import org.java_websocket.handshake.ServerHandshake +import org.slf4j.LoggerFactory +import java.net.URI +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The gateway of one bot: the WebSocket the open platform pushes its events on. + * + * The platform names the gateway for the bot, sends a hello that says how often to beat, and takes an identify that + * names the bot's token and the event groups it subscribes to. A connection that drops is made again: one that still + * holds a session resumes it and is sent what it missed, one that does not identifies again. + * + * The frames are handed on as they arrive; what they mean is the business of the channel above this class. + */ +@ChannelInstanceScoped +internal class QqBotGateway( + private val config: QqBotInstanceConfig, + private val api: QqBotApi, + private val tokens: QqBotTokens, +) : Lifecycle { + private val incoming = Channel(EVENT_CAPACITY, BufferOverflow.DROP_OLDEST) + + /** The frames the platform pushed, which the channel reads. */ + val frames: Flow = incoming.receiveAsFlow() + + private var scope: CoroutineScope? = null + private var loop: Job? = null + + @Volatile + private var client: GatewayClient? = null + + /** The session a reconnect resumes, null when the bot has none the platform still knows. */ + @Volatile + private var sessionId: String? = null + + /** The number of the last frame read, which a resume names and a heartbeat reports. */ + @Volatile + private var lastSeq: Long? = null + + /** The id the platform gave the bot in the ready frame. */ + @Volatile + var botId: String? = null + private set + + override suspend fun onStart() { + check(scope == null) { "The gateway is already open." } + val opened = CompletableDeferred() + val run = CoroutineScope(SupervisorJob() + Dispatchers.IO) + scope = run + loop = run.launch { connectLoop(opened) } + val failure = try { + withTimeout(config.connectTimeoutMillis * START_ATTEMPTS) { opened.await() } + } catch (e: TimeoutCancellationException) { + IllegalStateException("The gateway did not become ready in time.", e) + } + if (failure != null) throw failure + } + + override fun onStop() { + // Closing the socket ends the loop by way of its close handler; cancelling the scope ends it after that. + runCatching { client?.close() } + scope?.cancel() + scope = null + loop = null + client = null + } + + private suspend fun connectLoop(opened: CompletableDeferred) { + while (scope != null) { + val url = gatewayUrl() + if (url == null) { + if (!opened.isCompleted) opened.complete(IllegalStateException("The platform named no gateway.")) + return + } + val token = try { + tokens.token() + } catch (e: Exception) { + if (!opened.isCompleted) opened.complete(e) + return + } + val ended = CompletableDeferred() + val ready = CompletableDeferred() + val socket = GatewayClient(URI(url), ended, ready).also { it.token = token } + client = socket + val connected = try { + socket.connectBlocking(config.connectTimeoutMillis, TimeUnit.MILLISECONDS) + } catch (e: Exception) { + if (!opened.isCompleted) { + opened.complete(e) + return + } + null + } + if (connected != true) { + if (!opened.isCompleted) { + opened.complete(IllegalStateException("The gateway at $url did not answer.")) + return + } + } else { + val failure = try { + withTimeout(config.connectTimeoutMillis * START_ATTEMPTS) { ready.await() } + } catch (e: TimeoutCancellationException) { + IllegalStateException("The gateway at $url did not become ready in time.", e) + } + if (failure != null && !opened.isCompleted) { + opened.complete(failure) + socket.close() + return + } + if (!opened.isCompleted) opened.complete(null) + ended.await() + } + client = null + if (scope == null) return + delay(config.reconnectIntervalMillis) + } + } + + /** The address of the gateway, from the config or from the platform. */ + private suspend fun gatewayUrl(): String? { + config.gatewayUrl?.let { return it } + return when (val answer = api.gatewayUrl()) { + is QqBotAnswer.Ok -> (answer.body as? JsonObject)?.get("url")?.asTextOrNull() + + is QqBotAnswer.Failed -> { + logger.warn( + "{}: the platform named no gateway: {} {}", + PLUGIN, + answer.failure.code, + answer.failure.message, + ) + null + } + } + } + + /** The socket of one connection, which reads its frames on the thread of the library. */ + private inner class GatewayClient( + uri: URI, + private val ended: CompletableDeferred, + val ready: CompletableDeferred, + ) : WebSocketClient(uri) { + private val stopping = AtomicBoolean() + private var heartbeat: Job? = null + + override fun onOpen(handshake: ServerHandshake) { + logger.debug("{}: the gateway is open", PLUGIN) + } + + override fun onMessage(message: String) { + val frame = QqBotWire.frame(message) ?: run { + logger.warn("{}: the gateway sent a frame this version does not know: {}", PLUGIN, message.take(200)) + return + } + frame.s?.let { lastSeq = it } + when (frame.op) { + OP_HELLO -> if (!startHeartbeat(frame)) return + + OP_DISPATCH -> dispatch(frame) + + OP_HEARTBEAT_ACK -> Unit + + OP_RECONNECT -> close() + + OP_INVALID_SESSION -> { + sessionId = null + lastSeq = null + close() + } + + else -> logger.debug( + "{}: the gateway sent opcode {} which this version has no use for", + PLUGIN, + frame.op, + ) + } + } + + /** Starts beating as the hello asks, then identifies or resumes as the bot's session says. */ + private fun startHeartbeat(hello: QqBotFrame): Boolean { + val interval = (hello.d as? JsonObject)?.get("heartbeat_interval")?.asIntOrNull()?.toLong() + if (interval == null || interval <= 0) { + ready.complete(IllegalStateException("The platform's hello named no heartbeat interval.")) + close() + return false + } + val run = scope ?: return false + heartbeat = run.launch { + while (true) { + delay(interval) + val seq = lastSeq + send( + buildJsonObject { + put("op", OP_HEARTBEAT) + put("d", seq) + }.toString(), + ) + } + } + val kept = sessionId + if (kept != null) { + send(resume(kept)) + } else { + send(identify()) + } + return true + } + + /** Reads a dispatch frame: the ready and resumed frames settle the startup, the others are handed on. */ + private fun dispatch(frame: QqBotFrame) { + when (frame.t) { + "READY" -> { + val data = frame.d as? JsonObject + sessionId = (data?.get("session_id"))?.asTextOrNull() + botId = ((data?.get("user") as? JsonObject)?.get("id"))?.asTextOrNull() + logger.info("{}: the gateway is ready, session {}", PLUGIN, sessionId) + ready.complete(null) + } + + "RESUMED" -> { + logger.info("{}: the gateway resumed the session", PLUGIN) + ready.complete(null) + } + + null -> Unit + + else -> { + if (frame.t in MESSAGE_EVENTS) { + logger.info("{}: the platform dispatched {} at seq {}", PLUGIN, frame.t, frame.s) + } else { + logger.debug("{}: the platform dispatched {} at seq {}", PLUGIN, frame.t, frame.s) + } + if (!incoming.trySend(frame).isSuccess) { + logger.warn("{}: an event of the platform was dropped: the channel is behind", PLUGIN) + } + } + } + } + + /** The identify frame, which names the token, the event groups and the one shard this connection reads. */ + private fun identify(): String = buildJsonObject { + put("op", OP_IDENTIFY) + putJsonObject("d") { + put("token", "QQBot ${token ?: ""}") + put("intents", config.intents) + putJsonArray("shard") { + add(JsonPrimitive(0)) + add(JsonPrimitive(1)) + } + putJsonObject("properties") { + put("\$os", "linux") + put("\$browser", "alexandrite") + put("\$device", "alexandrite") + } + } + }.toString() + + /** The resume frame, which names the session and the last frame read. */ + private fun resume(kept: String): String = buildJsonObject { + put("op", OP_RESUME) + putJsonObject("d") { + put("token", "QQBot ${token ?: ""}") + put("session_id", kept) + put("seq", lastSeq) + } + }.toString() + + /** The token the identify and resume frames carry, read before the socket opened. */ + var token: String? = null + + override fun onClose(code: Int, reason: String, remote: Boolean) { + heartbeat?.cancel() + heartbeat = null + if (!stopping.get()) logger.warn("{}: the gateway closed: {} {}", PLUGIN, code, reason) + if (!ready.isCompleted) { + ready.complete(IllegalStateException("The gateway closed while starting: $code $reason")) + } + if (code in FATAL_CODES) { + logger.error("{}: the gateway refuses this bot: {} {}", PLUGIN, code, reason) + // Nothing this connection does makes the platform take the bot, so no reconnect is tried again. + scope?.cancel() + } + ended.complete(Unit) + } + + override fun onError(ex: Exception) { + logger.warn("{}: the gateway failed: {}", PLUGIN, ex.toString()) + if (!ready.isCompleted) ready.complete(ex) + } + + override fun close() { + stopping.set(true) + super.close() + } + } + + private companion object { + const val PLUGIN = "alexandrite-channel-qqbot" + + const val EVENT_CAPACITY = 1024 + + /** How many times a connect timeout the startup may take: the token, the gateway and the ready frame. */ + const val START_ATTEMPTS = 3L + + /** The dispatches that carry a message, which are worth a line at the usual level. */ + val MESSAGE_EVENTS = setOf("C2C_MESSAGE_CREATE", "GROUP_AT_MESSAGE_CREATE", "GROUP_MESSAGE_CREATE") + + const val OP_DISPATCH = 0 + const val OP_HEARTBEAT = 1 + const val OP_IDENTIFY = 2 + const val OP_RESUME = 6 + const val OP_RECONNECT = 7 + const val OP_INVALID_SESSION = 9 + const val OP_HELLO = 10 + const val OP_HEARTBEAT_ACK = 11 + + /** The codes the platform closes the connection with when the bot itself may not connect. */ + val FATAL_CODES = setOf(4001, 4002, 4010, 4011, 4012, 4013, 4014, 4914, 4915) + + val logger = LoggerFactory.getLogger(QqBotGateway::class.java) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/i18n/TextKeys.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/i18n/TextKeys.kt new file mode 100644 index 00000000..f3ccffb8 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/i18n/TextKeys.kt @@ -0,0 +1,20 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.i18n + +/** The texts of this channel for people, as keys of its own bundle. */ +internal object TextKeys { + const val APPROVAL_TITLE: String = "approval.title" + const val APPROVAL_TOOL: String = "approval.tool" + const val APPROVAL_REASON: String = "approval.reason" + const val APPROVAL_ARGUMENTS: String = "approval.arguments" + const val APPROVAL_ALLOW: String = "approval.allow" + const val APPROVAL_DENY: String = "approval.deny" + const val APPROVAL_ALWAYS: String = "approval.always" + const val APPROVAL_ALLOWED: String = "approval.allowed" + const val APPROVAL_DENIED: String = "approval.denied" + const val APPROVAL_EXPIRED: String = "approval.expired" + const val APPROVAL_ADMINS_ONLY: String = "approval.admins_only" + const val APPROVAL_NOTHING_WAITING: String = "approval.nothing_waiting" + const val APPROVAL_UNREACHABLE: String = "approval.unreachable" + const val REFUSED_ADMIN: String = "refused.admin" + const val REFUSED_ADMIN_HERE: String = "refused.admin_here" +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotChats.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotChats.kt new file mode 100644 index 00000000..64ca42d9 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotChats.kt @@ -0,0 +1,56 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.mapping + +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotMessageEvent +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.chat.UserAddress + +/** + * The chats of a QQ bot instance, printed the way the rest of Alexandrite reads them. + * + * A single chat is `private:` and a group is `group:`. The ids are the open ids the + * platform gave the bot itself: the same user or group has another id for another bot. + */ +internal object QqBotChats { + const val PRIVATE: String = "private" + + const val GROUP: String = "group" + + /** The chat [event] was written in. */ + fun addressOf(instance: ChannelInstanceId, event: QqBotMessageEvent): ChatAddress { + val group = event.groupOpenid + return if (group.isNullOrEmpty()) { + ChatAddress(instance, "$PRIVATE:${event.author.openid(group = false)}") + } else { + ChatAddress(instance, "$GROUP:$group") + } + } + + /** The kind of [chat], null when it names no chat this channel serves. */ + fun kind(chat: ChatAddress): ChatKind? = when { + chat.chat.startsWith("$PRIVATE:") -> ChatKind.DIRECT + chat.chat.startsWith("$GROUP:") -> ChatKind.GROUP + else -> null + } + + /** The openid of [chat], null when it names no chat this channel serves. */ + fun openid(chat: ChatAddress): String? = when { + chat.chat.startsWith("$PRIVATE:") -> chat.chat.removePrefix("$PRIVATE:").takeIf { it.isNotEmpty() } + chat.chat.startsWith("$GROUP:") -> chat.chat.removePrefix("$GROUP:").takeIf { it.isNotEmpty() } + else -> null + } + + /** The path below the API base that sends a message to [chat], null when it names no chat this channel serves. */ + fun sendPath(chat: ChatAddress): String? { + val id = openid(chat) ?: return null + return when (kind(chat)) { + ChatKind.DIRECT -> "/v2/users/$id/messages" + ChatKind.GROUP -> "/v2/groups/$id/messages" + else -> null + } + } + + /** The user of [id] in this instance. */ + fun user(instance: ChannelInstanceId, id: String): UserAddress = UserAddress(instance, id) +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMarkdown.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMarkdown.kt new file mode 100644 index 00000000..af98a542 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMarkdown.kt @@ -0,0 +1,128 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.mapping + +/** + * Makes a Markdown text safe for the QQ client without throwing away what it renders. + * + * The client renders headings, emphasis, lists, code, tables and most of what the model writes, so supported Markdown + * is passed through as it is. What the client only shows as raw text is adapted to the nearest form it does render: + * `==highlight==` becomes bold, `^sup^` and `~sub~` become the client's inline HTML, definition lists become plain + * "term: definition" lines, and HTML blocks lose their tags. A link the client cannot open is left as its label, and a + * local image path becomes its alt text. The inside of a code fence or an inline code span is never changed. + */ +internal object QqBotMarkdown { + private val FENCE = Regex("^\\s*(`{3,}|~{3,}).*") + private val TOC_LINE = Regex("^\\s*\\[TOC]\\s*$", RegexOption.IGNORE_CASE) + private val FOOTNOTE_DEFINITION = Regex("^\\s*\\[\\^[^]]+]:.*$") + private val DEFINITION_LINE = Regex("^\\s*:\\s+(.*)$") + private val LOCAL_IMAGE = Regex("!\\[([^]]*)]\\(([^)\\s]+)(?:\\s+\"[^\"]*\")?\\)") + private val HTML_BREAK = Regex("", RegexOption.IGNORE_CASE) + private val HTML_RULE = Regex("", RegexOption.IGNORE_CASE) + private val HTML_TAG = Regex("]*)?/?>") + private val HIGHLIGHT = Regex("==([^=\\n]+)==") + private val SUPERSCRIPT = Regex("\\^([^^\\n]+)\\^") + private val SUBSCRIPT = Regex("(?() + var fence: String? = null + for (line in text.lines()) { + val opening = FENCE.matchEntire(line)?.groupValues?.get(1) + if (fence != null) { + out += line + if (opening != null && line.trimStart().startsWith(fence)) fence = null + continue + } + if (opening != null) { + fence = opening.take(3) + out += line + continue + } + if (TOC_LINE.matches(line) || FOOTNOTE_DEFINITION.matches(line)) continue + val definition = DEFINITION_LINE.matchEntire(line) + if (definition != null) { + val previous = out.indexOfLast { it.isNotBlank() } + if (previous >= 0 && !looksLikeBlock(out[previous])) { + out[previous] = out[previous].trimEnd() + ":" + definition.groupValues[1].trim() + continue + } + } + out += renderLine(line, stripUrls) + } + return out.joinToString("\n") + } + + /** [text] with every URL and link target taken out, for a platform that refuses a message holding one. */ + fun withoutUrls(text: String): String = render(text, stripUrls = true) + + /** Whether [line] is a heading, list item or quote, which a definition line does not extend. */ + private fun looksLikeBlock(line: String): Boolean { + val trimmed = line.trimStart() + return trimmed.startsWith("#") || trimmed.startsWith("-") || trimmed.startsWith("*") || + trimmed.startsWith("+") || trimmed.startsWith(">") || trimmed.startsWith("|") || + trimmed.startsWith("```") || trimmed.startsWith("~~~") || trimmed.matches(Regex("^\\d+[.)]\\s.*")) + } + + /** [line] outside a code fence, with inline code left alone. */ + private fun renderLine(line: String, stripUrls: Boolean): String = buildString { + var index = 0 + while (index < line.length) { + val ticks = ticksAt(line, index) + if (ticks == 0) { + val start = index + while (index < line.length && ticksAt(line, index) == 0) index++ + append(renderSegment(line.substring(start, index), stripUrls)) + } else { + val end = line.indexOf("`".repeat(ticks), index + ticks) + if (end < 0) { + append(renderSegment(line.substring(index), stripUrls)) + return@buildString + } + append(line, index, end + ticks) + index = end + ticks + } + } + } + + /** How many backticks start at [index], zero when the character there is none. */ + private fun ticksAt(line: String, index: Int): Int { + if (index >= line.length || line[index] != '`') return 0 + var end = index + while (end < line.length && line[end] == '`') end++ + return end - index + } + + /** [segment] outside inline code, adapted for the client. */ + private fun renderSegment(segment: String, stripUrls: Boolean): String { + if (segment.isEmpty()) return segment + var text = segment + if (stripUrls) { + text = text.replace(Regex("\\[([^]]+)]\\([^)]*\\)"), "$1") + text = text.replace(Regex("https?://\\S+"), "") + } + text = text.replace(HIGHLIGHT, "**$1**") + text = text.replace(SUPERSCRIPT, "$1") + text = text.replace(SUBSCRIPT, "$1") + text = text.replace(FOOTNOTE_REFERENCE, "") + text = text.replace(HTML_BREAK, "\n") + text = text.replace(HTML_RULE, "\n---") + text = text.replace(HTML_TAG) { match -> + val tag = match.value.lowercase() + if (tag.startsWith(" + val alt = match.groupValues[1] + val url = match.groupValues[2] + if (url.startsWith("http://") || url.startsWith("https://")) match.value else alt + } + return text + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMedia.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMedia.kt new file mode 100644 index 00000000..8cc83ddd --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMedia.kt @@ -0,0 +1,33 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.mapping + +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.internal.http.HttpCall +import org.foedusprogramme.alexandrite.sdk.channel.MediaContent +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import java.io.ByteArrayOutputStream +import java.net.URI + +/** Reads the bytes of a media file a message of this platform carries, from the address it names. */ +@ChannelInstanceScoped +internal class QqBotMedia(private val http: QqBotHttp) { + /** What reads the bytes at [url], at most as many as a caller allows. */ + fun content(url: String): MediaContent { + val uri = runCatching { URI(url) }.getOrNull() + if (uri == null || (uri.scheme != "http" && uri.scheme != "https")) { + return MediaContent { throw IllegalArgumentException("'$url' is no address a file is read from.") } + } + return MediaContent { maxBytes -> read(uri, maxBytes) } + } + + private suspend fun read(uri: URI, maxBytes: Long): ByteArray = http.open(HttpCall("GET", uri)) { response -> + val out = ByteArrayOutputStream() + while (true) { + val piece = response.read() ?: break + if (out.size().toLong() + piece.size > maxBytes) { + throw IllegalArgumentException("The file at $uri holds more than the $maxBytes bytes allowed here.") + } + out.write(piece) + } + out.toByteArray() + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMessages.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMessages.kt new file mode 100644 index 00000000..440eac17 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMessages.kt @@ -0,0 +1,139 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.mapping + +import org.foedusprogramme.alexandrite.channel.qqbot.channel.QqBotMessageIndex +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotAttachment +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotMessageEvent +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotMsgElement +import org.foedusprogramme.alexandrite.sdk.channel.DisplayFact +import org.foedusprogramme.alexandrite.sdk.channel.IncomingMessage +import org.foedusprogramme.alexandrite.sdk.channel.MediaAttachment +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatInfo +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import org.foedusprogramme.alexandrite.sdk.chat.Quote +import org.foedusprogramme.alexandrite.sdk.chat.QuoteTrust +import org.foedusprogramme.alexandrite.sdk.transcript.MediaKind +import java.time.Instant +import java.time.OffsetDateTime + +/** The messages of a QQ bot instance, as the gateway reported them. */ +internal object QqBotMessages { + private val LEADING_MENTION = Regex("^\\s*<@!?[^>]+>\\s*") + + /** The message [event] is, where [admins] are the accounts the instance marks as its operators. */ + fun incoming( + instance: ChannelInstanceId, + event: QqBotMessageEvent, + admins: Set, + media: QqBotMedia, + index: QqBotMessageIndex? = null, + ): IncomingMessage { + val group = !event.groupOpenid.isNullOrEmpty() + val chat = QqBotChats.addressOf(instance, event) + index?.record(chat, event.messageScene?.ext("msg_idx"), event.id) + val authorId = event.author.openid(group) ?: event.author.userOpenid ?: event.author.memberOpenid ?: "unknown" + val role = event.author.memberRole + val name = event.author.username?.takeIf { it.isNotBlank() } ?: authorId + val sender = ChatUser( + address = QqBotChats.user(instance, authorId), + displayName = name, + username = null, + isBot = event.author.bot, + isAdmin = authorId in admins || role == "admin" || role == "owner", + ) + val quote = quote(event, chat, index) + index?.recordQuote(chat, event.id, quote?.target?.id) + return IncomingMessage.builder( + ref = ChannelMessageRef(chat, event.id), + sender = sender, + chatInfo = ChatInfo(if (group) ChatKind.GROUP else ChatKind.DIRECT, null, null), + receivedAt = instant(event.timestamp), + forwarded = null, + ) + .text(text(event)) + .media(attachments(event, media)) + .quote(quote) + .facts(facts(event)) + .build() + } + + /** + * The text of [event] without the mention a full-mode event kept at its start. + * + * A `GROUP_AT_MESSAGE_CREATE` comes with the mention already stripped; a `GROUP_MESSAGE_CREATE` of the full + * mode may still carry `<@…>` in front of the words, which is not part of what was said. + */ + fun text(event: QqBotMessageEvent): String = event.content.replace(LEADING_MENTION, "") + + /** The media of [event], each with what reads the bytes its attachment names. */ + private fun attachments(event: QqBotMessageEvent, media: QqBotMedia): List = + event.attachments.map { attachment -> mediaOf(attachment, media) } + + /** The media of [attachment], with the kind its content type names. */ + private fun mediaOf(attachment: QqBotAttachment, media: QqBotMedia): MediaAttachment { + val contentType = attachment.contentType + val kind = when { + contentType == "voice" -> MediaKind.AUDIO + contentType == "file" -> MediaKind.FILE + contentType?.startsWith("image/") == true -> MediaKind.IMAGE + contentType?.startsWith("audio/") == true -> MediaKind.AUDIO + contentType?.startsWith("video/") == true -> MediaKind.VIDEO + else -> MediaKind.FILE + } + val mediaType = when { + contentType == null || contentType == "voice" || contentType == "file" -> "application/octet-stream" + else -> contentType + } + val url = attachment.voiceWavUrl ?: attachment.url + val builder = MediaAttachment.builder(kind, mediaType, media.content(url.orEmpty())) + .size(attachment.size) + .width(attachment.width) + .height(attachment.height) + .name(attachment.filename) + if (url == null) builder.unavailable("the platform named no address for it") + return builder.build() + } + + /** What a message of several quoted ones says, which the platform nests as elements. */ + private fun quote(event: QqBotMessageEvent, chat: ChatAddress, index: QqBotMessageIndex?): Quote? { + if (event.msgElements.isEmpty()) return null + val text = elementsText(event.msgElements) + val reference = event.messageScene?.ext("ref_msg_idx") ?: event.msgElements.firstOrNull()?.msgIdx + val target = index?.resolve(chat, reference)?.let { ChannelMessageRef(chat, it) } + val builder = Quote.builder(text).trust(QuoteTrust.UNTRUSTED) + if (target != null) builder.target(target) + return builder.build() + } + + /** The text of [elements] and what they nest, a blank line between two of them. */ + private fun elementsText(elements: List): String = elements.joinToString("\n\n") { element -> + val own = element.content.orEmpty() + val nested = elementsText(element.msgElements) + listOf(own, nested).filter { it.isNotBlank() }.joinToString("\n") + } + + /** What the agent is shown of [event] besides its text and media. */ + private fun facts(event: QqBotMessageEvent): List = buildList { + event.arkData?.let { card -> + val label = card.arkName ?: card.arkType + val value = listOfNotNull(label, card.prompt).joinToString(": ").ifBlank { "a card" } + add(DisplayFact("card", value)) + } + if (event.mentions.isNotEmpty()) { + val names = event.mentions.mapNotNull { it.username?.takeIf(String::isNotBlank) } + add(DisplayFact("mentions", if (names.isEmpty()) "${event.mentions.size}" else names.joinToString(", "))) + } + for (attachment in event.attachments) { + add(DisplayFact("attachment", attachment.filename ?: attachment.contentType ?: "unnamed")) + } + } + + /** When the platform received the message, now when it named no time this version can read. */ + private fun instant(text: String?): Instant = runCatching { + if (text == null) return@runCatching Instant.now() + OffsetDateTime.parse(text).toInstant() + }.getOrElse { Instant.now() } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/net/QqBotApi.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/net/QqBotApi.kt new file mode 100644 index 00000000..e89c1e70 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/net/QqBotApi.kt @@ -0,0 +1,105 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.net + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFailure +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.failureOf +import org.foedusprogramme.alexandrite.internal.http.HttpCall +import org.foedusprogramme.alexandrite.internal.http.HttpException +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import org.slf4j.LoggerFactory + +/** What a call to the open platform answered: the body, or why it gave none. */ +internal sealed class QqBotAnswer { + class Ok(val body: JsonElement?) : QqBotAnswer() + + class Failed(val failure: QqBotFailure, val status: Int?) : QqBotAnswer() +} + +/** The calls of one bot to the open platform, which carry the token the platform sells it. */ +@ChannelInstanceScoped +internal class QqBotApi(private val http: QqBotHttp, private val tokens: QqBotTokens) { + /** The WebSocket address of the gateway, which the platform names for this bot. */ + suspend fun gatewayUrl(): QqBotAnswer = call("GET", "/gateway", null) + + /** Sends [body] to [path], which is the messages endpoint of one chat. */ + suspend fun post(path: String, body: JsonObject): QqBotAnswer = call("POST", path, body) + + /** Sends [body] to [path], replacing what it holds. */ + suspend fun put(path: String, body: JsonObject): QqBotAnswer = call("PUT", path, body) + + /** Reads [path], with the [query] it names as its own query. */ + suspend fun get(path: String, query: Map = emptyMap()): QqBotAnswer = + call("GET", withQuery(path, query), null) + + /** Deletes what [path] names. */ + suspend fun delete(path: String): QqBotAnswer = call("DELETE", path, null) + + /** [path] with [query] encoded after it, the path alone when there is nothing to name. */ + private fun withQuery(path: String, query: Map): String { + if (query.isEmpty()) return path + val encoded = query.entries.joinToString("&") { (name, value) -> + "${java.net.URLEncoder.encode(name, Charsets.UTF_8)}=" + + java.net.URLEncoder.encode(value, Charsets.UTF_8) + } + return "$path?$encoded" + } + + /** Calls [method] on [path] with [body], buying a new token once when the platform says the old one expired. */ + private suspend fun call(method: String, path: String, body: JsonObject?, retried: Boolean = false): QqBotAnswer { + val authorization = tokens.authorization(refresh = retried) + val call = HttpCall( + method = method, + uri = http.uri(path), + headers = buildMap { + put("Authorization", authorization) + if (body != null) put("Content-Type", "application/json; charset=utf-8") + }, + body = body?.toString(), + secrets = listOf(authorization.removePrefix("QQBot ")), + ) + return try { + val text = http.send(call) + val answer = if (text.isBlank()) { + null + } else { + runCatching { + QqBotWire.json.parseToJsonElement(text) + }.getOrNull() + } + val failure = failureOf(answer) + if (failure.code != null && failure.code != 0) { + if (failure.code in EXPIRED_TOKEN && !retried) { + logger.warn("{}: the token is not taken while calling {}; buying another", PLUGIN, path) + return call(method, path, body, retried = true) + } + QqBotAnswer.Failed(failure, null) + } else { + QqBotAnswer.Ok(answer) + } + } catch (e: HttpException) { + val parsed = e.body?.let { runCatching { QqBotWire.json.parseToJsonElement(it) }.getOrNull() } + if (e.status == UNAUTHORIZED && !retried) { + logger.warn("{}: the token expired while calling {}; buying another", PLUGIN, path) + return call(method, path, body, retried = true) + } + QqBotAnswer.Failed(failureOf(parsed), e.status) + } catch (e: Exception) { + logger.warn("{}: the call to {} failed: {}", PLUGIN, path, e.toString()) + QqBotAnswer.Failed(QqBotFailure(null, e.toString(), null), null) + } + } + + private companion object { + const val PLUGIN = "alexandrite-channel-qqbot" + + const val UNAUTHORIZED = 401 + + /** The codes with which the platform says the token presented is no good, which another one may fix. */ + val EXPIRED_TOKEN = setOf(11241, 11243, 11244) + + val logger = LoggerFactory.getLogger(QqBotApi::class.java) + } +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/net/QqBotHttp.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/net/QqBotHttp.kt new file mode 100644 index 00000000..0c05c71f --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/net/QqBotHttp.kt @@ -0,0 +1,33 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.net + +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.internal.http.HttpCall +import org.foedusprogramme.alexandrite.internal.http.HttpResponseStream +import org.foedusprogramme.alexandrite.internal.http.HttpTimeouts +import org.foedusprogramme.alexandrite.internal.http.HttpTransport +import org.foedusprogramme.alexandrite.sdk.di.ChannelInstanceScoped +import java.net.URI +import kotlin.time.Duration.Companion.milliseconds + +/** The HTTP calls of one bot, over the transport the internal module keeps. */ +@ChannelInstanceScoped +internal class QqBotHttp(private val config: QqBotInstanceConfig) : AutoCloseable { + private val transport = HttpTransport( + HttpTimeouts( + connect = config.connectTimeoutMillis.milliseconds, + firstByte = config.firstByteTimeoutMillis.milliseconds, + idle = config.idleTimeoutMillis.milliseconds, + ), + ) + + /** The body of the answer to [call] as it came, which a caller reads as JSON. */ + suspend fun send(call: HttpCall): String = transport.send(call) + + /** Runs [block] on the response to [call], for a caller that reads the body piece by piece. */ + suspend fun open(call: HttpCall, block: suspend (HttpResponseStream) -> T): T = transport.open(call, block) + + /** The address of [path] below the API base of the instance. */ + fun uri(path: String): URI = URI(config.apiBase.trimEnd('/') + path) + + override fun close() = transport.close() +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/protocol/QqBotPayloads.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/protocol/QqBotPayloads.kt new file mode 100644 index 00000000..854d7599 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/protocol/QqBotPayloads.kt @@ -0,0 +1,211 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.protocol + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** The JSON of the QQ open platform, whose answers carry fields this version does not read. */ +internal object QqBotWire { + /** The JSON this channel reads and writes, which keeps what it does not know and writes what it sets. */ + val json: Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = false + } + + /** The gateway frame [text] is, null when it is no frame this version knows. */ + fun frame(text: String): QqBotFrame? = runCatching { + json.decodeFromString(QqBotFrame.serializer(), text) + }.getOrNull() + + /** The message event [data] carries, null when it is no message event. */ + fun message(data: JsonElement?): QqBotMessageEvent? { + if (data == null || data is kotlinx.serialization.json.JsonNull) return null + return runCatching { + json.decodeFromJsonElement(QqBotMessageEvent.serializer(), data) + }.getOrNull() + } + + /** The text of a frame field, null when the frame holds no such field. */ + fun string(element: JsonElement?, key: String): String? = + (element as? JsonObject)?.get(key)?.let { it as? JsonPrimitive }?.contentOrNull() +} + +/** One frame of the gateway, whatever its opcode carries. */ +@Serializable +internal class QqBotFrame( + /** The event id of a dispatch frame, null on the frames the client sends. */ + val id: String? = null, + val op: Int, + val d: JsonElement? = null, + /** The sequence number of a dispatch frame, which a heartbeat and a resume name. */ + val s: Long? = null, + /** The event name of a dispatch frame. */ + val t: String? = null, +) + +/** A message of the QQ chats, which the gateway dispatches as C2C_MESSAGE_CREATE or GROUP_AT_MESSAGE_CREATE. */ +@Serializable +internal class QqBotMessageEvent( + /** The id of the message, which a passive reply names as `msg_id`. */ + val id: String, + val author: QqBotAuthor = QqBotAuthor(), + /** The text of the message, which the platform already stripped the mention of the bot from. */ + val content: String = "", + /** The group the message came from, null for a single chat. */ + @SerialName("group_openid") val groupOpenid: String? = null, + /** When the platform received the message, in RFC 3339. */ + val timestamp: String? = null, + /** What the message is: 0 text, 3 a card, 103 a quote of other messages. */ + @SerialName("message_type") val messageType: Int = 0, + @SerialName("message_scene") val messageScene: QqBotMessageScene? = null, + val attachments: List = emptyList(), + val mentions: List = emptyList(), + @SerialName("ark_data") val arkData: QqBotArkData? = null, + @SerialName("msg_elements") val msgElements: List = emptyList(), +) + +/** Who wrote a message, in a single chat or a group. */ +@Serializable +internal class QqBotAuthor( + val id: String? = null, + val username: String? = null, + val bot: Boolean = false, + /** The user's id in a single chat. */ + @SerialName("user_openid") val userOpenid: String? = null, + /** The user's id in a group. */ + @SerialName("member_openid") val memberOpenid: String? = null, + /** The user's role in the group: member, admin or owner. */ + @SerialName("member_role") val memberRole: String? = null, +) { + /** The id the chat the message came from names this author by. */ + fun openid(group: Boolean): String? = if (group) memberOpenid else userOpenid +} + +/** What a message carries besides its text: an image, a voice message, a video or a file. */ +@Serializable +internal class QqBotAttachment( + val url: String? = null, + val filename: String? = null, + val width: Int? = null, + val height: Int? = null, + val size: Long? = null, + /** The MIME type of the attachment, or `voice` for a voice message or `file` for a group file. */ + @SerialName("content_type") val contentType: String? = null, + /** The address a voice message's SILK was converted to WAV at, null for the other kinds. */ + @SerialName("voice_wav_url") val voiceWavUrl: String? = null, + @SerialName("asr_refer_text") val asrReferText: String? = null, +) + +/** Where a message was written and what indexes it carries: the message index and the one it quotes. */ +@Serializable +internal class QqBotMessageScene( + val source: String? = null, + /** `key=value` entries, among them `msg_idx`, `ref_msg_idx` and `auth_token`. */ + val ext: List = emptyList(), +) { + /** The value of [key] among the entries, null when they carry none. */ + fun ext(key: String): String? = ext.firstOrNull { it.startsWith("$key=") }?.substringAfter('=') +} + +/** The card a card message carries. */ +@Serializable +internal class QqBotArkData( + /** What the platform says the card is, in the words a chat is shown. */ + val prompt: String? = null, + @SerialName("ark_type") val arkType: String? = null, + @SerialName("ark_name") val arkName: String? = null, +) + +/** One element of a quote message, which nests the quoted messages it gathers. */ +@Serializable +internal class QqBotMsgElement( + @SerialName("msg_idx") val msgIdx: String? = null, + val author: QqBotAuthor? = null, + @SerialName("message_type") val messageType: Int? = null, + val content: String? = null, + val attachments: List = emptyList(), + @SerialName("ark_data") val arkData: QqBotArkData? = null, + @SerialName("msg_elements") val msgElements: List = emptyList(), +) + +/** The token the open platform sells for the AppID and AppSecret of a bot. */ +internal class QqBotAccessToken(val value: String, val expiresInSeconds: Long) + +/** What the open platform said when a call did not succeed. */ +internal class QqBotFailure( + /** The `err_code` or `code` of the answer, null when it named none. */ + val code: Int?, + /** The message the platform wrote, which is for people and may change. */ + val message: String, + /** The platform's id of the request, which it asks for in a support request. */ + val traceId: String?, +) + +/** What a failed answer says, as the fields both kinds of answer use. */ +internal fun failureOf(body: JsonElement?): QqBotFailure { + val objectBody = body as? JsonObject + val code = objectBody?.get("err_code")?.asIntOrNull() ?: objectBody?.get("code")?.asIntOrNull() + val message = objectBody?.get("message")?.asTextOrNull().orEmpty() + val trace = objectBody?.get("trace_id")?.asTextOrNull() + return QqBotFailure(code, message, trace) +} + +/** The int value of the field, null when it is no number. */ +internal fun JsonElement?.asIntOrNull(): Int? = (this as? JsonPrimitive)?.content?.toIntOrNull() + +/** The text of the field, null when it is blank or no text. */ +internal fun JsonElement?.asTextOrNull(): String? = (this as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() } + +/** The number of the token answer, which the platform writes as a string or as a number. */ +internal fun JsonObject.number(key: String): Long? = (this[key] as? JsonPrimitive)?.content?.toLongOrNull() + +/** The boolean of the field, null when it is no boolean. */ +internal fun JsonElement?.asBooleanOrNull(): Boolean? = (this as? JsonPrimitive)?.content?.let { + when (it.lowercase()) { + "true" -> true + "false" -> false + else -> null + } +} + +/** The content of the string, null when it is null or not a primitive. */ +internal fun JsonPrimitive?.contentOrNull(): String? = if (this == null || this is JsonNull) null else content + +/** The platform's answer to a message being sent: the id it gave the message and its quote reference. */ +@Serializable +internal class QqBotSent( + val id: String? = null, + val timestamp: String? = null, + @SerialName("ext_info") val extInfo: QqBotSentInfo? = null, +) + +/** What the send response says about quoting the sent message. */ +@Serializable +internal class QqBotSentInfo( + /** The reference a later quote of this message names, which the send's message id does not. */ + @SerialName("ref_idx") val refIdx: String? = null, +) + +/** What a group or single chat message is: `message_type=0` text, `2` markdown, `7` media. */ +internal object QqBotMessageType { + const val TEXT: Int = 0 + const val MARKDOWN: Int = 2 + const val TYPING: Int = 6 + const val MEDIA: Int = 7 +} + +/** The kinds of attachment whose content type the platform names, mapped to the kind the SDK knows. */ +internal fun attachmentKind(contentType: String?): String = when { + contentType == null -> "file" + contentType == "voice" -> "audio" + contentType == "file" -> "file" + contentType.startsWith("image/") -> "image" + contentType.startsWith("audio/") -> "audio" + contentType.startsWith("video/") -> "video" + else -> "file" +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotActionTool.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotActionTool.kt new file mode 100644 index 00000000..a852907f --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotActionTool.kt @@ -0,0 +1,178 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.tools + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import org.foedusprogramme.alexandrite.channel.qqbot.channel.QqBotMessageIndex +import org.foedusprogramme.alexandrite.channel.qqbot.channel.QqBotSentMessages +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.sdk.di.Contribute +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import org.foedusprogramme.alexandrite.sdk.tool.Tool +import org.foedusprogramme.alexandrite.sdk.tool.ToolContext +import org.foedusprogramme.alexandrite.sdk.tool.ToolDefinition +import org.foedusprogramme.alexandrite.sdk.tool.ToolResult +import org.foedusprogramme.alexandrite.sdk.tool.ToolRisk +import kotlin.time.Duration.Companion.minutes + +/** + * Reads what the open platform knows about the group of the turn or the bot itself. + * + * The tool takes the action and its arguments rather than one tool per action, because the platform's surface is a + * table of endpoints. Only the actions of [QqBotActions.reading] can be named; a read that answers makes the turn + * carry content the bot cannot vouch for. + */ +@Singleton +@Contribute(Tool::class) +internal class QqBotReadTool(private val apis: QqBotApis) : Tool { + override val definition: ToolDefinition = ToolDefinition( + name = "qqbot.read", + description = describe("Reads what the QQ open platform knows", QqBotActions.reading), + parameters = schema(), + risk = ToolRisk.READ_ONLY, + ) + + override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult = + call(apis, QqBotActions.reading, arguments, context, null, null) +} + +/** Changes something of the group this bot acts in: its members, their mute, its blacklist, a message. */ +@Singleton +@Contribute(Tool::class) +internal class QqBotActTool( + private val apis: QqBotApis, + private val sent: QqBotSentMessages, + private val index: QqBotMessageIndex, +) : Tool { + override val definition: ToolDefinition = ToolDefinition( + name = "qqbot.act", + description = describe( + "Changes something of a QQ group this bot acts in. A call that ends without an answer may still have " + + "been carried out, so read what that group holds before calling again", + QqBotActions.changing, + ), + parameters = schema(), + risk = ToolRisk.EXTERNAL_ACTION, + timeout = 2.minutes, + ) + + override suspend fun execute(arguments: JsonObject, context: ToolContext): ToolResult = + call(apis, QqBotActions.changing, arguments, context, sent, index) +} + +/** The arguments every call takes: the action and whatever that action takes. */ +private fun schema(): JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("action") { + put("type", "string") + put("description", "The action to call, named as one of the actions the description lists.") + } + putJsonObject("params") { + put("type", "object") + put("description", "That action's arguments, by the names the description gives them.") + put("additionalProperties", true) + } + } + put("required", JsonArray(listOf(JsonPrimitive("action")))) + put("additionalProperties", false) +} + +/** What the model is told these tools are for: every action and its arguments. */ +private fun describe(header: String, rows: List): String = buildString { + append(header) + append(". It calls one action per call, named as 'action', with that action's arguments under 'params'. ") + append("The group is the chat of the turn: it is never an argument. The actions, with a ? on an argument that ") + append("may be left out:\n") + rows.forEach { row -> + append("- ").append(row.name).append('(') + append(row.parameters.joinToString(", ") { it.name + (if (it.required) "" else "?") }) + append(") — ").append(row.said).append('\n') + row.parameters.forEach { parameter -> + append(" ").append(parameter.name).append(": ").append(parameter.description).append('\n') + } + } +} + +/** Calls [name] of [rows] in the chat of [context], and says what the platform answered. */ +private suspend fun call( + apis: QqBotApis, + rows: List, + arguments: JsonObject, + context: ToolContext, + sent: QqBotSentMessages?, + index: QqBotMessageIndex?, +): ToolResult { + val name = text(arguments, "action") + ?: return ToolResult("A call names an action, as action.", isError = true) + val row = rows.firstOrNull { it.name == name } + ?: return ToolResult( + "'$name' is no action this call may ask for. It may ask for: " + rows.joinToString(", ") { it.name } + ".", + isError = true, + ) + val params = arguments["params"] as? JsonObject ?: JsonObject(emptyMap()) + val missing = row.parameters.filter { it.required && !given(params, it) } + if (missing.isNotEmpty()) { + return ToolResult( + "The action '$name' needs " + missing.joinToString { it.name } + ": " + + missing.joinToString { "${it.name} (${it.description})" }, + isError = true, + ) + } + val chat = context.turn.chat + val api = apis.serving(chat) + ?: return ToolResult( + "The channel instance of $chat is not open, so the platform cannot be asked.", + isError = true, + ) + return withContext(Dispatchers.IO) { + currentCoroutineContext().ensureActive() + val quoted = index?.quotedTarget(chat, context.trigger?.id) + val trigger = when { + name != "recall" -> context.trigger?.id + + text(params, "message_id") != null -> context.trigger?.id + + quoted != null -> quoted + + else -> sent?.lastOf(chat) ?: return@withContext ToolResult( + "The bot knows no message it sent or this turn quotes, so a recall without message_id names nothing.", + isError = true, + ) + } + when (val answer = row.call(api, chat, params, trigger)) { + is QqBotAnswer.Ok -> { + if (!row.changes) context.untrusted("the platform answered what '$name' asked for") + val body = answer.body?.takeIf { it !is JsonNull }?.toString() + ToolResult(if (body == null) row.said else "${row.said}\n$body") + } + + is QqBotAnswer.Failed -> ToolResult( + "The platform answered no to '$name': ${answer.failure.message}" + + (answer.failure.code?.let { " (code $it)" } ?: ""), + isError = true, + ) + } + } +} + +/** Whether the call gave [parameter], as an array for an array argument. */ +private fun given(params: JsonObject, parameter: QqBotActions.Parameter): Boolean { + val value = params[parameter.name] ?: return false + if (value is JsonNull) return false + if (parameter.array) return value is JsonArray + return value is JsonPrimitive && value.contentOrNull?.isNotEmpty() == true +} + +/** The text a call gave for [name], null when it gave none. */ +private fun text(params: JsonObject, name: String): String? = + (params[name] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotEmpty() } diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotActions.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotActions.kt new file mode 100644 index 00000000..6707cedc --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotActions.kt @@ -0,0 +1,252 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.tools + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.put +import org.foedusprogramme.alexandrite.channel.qqbot.mapping.QqBotChats +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFailure +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind + +/** + * The actions of the open platform this channel offers the agent, each declared once. + * + * The official API is a set of REST endpoints and has no action names; the names here are this repository's own, and a + * call still makes the endpoint the row names. A row says what an action is for and what it takes, so a tool can + * describe the table to the model and refuse an action that is not in it. + */ +internal object QqBotActions { + internal class Parameter( + val name: String, + val description: String, + val required: Boolean = true, + /** Whether the value is an array of strings, which is sent as it arrives. */ + val array: Boolean = false, + ) + + /** + * One action: the endpoint it calls, the arguments it takes, and whether it changes something. + * + * The group id always comes from the chat of the call, never from the model, so a call cannot reach another chat. + */ + internal class Row( + val name: String, + val said: String, + val parameters: List, + val changes: Boolean, + val call: suspend (QqBotApi, ChatAddress, JsonObject, String?) -> QqBotAnswer, + ) + + /** The reads: what the platform knows about a group or the bot. */ + val reading: List = listOf( + Row("bot_info", "The bot was read.", emptyList(), changes = false) { api, _, _, _ -> + api.get("/users/@me") + }, + Row("group_info", "The group was read.", emptyList(), changes = false) { api, chat, _, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group, so it has no group information.") + api.get("/v2/groups/$group/info") + }, + Row("bot_state", "The state of the bot in the group was read.", emptyList(), changes = false) { + api, + chat, + _, + _, + -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + api.get("/v2/groups/$group/bot_state") + }, + Row( + "join_requests", + "The requests to join the group were read.", + listOf( + Parameter("cursor", "The cursor of the page to read, empty for the first page.", required = false), + Parameter("limit", "How many requests one page holds, at most 50.", required = false), + ), + changes = false, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + api.get("/v2/groups/$group/join_request_list", query(params, "cursor", "limit")) + }, + Row("group_mute_state", "The mute state of the group was read.", emptyList(), changes = false) { + api, + chat, + _, + _, + -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + api.get("/v2/groups/$group/restrict_chat_setting") + }, + Row( + "group_members", + "The members of the group were read.", + listOf(Parameter("cursor", "The cursor of the page to read, empty for the first page.", required = false)), + changes = false, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + api.get("/v2/groups/$group/members", query(params, "cursor")) + }, + Row( + "group_member", + "One member of the group was read.", + listOf(Parameter("member_openid", "The openid of the member in this group.")), + changes = false, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + val member = text(params, "member_openid") ?: return@Row refused("A call names member_openid.") + api.get("/v2/groups/$group/members/$member") + }, + Row( + "blacklist", + "The blacklist of the group was read.", + listOf( + Parameter("cursor", "The cursor of the page to read, empty for the first page.", required = false), + Parameter("limit", "How many entries one page holds, at most 100.", required = false), + ), + changes = false, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + api.get("/v2/groups/$group/member_blacklist", query(params, "cursor", "limit")) + }, + ) + + /** The actions that change something. */ + val changing: List = listOf( + Row( + "recall", + "The message was taken back.", + listOf( + Parameter( + "message_id", + "The id of the message to take back; the bot's last message in this chat when left out. A " + + "user's message must always be named explicitly.", + required = false, + ), + ), + changes = true, + ) { api, chat, params, trigger -> + val id = text(params, "message_id") ?: trigger + ?: return@Row refused("A call names message_id when the bot sent no message this process knows.") + val openid = QqBotChats.openid(chat) ?: return@Row refused("This instance serves no $chat.") + when (QqBotChats.kind(chat)) { + ChatKind.GROUP -> api.delete("/v2/groups/$openid/messages/$id") + ChatKind.DIRECT -> api.delete("/v2/users/$openid/messages/$id") + else -> refused("This instance serves no $chat.") + } + }, + Row( + "approve_join_request", + "The request to join the group was answered.", + listOf( + Parameter("member_openid", "The openid of the member who asked to join."), + Parameter("op", "approve to let the member in, decline to refuse."), + Parameter("join_request_id", "The id of the request, from join_requests.", required = false), + Parameter("reject_reason", "Why a request was declined.", required = false), + Parameter("add_to_member_blacklist", "Whether a declined member is blacklisted too.", required = false), + ), + changes = true, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + val member = text(params, "member_openid") ?: return@Row refused("A call names member_openid.") + if (text(params, "op") == null) return@Row refused("A call names op: approve or decline.") + api.post( + "/v2/groups/$group/approval_join_request/$member", + bodyOf(params, "op", "join_request_id", "reject_reason", "add_to_member_blacklist"), + ) + }, + Row( + "set_member_mute", + "The mute of members was changed.", + listOf( + Parameter( + "members", + "The changes, each {op: add|update|del, member_openid, mute_expire_at?}; at most 20.", + array = true, + ), + ), + changes = true, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + val members = params["members"] as? JsonArray ?: return@Row refused("A call names members as an array.") + api.post("/v2/groups/$group/restrict_chat_setting", buildJsonObject { put("members", members) }) + }, + Row( + "remove_members", + "The members were removed from the group.", + listOf( + Parameter("member_openids", "The openids of the members to remove, at most 20.", array = true), + Parameter( + "add_to_member_blacklist", + "Whether the removed members are blacklisted too.", + required = false, + ), + ), + changes = true, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + val ids = params["member_openids"] as? JsonArray ?: return@Row refused("A call names member_openids.") + api.post( + "/v2/groups/$group/batch_remove_members", + bodyOf(params, "add_to_member_blacklist").let { extra -> + buildJsonObject { + put("member_openids", ids) + extra.forEach { (name, value) -> put(name, value) } + } + }, + ) + }, + Row( + "blacklist_action", + "The blacklist of the group was changed.", + listOf( + Parameter("op", "add to put members on the blacklist, del to take them off."), + Parameter("member_openids", "The openids to change, at most 20.", array = true), + ), + changes = true, + ) { api, chat, params, _ -> + val group = groupOf(chat) ?: return@Row refused("This chat is no group.") + val ids = params["member_openids"] as? JsonArray ?: return@Row refused("A call names member_openids.") + val op = text(params, "op") ?: return@Row refused("A call names op: add or del.") + api.post( + "/v2/groups/$group/member_blacklist", + buildJsonObject { + put("op", op) + put("member_openids", ids) + }, + ) + }, + ) + + /** Every action, in the order the tools describe them. */ + val all: List get() = reading + changing + + /** [params] as the JSON body of the keys of [names] that the call gave and that are not null. */ + private fun bodyOf(params: JsonObject, vararg names: String): JsonObject = buildJsonObject { + for (name in names) { + val value = params[name] + if (value != null && value !is JsonNull) put(name, value) + } + } + + /** The query of the keys of [names] that the call gave. */ + private fun query(params: JsonObject, vararg names: String): Map = buildMap { + for (name in names) text(params, name)?.let { put(name, it) } + } + + /** The text a call gave for [name], null when it gave none. */ + private fun text(params: JsonObject, name: String): String? = + (params[name] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotEmpty() } + + /** The group openid of [chat], null when it is no group. */ + private fun groupOf(chat: ChatAddress): String? = + if (QqBotChats.kind(chat) == ChatKind.GROUP) QqBotChats.openid(chat) else null + + /** A call the platform is not asked about, with why. */ + private fun refused(said: String): QqBotAnswer = QqBotAnswer.Failed(QqBotFailure(null, said, null), null) +} diff --git a/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotApis.kt b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotApis.kt new file mode 100644 index 00000000..691d6372 --- /dev/null +++ b/libraries/channels/qqbot/src/main/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotApis.kt @@ -0,0 +1,31 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.tools + +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.di.Singleton +import java.util.concurrent.ConcurrentHashMap + +/** + * The API of each open channel instance, which a tool of the shared tool layer looks up by the chat of its call. + * + * A tool is a singleton and the API of an instance lives in that instance's scope, so the instance registers its API + * while it is open. A call of a chat whose instance is not open reaches nothing, which is the answer the tool gives. + */ +@Singleton +internal class QqBotApis { + private val apis = ConcurrentHashMap() + + /** Makes [api] the API of [instance] while it is open. */ + fun register(instance: ChannelInstanceId, api: QqBotApi) { + apis[instance] = api + } + + /** Forgets the API of [instance]. */ + fun forget(instance: ChannelInstanceId) { + apis.remove(instance) + } + + /** The API that serves [chat], null when its instance is not open. */ + fun serving(chat: ChatAddress): QqBotApi? = apis[chat.instance] +} diff --git a/libraries/channels/qqbot/src/main/resources/alexandrite/i18n/alexandrite-channel-qqbot/en.properties b/libraries/channels/qqbot/src/main/resources/alexandrite/i18n/alexandrite-channel-qqbot/en.properties new file mode 100644 index 00000000..aac31c8a --- /dev/null +++ b/libraries/channels/qqbot/src/main/resources/alexandrite/i18n/alexandrite-channel-qqbot/en.properties @@ -0,0 +1,15 @@ +approval.title=Approval needed (#{id}) +approval.tool=Tool: {tool} ({risk}) +approval.reason=Why: {reason} +approval.arguments=Arguments: +approval.allow=Allow +approval.deny=Deny +approval.always=Always allow: {rule} +approval.allowed=Allowed (#{id}) +approval.denied=Denied (#{id}) +approval.expired=The approval #{id} expired +approval.admins_only=Only an administrator may answer this. +approval.nothing_waiting=No approval waits for that. +approval.unreachable=No administrator could be reached for this approval. +refused.admin=Only an admin of this bot may use /{command}. +refused.admin_here=Only an admin of this bot may use /{command} in this chat. diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt new file mode 100644 index 00000000..e5b90b3d --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/QqBotChannelIntegrationTest.kt @@ -0,0 +1,478 @@ +package org.foedusprogramme.alexandrite.channel.qqbot + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.foedusprogramme.alexandrite.channel.qqbot.channel.QQ_BOT_MAX_FILE_BYTES +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeGatewayServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformAnswer +import org.foedusprogramme.alexandrite.sdk.InternalAlexandriteApi +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalAnswer +import org.foedusprogramme.alexandrite.sdk.channel.ApprovalRequest +import org.foedusprogramme.alexandrite.sdk.channel.ChannelDirectory +import org.foedusprogramme.alexandrite.sdk.channel.DelegatedRun +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.Markup +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.channel.ReplyRequest +import org.foedusprogramme.alexandrite.sdk.channel.ToolCallStatus +import org.foedusprogramme.alexandrite.sdk.channel.TurnEvent +import org.foedusprogramme.alexandrite.sdk.chat.AgentId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.RunId +import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId +import org.foedusprogramme.alexandrite.sdk.di.key +import org.foedusprogramme.alexandrite.sdk.tool.ToolActivity +import org.foedusprogramme.alexandrite.sdk.tool.ToolRisk +import org.foedusprogramme.alexandrite.sdk.turn.Submission +import org.foedusprogramme.alexandrite.testkit.PluginHarness +import org.foedusprogramme.alexandrite.testkit.RecordingTurnSubmitter +import org.foedusprogramme.alexandrite.testkit.testTurn +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * The QQ bot channel inside a real runtime. + * + * A harness builds the plugin set and the container, a fake platform serves the token and the gateway, and the test + * drives what the platform would do: it pushes a message of a single chat, the agent is asked for a turn, and the + * answer of that turn goes back to the fake platform as a reply to the message. + */ +class QqBotChannelIntegrationTest { + private val instance = ChannelInstanceId(ChannelType("qqbot"), "main") + + private val directory = key() + + @Test + fun `a message the platform pushes reaches the agent and its answer goes back as a reply`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(SEND_DIRECT, """{"id":"sent1","timestamp":"2026-07-21T10:00:00+08:00"}""") + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(EVENT) + val message = assertIs(awaitSubmission(submitter)).message + assertEquals("private:u1", message.chat.chat) + assertEquals("hello", message.text) + + val channel = get(directory).channel(instance) ?: error("the instance has no channel") + val sink = channel.openReply(ReplyRequest(testTurn(chat = message.chat), message.ref)) + val answer = OutboundMessage.builder("hello back", MessageKind.REPLY).build() + assertIs(sink.complete(answer)) + + val sent = api.calls(SEND_DIRECT).first { "\"msg_type\":0" in it.body } + assertTrue("\"content\":\"hello back\"" in sent.body, sent.body) + assertTrue("\"msg_id\":\"msg1\"" in sent.body, sent.body) + } + } + } finally { + platform.stop() + api.close() + } + } + + @Test + fun `whoami tells the person the address the instance knows them by`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(SEND_DIRECT, """{"id":"who1"}""") + val platform = FakeGatewayServer().startAndAwait() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(RecordingTurnSubmitter()) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(WHOAMI_EVENT) + val answer = assertNotNull(api.awaitRequest(SEND_DIRECT)) + assertTrue("private:u1" in answer.body, answer.body) + } + } + } finally { + platform.stop() + api.close() + } + } + + @Test + fun `a full-mode group message that names the bot reaches the agent`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(ME_PATH, """{"id":"bot1","username":"YosBot"}""") + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(FULL_MODE_EVENT) + val message = assertIs(awaitSubmission(submitter)).message + + assertEquals("group:g1", message.chat.chat) + assertEquals("hello", message.text) + } + } + } finally { + platform.stop() + api.close() + } + } + + @Test + fun `a full-mode group message names the bot by its member openid when the mention list does not`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(ME_PATH, """{"id":"global-bot-id"}""") + api.on(BOT_STATE_PATH, """{"member_openid":"member-bot-id"}""") + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(FULL_MODE_MENTION_EVENT) + val message = assertIs(awaitSubmission(submitter)).message + + assertEquals("group:g1", message.chat.chat) + assertEquals("hello", message.text) + } + } + } finally { + platform.stop() + api.close() + } + } + + @OptIn(InternalAlexandriteApi::class) + @Test + fun `a reply in a single chat is streamed into one message and ended there`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(STREAM_DIRECT, """{"id":"stream1","timestamp":"2026-07-21T10:00:00+08:00"}""") + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(EVENT) + val message = assertIs(awaitSubmission(submitter)).message + + val channel = get(directory).channel(instance) ?: error("the instance has no channel") + assertTrue(channel.capabilities(message.chat).streaming, "a single chat streams") + assertTrue(channel.capabilities(message.chat).files, "a message may carry files") + assertEquals(QQ_BOT_MAX_FILE_BYTES, channel.capabilities(message.chat).maxFileBytes) + val sink = channel.openReply(ReplyRequest(testTurn(chat = message.chat), message.ref)) + sink.event( + TurnEvent.ToolCallStarted( + ToolCallId("call1"), + "web.search", + ToolActivity.SEARCH, + "搜索资料", + ), + ) + sink.preview(0, "hel") + sink.preview(0, "hello") + val answer = OutboundMessage.builder("hello there", MessageKind.REPLY) + .markup(Markup.MARKDOWN) + .build() + assertIs(sink.complete(answer)) + + val pieces = api.calls(STREAM_DIRECT) + assertEquals(5, pieces.size) + assertTrue("\"content_raw\":\"⏳ web.search — 搜索资料\"" in pieces[0].body, pieces[0].body) + assertTrue("\"input_state\":1" in pieces[0].body, pieces[0].body) + assertTrue("hel" in pieces[1].body, pieces[1].body) + assertTrue("\"stream_msg_id\":\"stream1\"" in pieces[2].body, pieces[2].body) + assertTrue(" there" in pieces[3].body, pieces[3].body) + assertTrue("\"input_state\":10" in pieces[4].body, pieces[4].body) + assertTrue( + api.calls(SEND_DIRECT).none { "\"msg_type\":6" !in it.body }, + "the answer was sent as an ordinary message too", + ) + } + } + } finally { + platform.stop() + api.close() + } + } + + @Test + fun `a reply that starts a new segment keeps one streaming message`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(STREAM_DIRECT, """{"id":"stream1","timestamp":"2026-07-21T10:00:00+08:00"}""") + val platform = FakeGatewayServer().startAndAwait() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(RecordingTurnSubmitter()) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(EVENT) + val channel = get(directory).channel(instance) ?: error("the instance has no channel") + val chat = ChatAddress(instance, "private:u1") + val sink = channel.openReply( + ReplyRequest(testTurn(chat = chat), ChannelMessageRef(chat, "msg1")), + ) + sink.preview(0, "first") + sink.startSegment(1) + sink.preview(1, "second") + val answer = OutboundMessage.builder("answer", MessageKind.REPLY).build() + assertIs(sink.complete(answer)) + + val pieces = api.calls(STREAM_DIRECT) + assertEquals(4, pieces.size) + assertTrue("\"stream_msg_id\":\"stream1\"" in pieces[1].body, pieces[1].body) + assertTrue("\"stream_msg_id\":\"stream1\"" in pieces[2].body, pieces[2].body) + assertTrue("\"input_state\":10" in pieces[3].body, pieces[3].body) + assertTrue(api.calls(SEND_DIRECT).none { "\"msg_type\":6" !in it.body }) + } + } + } finally { + platform.stop() + api.close() + } + } + + @OptIn(InternalAlexandriteApi::class) + @Test + fun `an approval is asked with buttons and answered by a press`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + val sendReached = CountDownLatch(1) + val releaseSend = CountDownLatch(1) + api.on(SEND_DIRECT) { + sendReached.countDown() + check(releaseSend.await(5, TimeUnit.SECONDS)) { "the test did not release the approval send" } + PlatformAnswer(200, """{"id":"sent1","timestamp":"2026-07-21T10:00:00+08:00"}""") + } + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url, admins = setOf("u1"))) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + val channel = get(directory).channel(instance) ?: error("the instance has no channel") + val chat = ChatAddress(instance, "private:u1") + val request = ApprovalRequest( + "req1", + testTurn(chat = chat), + "command.exec", + ToolRisk.EXEC, + "{}", + null, + "it runs a command", + null, + ) + val waiting = async(start = CoroutineStart.UNDISPATCHED) { channel.askApproval(request) } + assertTrue(sendReached.await(5, TimeUnit.SECONDS), "the approval message was not sent") + val shown = assertNotNull(api.awaitRequest(SEND_DIRECT)) + assertTrue("\"msg_type\":2" in shown.body, shown.body) + assertTrue("\"markdown\"" in shown.body, shown.body) + assertTrue("\"keyboard\"" in shown.body, shown.body) + assertTrue("\"data\":\"/approve req1\"" in shown.body, shown.body) + assertTrue("\"data\":\"/deny req1\"" in shown.body, shown.body) + assertTrue("\"enter\":true" in shown.body, shown.body) + + platform.push(APPROVAL_PRESS) + // Let the inbox handle the press while sendApproval is still waiting for its answer. The + // recipient has to be known before this point; otherwise the press is silently dropped. + Thread.sleep(100) + releaseSend.countDown() + val answer = withTimeout(10.seconds) { waiting.await() } + assertIs(answer) + } + } + } finally { + releaseSend.countDown() + platform.stop() + api.close() + } + } + + @Test + fun `a command reaches the agent as a command rather than as a message`() { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + runBlocking { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(COMMAND_EVENT) + val command = assertIs(awaitSubmission(submitter)) + assertEquals("model", command.invocation.name) + assertEquals("deepseek/deepseek-flash", command.invocation.arguments) + } + } + } finally { + platform.stop() + api.close() + } + } + + private suspend fun awaitSubmission(submitter: RecordingTurnSubmitter): Submission { + val deadline = System.nanoTime() + 10_000_000_000L + while (System.nanoTime() < deadline) { + submitter.submissions.firstOrNull()?.let { return it } + delay(20) + } + error("the agent was asked for no turn") + } + + @Test + fun `a detached background run keeps writing to the reply's stream until it closes`() = runBlocking { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + api.on(STREAM_DIRECT, """{"id":"stream1","timestamp":"2026-07-21T10:00:00+08:00"}""") + val platform = FakeGatewayServer().startAndAwait() + val submitter = RecordingTurnSubmitter() + try { + PluginHarness.builder(AlexandriteChannelQqbotIndex()) + .config(config(api.base, platform.url)) + .turnSubmitter(submitter) + .build() + .run { + assertTrue(platform.awaitConnection(), "the gateway was not connected") + platform.push(EVENT) + val message = assertIs(awaitSubmission(submitter)).message + val channel = get(directory).channel(instance) ?: error("the instance has no channel") + val sink = channel.openReply(ReplyRequest(testTurn(chat = message.chat), message.ref)) + val call = ToolCallId("delegate1") + sink.event(TurnEvent.ToolCallStarted(call, "agent.delegate", ToolActivity.DELEGATE, "general")) + val activity = assertNotNull( + sink.detachActivity(DelegatedRun(call, RunId("r1"), AgentId("general"))), + ) + activity.event( + TurnEvent.ToolCallStarted( + ToolCallId("child1"), + "fs.read", + ToolActivity.READ, + "a file", + RunId("r1"), + AgentId("general"), + ), + ) + assertIs( + sink.complete(OutboundMessage.builder("done", MessageKind.REPLY).build()), + ) + assertTrue( + api.calls(STREAM_DIRECT).none { "\"input_state\":10" in it.body }, + "the stream closed while a background run still wrote to it", + ) + activity.close(ToolCallStatus.COMPLETED) + val last = api.calls(STREAM_DIRECT).last() + assertTrue("\"input_state\":10" in last.body, last.body) + } + } finally { + platform.stop() + api.close() + } + } + + private fun config(apiBase: String, gatewayUrl: String, admins: Set = emptySet()): String = """ + { + "instances": { + "main": { + "appId": "10000", + "appSecret": "secret", + "apiBase": "$apiBase", + "gatewayUrl": "$gatewayUrl", + "admins": [${admins.joinToString(",") { "\"$it\"" }}], + "connectTimeoutMillis": 5000, + "firstByteTimeoutMillis": 5000, + "idleTimeoutMillis": 5000, + "reconnectIntervalMillis": 200 + } + } + } + """.trimIndent() + + private companion object { + const val TOKEN_PATH = "/app/getAppAccessToken" + + const val ME_PATH = "/users/@me" + + const val BOT_STATE_PATH = "/v2/groups/g1/bot_state" + + const val SEND_DIRECT = "/v2/users/u1/messages" + + const val STREAM_DIRECT = "/v2/users/u1/stream_messages" + + const val APPROVAL_PRESS = """{"id":"event-approve","op":0,"s":2,"t":"C2C_MESSAGE_CREATE","d":""" + + """{"id":"msg-approve","author":{"id":"u1","user_openid":"u1","username":"Ada","bot":false},""" + + """"content":"/approve req1","message_type":0,"timestamp":"2026-07-21T10:00:00+08:00"}}""" + + const val COMMAND_EVENT = """{"id":"event-command","op":0,"s":3,"t":"C2C_MESSAGE_CREATE","d":""" + + """{"id":"msg-command","author":{"id":"u1","user_openid":"u1","username":"Ada","bot":false},""" + + """"content":"/model deepseek/deepseek-flash","message_type":0,"timestamp":"2026-07-21T10:00:01+08:00"}}""" + + const val EVENT = """{"id":"event1","op":0,"s":2,"t":"C2C_MESSAGE_CREATE","d":""" + + """{"id":"msg1","author":{"id":"u1","user_openid":"u1","username":"Ada","bot":false},""" + + """"content":"hello","message_type":0,"timestamp":"2026-07-21T10:00:00+08:00"}}""" + + const val WHOAMI_EVENT = """{"id":"event-who","op":0,"s":2,"t":"C2C_MESSAGE_CREATE","d":""" + + """{"id":"msg-who","author":{"id":"u1","user_openid":"u1","username":"Ada","bot":false},""" + + """"content":"/whoami","message_type":0,"timestamp":"2026-07-21T10:00:02+08:00"}}""" + + const val FULL_MODE_EVENT = """{"id":"event-full","op":0,"s":3,"t":"GROUP_MESSAGE_CREATE","d":""" + + """{"id":"msg-full","author":{"id":"u1","member_openid":"u1","username":"Ada","bot":false},""" + + """"content":"<@bot1> hello","group_openid":"g1","message_type":0,""" + + """"mentions":[{"id":"bot1","member_openid":"bot1","bot":true}],""" + + """"timestamp":"2026-07-21T10:00:02+08:00"}}""" + + const val FULL_MODE_MENTION_EVENT = """{"id":"event-full-mention","op":0,"s":4,""" + + """"t":"GROUP_MESSAGE_CREATE","d":{"id":"msg-full-mention",""" + + """"author":{"id":"u1","member_openid":"u1","username":"Ada","bot":false},""" + + """"content":"<@member-bot-id> hello","group_openid":"g1","message_type":0,""" + + """"timestamp":"2026-07-21T10:00:03+08:00"}}""" + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/auth/QqBotTokensTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/auth/QqBotTokensTest.kt new file mode 100644 index 00000000..43650d65 --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/auth/QqBotTokensTest.kt @@ -0,0 +1,60 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.auth + +import kotlinx.coroutines.runBlocking +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.MutableClock +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class QqBotTokensTest { + private val clock = MutableClock(Instant.parse("2026-07-21T00:00:00Z")) + + @Test + fun `a token is bought once and used until it is close to expiring`() = runBlocking { + val server = FakeApiServer() + server.on(TOKEN_PATH, """{"access_token":"one","expires_in":"7200"}""") + val http = QqBotHttp(testConfig(server.base)) + try { + val tokens = QqBotTokens(testConfig(server.base), http, clock) + assertEquals("one", tokens.token()) + assertEquals("one", tokens.token()) + assertEquals(1, server.calls(TOKEN_PATH).size, "the token was bought again while it was valid") + + clock.at = clock.at.plusSeconds(7_200) + assertEquals("one", tokens.token()) + + server.on(TOKEN_PATH, """{"access_token":"two","expires_in":"7200"}""") + clock.at = clock.at.plusSeconds(7_200) + assertEquals("two", tokens.token()) + assertEquals(3, server.calls(TOKEN_PATH).size) + } finally { + http.close() + server.close() + } + } + + @Test + fun `a refusal of the platform names its code and message`() = runBlocking { + val server = FakeApiServer() + server.on(TOKEN_PATH, """{"code":100016,"message":"invalid appid or secret"}""") + val http = QqBotHttp(testConfig(server.base)) + try { + val tokens = QqBotTokens(testConfig(server.base), http, clock) + val failure = assertFailsWith { tokens.token() } + assertTrue("100016" in failure.message.orEmpty(), failure.message) + assertTrue("invalid appid or secret" in failure.message.orEmpty(), failure.message) + } finally { + http.close() + server.close() + } + } + + private companion object { + const val TOKEN_PATH = "/app/getAppAccessToken" + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRulesTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRulesTest.kt new file mode 100644 index 00000000..471fb730 --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotCommandRulesTest.kt @@ -0,0 +1,45 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.turn.CommandContext +import org.foedusprogramme.alexandrite.sdk.turn.CommandHandler +import org.foedusprogramme.alexandrite.sdk.turn.CommandInvocation +import org.foedusprogramme.alexandrite.sdk.turn.CommandSpec +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class QqBotCommandRulesTest { + private val instance = ChannelInstanceId(ChannelType("qqbot"), "main") + private val direct = ChatAddress(instance, "private:u1") + private val group = ChatAddress(instance, "group:g1") + private val rules = QqBotCommandRules(listOf(Commands), control = null) + + @Test + fun `mayRun answers the matrix for a member`() { + assertTrue(rules.mayRun(direct, admin = false, "model", withArguments = false)) + assertFalse(rules.mayRun(direct, admin = false, "model", withArguments = true)) + assertTrue(rules.mayRun(direct, admin = false, "help", withArguments = false)) + assertTrue(rules.mayRun(group, admin = false, "help", withArguments = false)) + assertTrue(rules.mayRun(group, admin = false, "context", withArguments = true)) + assertFalse(rules.mayRun(group, admin = false, "agent", withArguments = true)) + assertFalse(rules.mayRun(group, admin = false, "trust", withArguments = false)) + assertTrue(rules.mayRun(group, admin = true, "trust", withArguments = false)) + assertTrue(rules.mayRun(group, admin = false, "not_a_command", withArguments = true)) + } + + private object Commands : CommandHandler { + override val commands: List = listOf( + "help", + "settings", + "context", + "agent", + "model", + "trust", + ).map { CommandSpec.builder(it, "a test command").build() } + + override suspend fun handle(invocation: CommandInvocation, context: CommandContext) = Unit + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt new file mode 100644 index 00000000..f37e9d47 --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotMenuSyncTest.kt @@ -0,0 +1,139 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.runBlocking +import org.foedusprogramme.alexandrite.channel.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.MutableClock +import org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformRequest +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +import org.foedusprogramme.alexandrite.sdk.turn.CommandContext +import org.foedusprogramme.alexandrite.sdk.turn.CommandHandler +import org.foedusprogramme.alexandrite.sdk.turn.CommandInvocation +import org.foedusprogramme.alexandrite.sdk.turn.CommandSpec +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class QqBotMenuSyncTest { + private val clock = MutableClock(Instant.parse("2026-07-21T00:00:00Z")) + + @Test + fun `the slash commands become a single-chat menu and command panels`() = runBlocking { + val platform = Platform() + try { + platform.server.on("/v2/panels") { request -> + if (request.method == "GET") { + PlatformAnswer(200, """{"records":[],"next_cursor":"","is_end":true}""") + } else { + PlatformAnswer(200, """{"panel_id":"p1"}""") + } + } + platform.server.on("/v2/menu", """{"version":1}""") + QqBotMenuSync(platform.api, platform.config, listOf(Commands)).onOpen() + + val menu = platform.server.calls("/v2/menu").single() + assertEquals("PUT", menu.method) + assertTrue("\"name\":\"model\"" in menu.body, menu.body) + assertTrue("\"send_message\":\"/model\"" in menu.body, menu.body) + assertTrue("\"desc\":\"Pick the model\"" in platform.server.calls("/v2/panels").last().body) + + val panels = platform.server.calls("/v2/panels") + assertEquals(4, panels.size) + assertEquals(listOf("GET", "POST", "GET", "POST"), panels.map { it.method }) + assertTrue("\"scope\":\"c2c\"" in panels[1].body, panels[1].body) + assertTrue("\"scope\":\"group\"" in panels[3].body, panels[3].body) + } finally { + platform.close() + } + } + + @Test + fun `the sync language picks the description the handler gives it`() = runBlocking { + val platform = Platform("zh-CN") + try { + platform.server.on("/v2/panels") { request -> + if (request.method == "GET") { + PlatformAnswer(200, """{"records":[],"next_cursor":"","is_end":true}""") + } else { + PlatformAnswer(200, """{"panel_id":"p1"}""") + } + } + platform.server.on("/v2/menu", """{"version":1}""") + QqBotMenuSync(platform.api, platform.config, listOf(Commands)).onOpen() + + assertTrue("\"desc\":\"选模型\"" in platform.server.calls("/v2/panels").last().body) + } finally { + platform.close() + } + } + + @Test + fun `an already published panel is updated instead of created again`() = runBlocking { + val platform = Platform() + try { + platform.server.on("/v2/panels") { request -> + if (request.method == "GET") { + PlatformAnswer( + 200, + """{"records":[{"panel_id":"p1","panel":{"remark":"alexandrite commands"}}],"is_end":true}""", + ) + } else { + PlatformAnswer(200, """{"id":"p1"}""") + } + } + platform.server.on("/v2/menu", """{"version":2}""") + platform.server.on("/v2/panels/p1", """{"version":2}""") + QqBotMenuSync(platform.api, platform.config, listOf(Commands)).onOpen() + + val calls = platform.server.calls("/v2/panels/p1") + assertEquals(2, calls.size) + assertTrue(calls.all { it.method == "PUT" }, calls.joinToString { it.method }) + } finally { + platform.close() + } + } + + private object Commands : CommandHandler { + override val commands: List = listOf( + CommandSpec.builder("model", "Pick the model").build(), + CommandSpec.builder("whoami", "Show the chat and sender").build(), + ) + + override fun description( + command: String, + language: org.foedusprogramme.alexandrite.sdk.chat.LanguageTag?, + ): String = if (language?.value?.startsWith("zh") == true) { + when (command) { + "model" -> "选模型" + else -> "查看 $command" + } + } else { + commands.first { it.name == command }.description + } + + override suspend fun handle(invocation: CommandInvocation, context: CommandContext) = + throw UnsupportedOperationException("not used") + } + + private inner class Platform(private val syncLanguage: String? = null) : AutoCloseable { + val server = FakeApiServer() + private val http: QqBotHttp + val api: QqBotApi + val config = testConfig(server.base, syncLanguage = syncLanguage) + + init { + server.on("/app/getAppAccessToken", """{"access_token":"access","expires_in":"7200"}""") + http = QqBotHttp(config) + api = QqBotApi(http, QqBotTokens(config, http, clock)) + } + + override fun close() { + http.close() + server.close() + } + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgressTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgressTest.kt new file mode 100644 index 00000000..9b8fb4ec --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotProgressTest.kt @@ -0,0 +1,74 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.runBlocking +import org.foedusprogramme.alexandrite.channel.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.MutableClock +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.TurnId +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class QqBotProgressTest { + private val instance = ChannelInstanceId(ChannelType("qqbot"), "main") + private val direct = ChatAddress(instance, "private:u1") + private val turn = TurnId("turn1") + private val clock = MutableClock(Instant.parse("2026-07-21T00:00:00Z")) + + @Test + fun `an approval closes the stream and defers the answer`() = runBlocking { + val platform = Platform() + try { + val stream = QqBotStream(platform.sender, direct, "msg1", platform.sender.sequenceFor("msg1")) + val progress = QqBotProgress() + progress.attach(turn, direct, stream) + stream.show("working") + + progress.defer(turn) + + assertTrue(progress.isDeferred(turn)) + val pieces = platform.server.calls(STREAM_DIRECT) + assertEquals(2, pieces.size) + assertTrue("\"input_state\":1" in pieces[0].body, pieces[0].body) + assertTrue("\"input_state\":10" in pieces[1].body, pieces[1].body) + assertTrue("\"content_raw\"" !in pieces[1].body, pieces[1].body) + + progress.done(turn) + assertTrue(!progress.isDeferred(turn)) + } finally { + platform.close() + } + } + + private inner class Platform : AutoCloseable { + val server = FakeApiServer() + private val http: QqBotHttp + val sender: QqBotSender + + init { + server.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + server.on(STREAM_DIRECT, """{"id":"stream1"}""") + val config = testConfig(server.base) + http = QqBotHttp(config) + val api = QqBotApi(http, QqBotTokens(config, http, clock)) + sender = QqBotSender(api, QqBotUploads(api, config), QqBotSentMessages(), QqBotMessageIndex()) + } + + override fun close() { + http.close() + server.close() + } + } + + private companion object { + const val TOKEN_PATH = "/app/getAppAccessToken" + const val STREAM_DIRECT = "/v2/users/u1/stream_messages" + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotPromptHookTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotPromptHookTest.kt new file mode 100644 index 00000000..6ba21d81 --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotPromptHookTest.kt @@ -0,0 +1,39 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.runBlocking +import org.foedusprogramme.alexandrite.sdk.InternalAlexandriteApi +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.hook.HookDecision +import org.foedusprogramme.alexandrite.sdk.turn.PromptSections +import org.foedusprogramme.alexandrite.testkit.testTurn +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(InternalAlexandriteApi::class) +class QqBotPromptHookTest { + private val hook = QqBotPromptHook() + + @Test + fun `the prompt of a qqbot turn says what the client renders`() = runBlocking { + val turn = testTurn(chat = chat("qqbot")) + val decision = hook.intercept(PromptSections(turn, emptyList())) + + val payload = assertIs>(decision).payload + val section = payload.sections.single { it.id == "qqbot.platform" } + assertTrue("GFM tables" in section.text) + assertTrue("[TOC]" in section.text) + } + + @Test + fun `the prompt of another channel is left alone`() = runBlocking { + val turn = testTurn(chat = chat("onebot")) + + assertEquals(HookDecision.Continue, hook.intercept(PromptSections(turn, emptyList()))) + } + + private fun chat(type: String) = ChatAddress(ChannelInstanceId(ChannelType(type), "main"), "private:u1") +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSenderTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSenderTest.kt new file mode 100644 index 00000000..69f8894c --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotSenderTest.kt @@ -0,0 +1,292 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.runBlocking +import org.foedusprogramme.alexandrite.channel.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.MutableClock +import org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformAnswer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +import org.foedusprogramme.alexandrite.sdk.channel.Delivery +import org.foedusprogramme.alexandrite.sdk.channel.DeliveryFailure +import org.foedusprogramme.alexandrite.sdk.channel.Markup +import org.foedusprogramme.alexandrite.sdk.channel.MessageKind +import org.foedusprogramme.alexandrite.sdk.channel.OutboundFile +import org.foedusprogramme.alexandrite.sdk.channel.OutboundMessage +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class QqBotSenderTest { + private val instance = ChannelInstanceId(ChannelType("qqbot"), "main") + private val group = ChatAddress(instance, "group:g1") + private val direct = ChatAddress(instance, "private:u1") + private val clock = MutableClock(Instant.parse("2026-07-21T00:00:00Z")) + + @Test + fun `a reply names the message it answers and counts its answers`() = runBlocking { + val platform = Platform() + try { + val message = OutboundMessage.builder("hi", MessageKind.REPLY) + .replyTo(ChannelMessageRef(group, "m1")) + .build() + assertIs(platform.sender.send(group, message)) + assertIs(platform.sender.send(group, message)) + + val calls = platform.server.calls(SEND_GROUP) + assertEquals(2, calls.size) + assertTrue("\"msg_id\":\"m1\"" in calls[0].body, calls[0].body) + assertTrue("\"msg_seq\":1" in calls[0].body, calls[0].body) + assertTrue("\"msg_seq\":2" in calls[1].body, calls[1].body) + assertEquals( + "QQBot access", + calls[0].headers.entries.firstOrNull { + it.key.equals("Authorization", true) + }?.value, + ) + } finally { + platform.close() + } + } + + @Test + fun `a reply carries the reference the platform needs for a quote bar`() = runBlocking { + val platform = Platform() + try { + platform.index.record(group, "REF1", "m1") + val message = OutboundMessage.builder("hi", MessageKind.REPLY) + .replyTo(ChannelMessageRef(group, "m1")) + .build() + assertIs(platform.sender.send(group, message)) + + val body = platform.server.calls(SEND_GROUP).single().body + assertTrue("\"message_reference\":{\"message_id\":\"REF1\"}" in body, body) + } finally { + platform.close() + } + } + + @Test + fun `a message with a file uploads it and sends the file alone`() = runBlocking { + val platform = Platform() + platform.server.on(UPLOAD_GROUP, """{"file_info":"FI","ttl":300}""") + platform.server.on(SEND_GROUP) { request -> + PlatformAnswer(200, if ("\"msg_type\":7" in request.body) """{"id":"media1"}""" else """{"id":"text1"}""") + } + try { + val file = OutboundFile.builder("a.png", "image/png", "hello".toByteArray()).build() + val message = OutboundMessage.builder("a caption", MessageKind.REPLY).files(listOf(file)).build() + + val delivery = assertIs(platform.sender.send(group, message)) + + assertEquals(listOf("media1"), delivery.messages.map { it.id }) + val upload = platform.server.calls(UPLOAD_GROUP).single() + assertTrue("\"file_type\":1" in upload.body, upload.body) + assertTrue("\"file_data\":\"aGVsbG8=\"" in upload.body, upload.body) + val messages = platform.server.calls(SEND_GROUP) + assertEquals( + 1, + messages.size, + "the caption was sent as a message beside the file: ${messages.map { + it.body + }}", + ) + assertTrue("\"msg_type\":7" in messages[0].body, messages[0].body) + assertTrue("\"file_info\":\"FI\"" in messages[0].body, messages[0].body) + } finally { + platform.close() + } + } + + @Test + fun `a message written as markdown goes as a markdown message`() = runBlocking { + val platform = Platform() + try { + val message = OutboundMessage.builder("## hi", MessageKind.REPLY).markup(Markup.MARKDOWN).build() + assertIs(platform.sender.send(direct, message)) + + val body = platform.server.calls(SEND_DIRECT).single().body + assertTrue("\"msg_type\":2" in body, body) + assertTrue("\"markdown\"" in body, body) + assertFalse("\"msg_id\"" in body, "a message that answers nothing is an active one: $body") + } finally { + platform.close() + } + } + + @Test + fun `a reply the platform no longer takes is sent as an active message`() = runBlocking { + val platform = Platform() + platform.server.on(SEND_DIRECT) { + if (it.body.contains("msg_id")) { + PlatformAnswer(200, """{"err_code":40034005,"message":"回复消息msg_id已过期"}""") + } else { + PlatformAnswer(200, """{"id":"sent2","timestamp":"2026-07-21T10:00:00+08:00"}""") + } + } + try { + val message = OutboundMessage.builder("late", MessageKind.REPLY) + .replyTo(ChannelMessageRef(direct, "m2")) + .build() + val delivery = platform.sender.send(direct, message) + + assertEquals("sent2", assertIs(delivery).messages.single().id) + val calls = platform.server.calls(SEND_DIRECT) + assertEquals(2, calls.size) + assertFalse("\"msg_id\"" in calls[1].body, calls[1].body) + } finally { + platform.close() + } + } + + @Test + fun `a message the platform finds too long comes back as one a caller may shorten`() = runBlocking { + val platform = Platform() + platform.server.on(SEND_DIRECT, """{"err_code":40054007,"message":"消息长度超限"}""") + try { + val message = OutboundMessage.builder("long", MessageKind.REPLY).build() + val delivery = platform.sender.send(direct, message) + + val failure = assertIs(delivery) + assertEquals(DeliveryFailure.TOO_LONG, failure.kind) + assertTrue("消息长度超限" in failure.detail.orEmpty(), failure.detail) + } finally { + platform.close() + } + } + + @Test + fun `a chat this channel serves none of is refused before any call`() = runBlocking { + val platform = Platform() + try { + val message = OutboundMessage.builder("hi", MessageKind.REPLY).build() + val delivery = platform.sender.send(ChatAddress(instance, "topic:1"), message) + + assertIs(delivery) + assertTrue(platform.server.requests().isEmpty(), "a chat of another channel reached the platform") + } finally { + platform.close() + } + } + + @Test + fun `a message the platform takes no markdown of is sent as plain text`() = runBlocking { + val platform = Platform() + platform.server.on(SEND_DIRECT) { + if ("\"msg_type\":2" in it.body) { + PlatformAnswer(200, """{"err_code":40034127,"message":"无markdown模板权限"}""") + } else { + PlatformAnswer(200, """{"id":"plain1","timestamp":"2026-07-21T10:00:00+08:00"}""") + } + } + try { + val message = OutboundMessage.builder("## hi", MessageKind.REPLY).markup(Markup.MARKDOWN).build() + val delivery = platform.sender.send(direct, message) + + assertEquals("plain1", assertIs(delivery).messages.single().id) + val calls = platform.server.calls(SEND_DIRECT) + assertEquals(2, calls.size) + assertTrue("\"msg_type\":0" in calls[1].body, calls[1].body) + assertTrue("\"content\":\"## hi\"" in calls[1].body, calls[1].body) + } finally { + platform.close() + } + } + + @Test + fun `a single chat is streamed in one message the pieces replace`() = runBlocking { + val platform = Platform() + platform.server.on(STREAM_DIRECT, """{"id":"stream1","timestamp":"2026-07-21T10:00:00+08:00"}""") + try { + val first = platform.sender.stream(direct, "he", "m1", 1, null, 0, last = false) + assertEquals("stream1", platform.sender.sentId(first)) + val second = platform.sender.stream(direct, "hello", "m1", 1, "stream1", 1, last = false) + assertEquals("stream1", platform.sender.sentId(second)) + + val calls = platform.server.calls(STREAM_DIRECT) + assertEquals(2, calls.size) + assertTrue("\"input_state\":1" in calls[0].body, calls[0].body) + assertTrue("\"index\":0" in calls[0].body, calls[0].body) + assertTrue("\"content_type\":\"markdown\"" in calls[0].body, calls[0].body) + assertTrue("\"content_raw\":\"he\"" in calls[0].body, calls[0].body) + assertTrue("\"msg_id\":\"m1\"" in calls[0].body, calls[0].body) + assertTrue("\"stream_msg_id\"" !in calls[0].body, calls[0].body) + assertTrue("\"stream_msg_id\":\"stream1\"" in calls[1].body, calls[1].body) + assertTrue("\"index\":1" in calls[1].body, calls[1].body) + } finally { + platform.close() + } + } + + @Test + fun `a group has no streaming endpoint so no piece is sent`() = runBlocking { + val platform = Platform() + try { + val answer = platform.sender.stream(group, "he", "m1", 1, null, 0, last = false) + + assertIs(answer) + assertTrue(platform.server.calls(STREAM_GROUP).isEmpty(), "a group piece reached the platform") + } finally { + platform.close() + } + } + + @Test + fun `a single chat is told the bot is writing`() = runBlocking { + val platform = Platform() + platform.server.on(SEND_DIRECT) { PlatformAnswer(200, """{"id":"typing1"}""") } + try { + assertIs(platform.sender.sendTyping(direct)) + val body = platform.server.calls(SEND_DIRECT).single().body + assertTrue("\"msg_type\":6" in body, body) + assertTrue("\"input_type\":1" in body, body) + assertTrue("\"input_second\":60" in body, body) + + assertIs(platform.sender.sendTyping(group)) + assertTrue(platform.server.calls(SEND_GROUP).isEmpty(), "a group was told of an input state") + } finally { + platform.close() + } + } + + /** A sender that talks to a fake platform, which answers the token and every message with a success. */ + private inner class Platform : AutoCloseable { + val server = FakeApiServer() + private val http: QqBotHttp + val sender: QqBotSender + val index = QqBotMessageIndex() + + init { + server.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + server.on(SEND_GROUP, """{"id":"sent1","timestamp":"2026-07-21T10:00:00+08:00"}""") + server.on(SEND_DIRECT, """{"id":"sent1","timestamp":"2026-07-21T10:00:00+08:00"}""") + val config = testConfig(server.base) + http = QqBotHttp(config) + val tokens = QqBotTokens(config, http, clock) + val api = QqBotApi(http, tokens) + sender = QqBotSender(api, QqBotUploads(api, config), QqBotSentMessages(), index) + } + + override fun close() { + http.close() + server.close() + } + } + + private companion object { + const val TOKEN_PATH = "/app/getAppAccessToken" + const val SEND_GROUP = "/v2/groups/g1/messages" + const val UPLOAD_GROUP = "/v2/groups/g1/files" + const val SEND_DIRECT = "/v2/users/u1/messages" + const val STREAM_DIRECT = "/v2/users/u1/stream_messages" + const val STREAM_GROUP = "/v2/groups/g1/stream_messages" + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotUploadsTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotUploadsTest.kt new file mode 100644 index 00000000..abb9a545 --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/channel/QqBotUploadsTest.kt @@ -0,0 +1,119 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.channel + +import kotlinx.coroutines.runBlocking +import org.foedusprogramme.alexandrite.channel.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.MutableClock +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +import org.foedusprogramme.alexandrite.sdk.channel.OutboundFile +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import java.time.Instant +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class QqBotUploadsTest { + private val instance = ChannelInstanceId(ChannelType("qqbot"), "main") + private val direct = ChatAddress(instance, "private:u1") + private val clock = MutableClock(Instant.parse("2026-07-21T00:00:00Z")) + + @Test + fun `a small file goes as base64 in the upload call`() = runBlocking { + val platform = Platform() + platform.server.on(FILES, """{"file_info":"FI","ttl":300}""") + try { + val file = OutboundFile.builder("a.png", "image/png", "hello".toByteArray()).build() + + val uploaded = assertIs(platform.uploads.upload(direct, file)) + + assertEquals("FI", uploaded.fileInfo) + val body = platform.server.calls(FILES).single().body + assertTrue("\"file_type\":1" in body, body) + assertTrue("\"file_name\":\"a.png\"" in body, body) + assertTrue("\"file_data\":\"aGVsbG8=\"" in body, body) + } finally { + platform.close() + } + } + + @Test + fun `a large file goes in blocks and the platform merges them`() = runBlocking { + val platform = Platform() + platform.server.on(PREPARE) { + val base = platform.server.base + org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformAnswer( + 200, + """{"upload_id":"up","block_size":"3145728","upload_config":{"concurrency":2},"parts":[""" + + """{"index":0,"presigned_url":"$base/upload/0","block_size":"3145728"},""" + + """{"index":1,"presigned_url":"$base/upload/1","block_size":"3145728"}]}""", + ) + } + val active = AtomicInteger() + val peak = AtomicInteger() + for (path in listOf("/upload/0", "/upload/1")) { + platform.server.on(path) { + val now = active.incrementAndGet() + peak.updateAndGet { maxOf(it, now) } + Thread.sleep(80) + active.decrementAndGet() + org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformAnswer(200, "{}") + } + } + platform.server.on(PART_FINISH, "{}") + platform.server.on(FILES, """{"file_info":"FI2"}""") + try { + val bytes = ByteArray(6 * 1024 * 1024) { (it % 251).toByte() } + val file = OutboundFile.builder("big.bin", "application/octet-stream", bytes).build() + + val uploaded = assertIs(platform.uploads.upload(direct, file)) + + assertEquals("FI2", uploaded.fileInfo) + val prepare = platform.server.calls(PREPARE).single() + assertTrue("\"file_size\":\"6291456\"" in prepare.body, prepare.body) + assertTrue("\"md5\":" in prepare.body, prepare.body) + assertTrue("\"sha1\":" in prepare.body, prepare.body) + assertTrue("\"md5_10m\":" in prepare.body, prepare.body) + val puts = platform.server.calls("/upload/0") + platform.server.calls("/upload/1") + assertEquals(2, puts.size) + assertTrue(puts.all { it.method == "PUT" }, puts.map { it.method }.toString()) + assertEquals(2, platform.server.calls(PART_FINISH).size) + assertTrue("\"upload_id\":\"up\"" in platform.server.calls(FILES).single().body) + assertTrue(peak.get() >= 2, "the two blocks were not uploaded at the same time: peak ${peak.get()}") + } finally { + platform.close() + } + } + + /** Uploads that talk to a fake platform, which keeps what it read. */ + private inner class Platform : AutoCloseable { + val server = FakeApiServer() + private val http: QqBotHttp + val uploads: QqBotUploads + + init { + server.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + val config = testConfig(server.base) + http = QqBotHttp(config) + val tokens = QqBotTokens(config, http, clock) + uploads = QqBotUploads(QqBotApi(http, tokens), config) + } + + override fun close() { + http.close() + server.close() + } + } + + private companion object { + const val TOKEN_PATH = "/app/getAppAccessToken" + const val FILES = "/v2/users/u1/files" + const val PREPARE = "/v2/users/u1/upload_prepare" + const val PART_FINISH = "/v2/users/u1/upload_part_finish" + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/config/QqBotConfigTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/config/QqBotConfigTest.kt new file mode 100644 index 00000000..6bee4e7d --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/config/QqBotConfigTest.kt @@ -0,0 +1,71 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.config + +import org.foedusprogramme.alexandrite.sdk.config.Secret +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class QqBotConfigTest { + @Test + fun `an instance names the bot and the platform`() { + val config = QqBotInstanceConfig(appId = "10000", appSecret = Secret("secret")) + + assertEquals("10000", config.appId) + assertEquals(QqBotInstanceConfig.DEFAULT_API_BASE, config.apiBase) + assertEquals(1L shl 25, config.intents) + assertEquals(true, config.streaming) + assertEquals(10_000L, config.connectTimeoutMillis) + assertEquals(10L, config.approvalMinutes) + } + + @Test + fun `a blank app id is refused`() { + assertFailsWith { QqBotInstanceConfig(appId = "", appSecret = Secret("secret")) } + } + + @Test + fun `an app id that is no number or word is refused`() { + assertFailsWith { + QqBotInstanceConfig(appId = "app id", appSecret = Secret("secret")) + } + } + + @Test + fun `an api base that is no HTTP address is refused`() { + assertFailsWith { + QqBotInstanceConfig(appId = "10000", appSecret = Secret("secret"), apiBase = "api.bot.qq.com") + } + } + + @Test + fun `a gateway address that is no websocket address is refused`() { + assertFailsWith { + QqBotInstanceConfig( + appId = "10000", + appSecret = Secret("secret"), + gatewayUrl = "https://api.bot.qq.com/websocket", + ) + } + } + + @Test + fun `intents that name no event group are refused`() { + assertFailsWith { + QqBotInstanceConfig(appId = "10000", appSecret = Secret("secret"), intents = 0) + } + } + + @Test + fun `a timeout that is not positive is refused`() { + assertFailsWith { + QqBotInstanceConfig(appId = "10000", appSecret = Secret("secret"), connectTimeoutMillis = 0) + } + } + + @Test + fun `a sync language that is no language tag is refused`() { + assertFailsWith { + QqBotInstanceConfig(appId = "10000", appSecret = Secret("secret"), syncLanguage = "中文") + } + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGatewayTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGatewayTest.kt new file mode 100644 index 00000000..85627f0e --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/gateway/QqBotGatewayTest.kt @@ -0,0 +1,65 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.gateway + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.foedusprogramme.alexandrite.channel.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeGatewayServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.MutableClock +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class QqBotGatewayTest { + private val clock = MutableClock(Instant.parse("2026-07-21T00:00:00Z")) + + @Test + fun `the gateway identifies, becomes ready and hands the pushed events on`() = runBlocking { + val api = FakeApiServer() + api.on(TOKEN_PATH, """{"access_token":"access","expires_in":"7200"}""") + val platform = FakeGatewayServer().startAndAwait() + val config = testConfig(api.base, gatewayUrl = platform.url) + val http = QqBotHttp(config) + try { + val tokens = QqBotTokens(config, http, clock) + val gateway = QqBotGateway(config, QqBotApi(http, tokens), tokens) + gateway.onStart() + + val identify = assertNotNull(platform.awaitFrame(predicate = { it.contains("\"op\":2") })) + assertTrue("QQBot access" in identify, identify) + assertTrue("\"intents\":${1L shl 25}" in identify, identify) + assertTrue("\"shard\":[0,1]" in identify, identify) + assertEquals("bot1", gateway.botId) + + platform.push(EVENT) + val frame = withTimeout(5_000) { gateway.frames.first { it.t == "C2C_MESSAGE_CREATE" } } + assertEquals("event1", frame.id) + assertEquals( + "msg1", + frame.d?.let { + org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire.message(it)?.id + }, + ) + + gateway.onStop() + } finally { + platform.stop() + http.close() + api.close() + } + } + + private companion object { + const val TOKEN_PATH = "/app/getAppAccessToken" + + const val EVENT = """{"id":"event1","op":0,"s":2,"t":"C2C_MESSAGE_CREATE","d":""" + + """{"id":"msg1","author":{"id":"u1","user_openid":"u1","username":"Ada"},"content":"hi",""" + + """"message_type":0,"timestamp":"2026-07-21T10:00:00+08:00"}}""" + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMarkdownTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMarkdownTest.kt new file mode 100644 index 00000000..bfcef4cb --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMarkdownTest.kt @@ -0,0 +1,72 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.mapping + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class QqBotMarkdownTest { + @Test + fun `supported markdown is passed through`() { + val text = "# Title\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```kotlin\nval x = 1\n```\n" + + assertEquals(text, QqBotMarkdown.render(text)) + } + + @Test + fun `a highlight becomes bold`() { + assertEquals("**important**", QqBotMarkdown.render("==important==")) + } + + @Test + fun `a superscript and a subscript become the inline HTML the client renders`() { + assertEquals("E = mc2", QqBotMarkdown.render("E = mc^2^")) + assertEquals("H2O", QqBotMarkdown.render("H~2~O")) + assertEquals("~~removed~~", QqBotMarkdown.render("~~removed~~")) + } + + @Test + fun `a definition list becomes plain lines`() { + assertEquals("term:its meaning", QqBotMarkdown.render("term\n: its meaning")) + } + + @Test + fun `a table of contents and a footnote definition are dropped`() { + val text = "[TOC]\n\n[^note]: a footnote\n\nA paragraph." + + assertEquals("\n\nA paragraph.", QqBotMarkdown.render(text)) + } + + @Test + fun `an HTML block loses its tags but keeps its text`() { + val text = "
More\n\nSome text\n\n
" + + assertEquals("More\n\nSome text\n\n", QqBotMarkdown.render(text)) + } + + @Test + fun `a break and a rule become markdown the client renders`() { + assertEquals("a\nb\n---", QqBotMarkdown.render("a
b
")) + } + + @Test + fun `an image with a local path becomes its alt text and a public one stays`() { + assertEquals("a picture", QqBotMarkdown.render("![a picture](./images/a.png)")) + val remote = "![a picture](https://example.com/a.png)" + assertEquals(remote, QqBotMarkdown.render(remote)) + } + + @Test + fun `a code fence and an inline code span are not changed`() { + val text = "```\n==not bold==
\n```\n\n`==not bold==`" + + assertEquals(text, QqBotMarkdown.render(text)) + } + + @Test + fun `withoutUrls keeps the label and drops the target`() { + assertEquals("see the site", QqBotMarkdown.render("see [the site](https://example.com)", stripUrls = true)) + assertFalse("example.com" in QqBotMarkdown.render("see https://example.com", stripUrls = true)) + assertTrue("the site" in QqBotMarkdown.withoutUrls("see [the site](https://example.com)")) + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMessagesTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMessagesTest.kt new file mode 100644 index 00000000..481e6d8e --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/mapping/QqBotMessagesTest.kt @@ -0,0 +1,162 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.mapping + +import org.foedusprogramme.alexandrite.channel.qqbot.channel.QqBotMessageIndex +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatKind +import org.foedusprogramme.alexandrite.sdk.transcript.MediaKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class QqBotMessagesTest { + private val instance = ChannelInstanceId(ChannelType("qqbot"), "main") + + private val http = QqBotHttp(testConfig("http://127.0.0.1:1")) + private val media = QqBotMedia(http) + + @Test + fun `a message of a single chat is the direct chat of its author`() { + val event = eventOf(c2c()) + val message = QqBotMessages.incoming(instance, event, admins = emptySet(), media = media) + + assertEquals("private:u1", message.chat.chat) + assertEquals(ChatKind.DIRECT, message.chatInfo.kind) + assertEquals("msg1", message.ref.id) + assertEquals("Ada", message.sender.displayName) + assertEquals(" hello ", message.text) + assertEquals(1, message.media.size) + assertEquals(MediaKind.IMAGE, message.media.single().kind) + assertEquals("a.jpg", message.media.single().name) + assertEquals(10L, message.media.single().size) + } + + @Test + fun `a message of a group keeps its group and the role of its author`() { + val message = QqBotMessages.incoming(instance, eventOf(group()), admins = emptySet(), media = media) + + assertEquals("group:g1", message.chat.chat) + assertEquals(ChatKind.GROUP, message.chatInfo.kind) + assertEquals("member1", message.sender.address.user) + assertTrue(message.sender.isAdmin, "the owner and admins of a group operate the bot") + } + + @Test + fun `an account the config names is an admin`() { + val message = QqBotMessages.incoming(instance, eventOf(c2c()), admins = setOf("u1"), media = media) + + assertTrue(message.sender.isAdmin) + } + + @Test + fun `a card message reaches the model as a card`() { + val message = QqBotMessages.incoming(instance, eventOf(framed("C2C_MESSAGE_CREATE", card())), emptySet(), media) + + val fact = message.facts.singleOrNull { it.label == "card" } + assertNotNull(fact) + assertTrue("小程序" in fact.value, fact.value) + } + + @Test + fun `a quote message carries what it quoted`() { + val message = QqBotMessages.incoming( + instance, + eventOf(framed("C2C_MESSAGE_CREATE", quoted())), + emptySet(), + media, + ) + + val quote = assertNotNull(message.quote) + assertTrue("每天坚持阅读" in quote.text, quote.text) + } + + @Test + fun `a quote resolves its reference to the message id`() { + val index = QqBotMessageIndex() + val chat = ChatAddress(instance, "private:u1") + index.record(chat, "REF2", "original") + val message = QqBotMessages.incoming( + instance, + eventOf(framed("C2C_MESSAGE_CREATE", quoted())), + emptySet(), + media, + index, + ) + + assertEquals("original", message.quote?.target?.id) + } + + private fun eventOf(frame: org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFrame) = + assertNotNull(QqBotWire.message(frame.d)) + + private fun framed(name: String, data: String): org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFrame = + assertNotNull(QqBotWire.frame("""{"id":"event1","op":0,"s":2,"t":"$name","d":$data}""")) + + private fun c2c(): org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFrame = framed( + "C2C_MESSAGE_CREATE", + """ + { + "id": "msg1", + "author": {"id": "u1", "user_openid": "u1", "username": "Ada", "bot": false}, + "content": " hello ", + "message_type": 0, + "timestamp": "2026-07-21T10:00:00+08:00", + "message_scene": {"source": "default", "ext": ["msg_idx=REF1"]}, + "attachments": [ + { + "url": "https://multimedia.example.com/1.jpg", + "filename": "a.jpg", + "content_type": "image/jpeg", + "size": 10, + "width": 2, + "height": 3 + } + ] + } + """.trimIndent(), + ) + + private fun group(): org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotFrame = framed( + "GROUP_AT_MESSAGE_CREATE", + """ + { + "id": "msg2", + "author": {"id": "m1", "member_openid": "member1", "member_role": "owner", "username": "小明"}, + "content": "hi", + "group_openid": "g1", + "message_type": 0, + "timestamp": "2026-07-21T10:01:00+08:00" + } + """.trimIndent(), + ) + + private fun card(): String = """ + { + "id": "msg3", + "author": {"id": "u1", "user_openid": "u1", "username": "Ada"}, + "content": "[卡片消息]", + "message_type": 3, + "ark_data": {"prompt": "[每日打卡]", "ark_type": "miniapp", "ark_name": "小程序"}, + "timestamp": "2026-07-21T10:02:00+08:00" + } + """.trimIndent() + + private fun quoted(): String = """ + { + "id": "msg4", + "author": {"id": "u1", "user_openid": "u1", "username": "Ada"}, + "content": "", + "message_type": 103, + "message_scene": {"source": "default", "ext": ["msg_idx=REF3", "ref_msg_idx=REF2"]}, + "msg_elements": [ + {"msg_idx": "REF2", "message_type": 0, "content": "每天坚持阅读半小时"} + ], + "timestamp": "2026-07-21T10:03:00+08:00" + } + """.trimIndent() +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/testing/FakePlatform.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/testing/FakePlatform.kt new file mode 100644 index 00000000..fd493dfc --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/testing/FakePlatform.kt @@ -0,0 +1,161 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.testing + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.time.Clock +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +/** A clock a test moves by hand. */ +internal class MutableClock(var at: Instant) : Clock() { + override fun instant(): Instant = at + + override fun getZone(): ZoneId = ZoneOffset.UTC + + override fun withZone(zone: ZoneId?): Clock = this +} + +/** One request a fake platform read. */ +internal class PlatformRequest( + val method: String, + val path: String, + val query: String, + val body: String, + val headers: Map, +) + +/** What a fake platform answers to one path. */ +internal class PlatformAnswer(val status: Int = 200, val body: String = "{}") + +/** An HTTP server that stands for the open platform's API and keeps what it read. */ +internal class FakeApiServer : AutoCloseable { + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + private val routes = CopyOnWriteArrayList PlatformAnswer>>() + private val requests = CopyOnWriteArrayList() + + val base: String + get() = "http://127.0.0.1:${server.address.port}" + + init { + server.createContext("/") { exchange -> answer(exchange) } + server.executor = java.util.concurrent.Executors.newCachedThreadPool() + server.start() + } + + /** Answers calls to [path] with [answer], which the last route added for a path wins. */ + fun on(path: String, answer: (PlatformRequest) -> PlatformAnswer) { + routes += path to answer + } + + /** Answers every call to [path] with [body] as JSON. */ + fun on(path: String, body: String, status: Int = 200) = on(path) { PlatformAnswer(status, body) } + + /** Every request read so far, oldest first. */ + fun requests(): List = requests.toList() + + /** The request calls of [path] read so far. */ + fun calls(path: String): List = requests.filter { it.path == path } + + /** The next request that [predicate] takes, waiting for it at most [timeoutMillis]. */ + fun awaitRequest(path: String, timeoutMillis: Long = 5_000): PlatformRequest? { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + while (System.nanoTime() < deadline) { + calls(path).lastOrNull()?.let { return it } + Thread.sleep(10) + } + return null + } + + private fun answer(exchange: HttpExchange) { + val body = exchange.requestBody.readBytes().decodeToString() + val request = PlatformRequest( + exchange.requestMethod, + exchange.requestURI.rawPath, + exchange.requestURI.rawQuery ?: "", + body, + exchange.requestHeaders.entries.associate { it.key to it.value.first() }, + ) + requests += request + val answer = routes.lastOrNull { it.first == request.path }?.second?.invoke(request) + ?: PlatformAnswer(404, """{"err_code":404,"message":"no route"}""") + val bytes = answer.body.toByteArray() + exchange.responseHeaders.add("Content-Type", "application/json; charset=utf-8") + exchange.sendResponseHeaders(answer.status, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + + override fun close() = server.stop(0) +} + +/** A gateway that stands for the platform's WebSocket: it says hello, takes an identify and pushes what it is told. */ +internal class FakeGatewayServer : WebSocketServer(InetSocketAddress("127.0.0.1", 0)) { + private val started = CountDownLatch(1) + private val connected = CountDownLatch(1) + private val received = LinkedBlockingQueue() + private val clients = CopyOnWriteArrayList() + + /** The address the channel connects to. */ + val url: String get() = "ws://127.0.0.1:$port" + + /** Starts the server and returns once it listens. */ + fun startAndAwait(): FakeGatewayServer { + start() + check(started.await(5, TimeUnit.SECONDS)) { "the fake gateway did not start" } + return this + } + + /** Whether a client connected within [timeoutMillis]. */ + fun awaitConnection(timeoutMillis: Long = 5_000): Boolean = connected.await(timeoutMillis, TimeUnit.MILLISECONDS) + + /** The next frame the client sent that [predicate] takes, waiting for it at most [timeoutMillis]. */ + fun awaitFrame(predicate: (String) -> Boolean = { true }, timeoutMillis: Long = 5_000): String? { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis) + val kept = mutableListOf() + while (System.nanoTime() < deadline) { + val frame = received.poll(20, TimeUnit.MILLISECONDS) ?: continue + kept += frame + if (predicate(frame)) return frame + } + return null + } + + /** Pushes [frame] to every connected client. */ + fun push(frame: String) { + clients.forEach { it.send(frame) } + } + + override fun onStart() { + started.countDown() + } + + override fun onOpen(connection: WebSocket, handshake: ClientHandshake) { + clients += connection + connected.countDown() + connection.send("""{"op":10,"d":{"heartbeat_interval":60000}}""") + } + + override fun onMessage(connection: WebSocket, message: String) { + received += message + if (message.contains("\"op\":2")) { + connection.send(READY) + } + } + + override fun onClose(connection: WebSocket, code: Int, reason: String, remote: Boolean) = Unit + + override fun onError(connection: WebSocket?, ex: Exception) = Unit + + private companion object { + const val READY = """{"op":0,"s":1,"t":"READY","d":{"version":1,"session_id":"s1",""" + + """"user":{"id":"bot1","username":"Bot","bot":true},"shard":[0,0]}}""" + } +} diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/testing/TestConfig.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/testing/TestConfig.kt new file mode 100644 index 00000000..1958c35b --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/testing/TestConfig.kt @@ -0,0 +1,23 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.testing + +import org.foedusprogramme.alexandrite.channel.qqbot.config.QqBotInstanceConfig +import org.foedusprogramme.alexandrite.sdk.config.Secret + +/** The config of one bot a test runs against [apiBase], with [gatewayUrl] when the test serves the gateway itself. */ +internal fun testConfig( + apiBase: String, + gatewayUrl: String? = null, + admins: Set = emptySet(), + syncLanguage: String? = null, +): QqBotInstanceConfig = QqBotInstanceConfig( + appId = "10000", + appSecret = Secret("secret"), + apiBase = apiBase, + gatewayUrl = gatewayUrl, + admins = admins, + syncLanguage = syncLanguage, + connectTimeoutMillis = 5_000, + firstByteTimeoutMillis = 5_000, + idleTimeoutMillis = 5_000, + reconnectIntervalMillis = 200, +) diff --git a/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotToolsTest.kt b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotToolsTest.kt new file mode 100644 index 00000000..64001a8b --- /dev/null +++ b/libraries/channels/qqbot/src/test/kotlin/org/foedusprogramme/alexandrite/channel/qqbot/tools/QqBotToolsTest.kt @@ -0,0 +1,159 @@ +package org.foedusprogramme.alexandrite.channel.qqbot.tools + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject +import org.foedusprogramme.alexandrite.channel.qqbot.auth.QqBotTokens +import org.foedusprogramme.alexandrite.channel.qqbot.channel.QqBotMessageIndex +import org.foedusprogramme.alexandrite.channel.qqbot.channel.QqBotSentMessages +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotApi +import org.foedusprogramme.alexandrite.channel.qqbot.net.QqBotHttp +import org.foedusprogramme.alexandrite.channel.qqbot.testing.FakeApiServer +import org.foedusprogramme.alexandrite.channel.qqbot.testing.MutableClock +import org.foedusprogramme.alexandrite.channel.qqbot.testing.testConfig +import org.foedusprogramme.alexandrite.sdk.chat.ChannelInstanceId +import org.foedusprogramme.alexandrite.sdk.chat.ChannelType +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.testkit.TestToolContext +import org.foedusprogramme.alexandrite.testkit.testToolContext +import org.foedusprogramme.alexandrite.testkit.testTurn +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class QqBotToolsTest { + private val instance = ChannelInstanceId(ChannelType("qqbot"), "main") + private val group = ChatAddress(instance, "group:g1") + + @Test + fun `a read names its action and marks what it read as untrusted`() = runBlocking { + val platform = Platform() + platform.server.on("/v2/groups/g1/info", """{"group_name":"The group"}""") + try { + val context = context() + val result = platform.read.execute(action("group_info"), context) + + assertTrue(!result.isError, result.content.toString()) + assertTrue("The group" in result.content.toString(), result.content.toString()) + assertTrue(context.untrustedReasons.isNotEmpty(), "a read was not marked untrusted") + } finally { + platform.close() + } + } + + @Test + fun `a group is read by the openid of the chat rather than by an argument`() = runBlocking { + val platform = Platform() + platform.server.on("/v2/groups/g1/members", """{"members":[],"next_cursor":""}""") + try { + val result = platform.read.execute( + action("group_members", """{"cursor":"c1"}"""), + context(), + ) + + assertTrue(!result.isError, result.content.toString()) + val calls = platform.server.calls("/v2/groups/g1/members") + assertEquals(1, calls.size) + assertTrue("cursor=c1" in calls.single().query, calls.single().query) + } finally { + platform.close() + } + } + + @Test + fun `a permission the platform withholds is reported as an error`() = runBlocking { + val platform = Platform() + platform.server.on("/v2/groups/g1/info", """{"code":11253,"message":"the bot is not whitelisted"}""") + try { + val result = platform.read.execute(action("group_info"), context()) + + assertTrue(result.isError, result.content.toString()) + assertTrue("11253" in result.content.toString(), result.content.toString()) + } finally { + platform.close() + } + } + + @Test + fun `an action the table does not hold is refused`() = runBlocking { + val platform = Platform() + try { + val result = platform.read.execute(action("delete_everything"), context()) + + assertTrue(result.isError) + assertTrue("no action" in result.content.toString(), result.content.toString()) + } finally { + platform.close() + } + } + + @Test + fun `a message of the turn is taken back when the call names none`() = runBlocking { + val platform = Platform() + platform.server.on("/v2/groups/g1/messages/m1") { + org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformAnswer(200, "{}") + } + try { + val result = platform.act.execute( + action("recall", """{"message_id":"m1"}"""), + context(), + ) + + assertTrue(!result.isError, result.content.toString()) + val call = platform.server.calls("/v2/groups/g1/messages/m1").single() + assertEquals("DELETE", call.method) + } finally { + platform.close() + } + } + + @Test + fun `a recall without an id takes back the bot's last message`() = runBlocking { + val platform = Platform() + platform.server.on("/v2/groups/g1/messages/bot1") { + org.foedusprogramme.alexandrite.channel.qqbot.testing.PlatformAnswer(200, "{}") + } + platform.sent.record(group, "bot1") + try { + val result = platform.act.execute(action("recall"), context()) + + assertTrue(!result.isError, result.content.toString()) + assertEquals("DELETE", platform.server.calls("/v2/groups/g1/messages/bot1").single().method) + } finally { + platform.close() + } + } + + private fun context(): TestToolContext = testToolContext(turn = testTurn(chat = group)) + + private fun action(name: String, params: String = "{}"): JsonObject = + org.foedusprogramme.alexandrite.channel.qqbot.protocol.QqBotWire.json.parseToJsonElement( + """{"action":"$name","params":$params}""", + ) as JsonObject + + /** An API of the fake platform, and the tools that talk through it. */ + private inner class Platform : AutoCloseable { + val server = FakeApiServer() + private val http: QqBotHttp + val apis = QqBotApis() + val sent = QqBotSentMessages() + val index = QqBotMessageIndex() + val read: QqBotReadTool + val act: QqBotActTool + + init { + server.on("/app/getAppAccessToken", """{"access_token":"access","expires_in":"7200"}""") + val config = testConfig(server.base) + http = QqBotHttp(config) + val tokens = QqBotTokens(config, http, MutableClock(Instant.parse("2026-07-21T00:00:00Z"))) + apis.register(instance, QqBotApi(http, tokens)) + read = QqBotReadTool(apis) + act = QqBotActTool(apis, sent, index) + } + + override fun close() { + http.close() + server.close() + } + } +} diff --git a/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/channel/ChannelNotice.kt b/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/channel/ChannelNotice.kt new file mode 100644 index 00000000..59aebf5f --- /dev/null +++ b/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/channel/ChannelNotice.kt @@ -0,0 +1,84 @@ +package org.foedusprogramme.alexandrite.sdk.channel + +import dev.drewhamilton.poko.Poko +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef +import org.foedusprogramme.alexandrite.sdk.chat.ChatAddress +import org.foedusprogramme.alexandrite.sdk.chat.ChatUser +import java.time.Instant + +/** + * Something a channel was told that is not a message anybody wrote. + * + * A channel reports the messages its chats send as turns, and everything else it is told — somebody joined, somebody + * left, a message was taken back, a friendship was asked for — as one of these. What a plugin does with one is its own + * business, which is why the kind is a name rather than a closed set: every platform has events the others do not, and + * a subscriber that does not know a kind can still see that something happened. + * + * The texts here are what the platform reported, escaped where they are shown to a model. + */ +@Poko +public class ChannelNotice( + public val chat: ChatAddress, + public val kind: ChannelNoticeKind, + /** Who caused it, null when the platform did not say. */ + public val actor: ChatUser? = null, + /** Whom it concerns besides [actor], such as the member who joined or was removed. */ + public val target: ChatUser? = null, + /** The message it is about, for the kinds that name one, such as a message taken back. */ + public val message: ChannelMessageRef? = null, + public val receivedAt: Instant, + /** What else the platform said, in the order it said it. */ + public val facts: List = emptyList(), +) + +/** + * What kind of [ChannelNotice] this is. + * + * A channel reports the kinds it knows under these names, and may report one of its own under a name of its own, which + * is what keeps a platform's whole vocabulary reachable without this list growing for every platform. + */ +@JvmInline +public value class ChannelNoticeKind(public val id: String) { + override fun toString(): String = id + + public companion object { + /** Somebody joined the chat. */ + public val MEMBER_JOINED: ChannelNoticeKind = ChannelNoticeKind("member.joined") + + /** Somebody left the chat, whether by leaving or by being removed. */ + public val MEMBER_LEFT: ChannelNoticeKind = ChannelNoticeKind("member.left") + + /** Somebody was silenced or unsilenced. */ + public val MEMBER_BANNED: ChannelNoticeKind = ChannelNoticeKind("member.banned") + + /** Somebody's standing in the chat changed, such as becoming an administrator. */ + public val ROLE_CHANGED: ChannelNoticeKind = ChannelNoticeKind("role.changed") + + /** A message was taken back. */ + public val MESSAGE_RECALLED: ChannelNoticeKind = ChannelNoticeKind("message.recalled") + + /** Somebody was added as a friend, or asked to be. */ + public val FRIEND_ADDED: ChannelNoticeKind = ChannelNoticeKind("friend.added") + + /** Somebody asked to join. */ + public val JOIN_REQUESTED: ChannelNoticeKind = ChannelNoticeKind("join.requested") + + /** Somebody asked to be a friend. */ + public val FRIEND_REQUESTED: ChannelNoticeKind = ChannelNoticeKind("friend.requested") + + /** The kinds this version knows. */ + public val entries: List = listOf( + MEMBER_JOINED, + MEMBER_LEFT, + MEMBER_BANNED, + ROLE_CHANGED, + MESSAGE_RECALLED, + FRIEND_ADDED, + JOIN_REQUESTED, + FRIEND_REQUESTED, + ) + + /** The kind of [id], which keeps an id this version does not know. */ + public fun of(id: String): ChannelNoticeKind = entries.firstOrNull { it.id == id } ?: ChannelNoticeKind(id) + } +} diff --git a/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/channel/ChannelPoints.kt b/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/channel/ChannelPoints.kt new file mode 100644 index 00000000..cc47edcc --- /dev/null +++ b/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/channel/ChannelPoints.kt @@ -0,0 +1,20 @@ +package org.foedusprogramme.alexandrite.sdk.channel + +import org.foedusprogramme.alexandrite.sdk.hook.ObserverHook +import org.foedusprogramme.alexandrite.sdk.hook.ObserverPoint + +/** + * The hook points of the channels. + * + * A channel fires these for what its chats were told besides messages. A subscriber contributes an [ObserverHook] of + * the point, and the runtime hands it every notice of every channel that fires it, which is what lets a plugin act on + * an event of a platform it was not written for. + */ +public object ChannelPoints { + /** + * Fires for every report that is not a message, in the order the channel read them. + * + * A notice is not a turn and nothing waits for a subscriber: one that fails is logged and the channel goes on. + */ + public val NOTICE: ObserverPoint = ObserverPoint("channel.notice") +} diff --git a/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/tool/Tool.kt b/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/tool/Tool.kt index 158695e9..1d777bb7 100644 --- a/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/tool/Tool.kt +++ b/libraries/plugin-sdk/src/main/kotlin/org/foedusprogramme/alexandrite/sdk/tool/Tool.kt @@ -3,6 +3,7 @@ package org.foedusprogramme.alexandrite.sdk.tool import dev.drewhamilton.poko.Poko import kotlinx.serialization.json.JsonObject import org.foedusprogramme.alexandrite.sdk.InternalAlexandriteApi +import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId import org.foedusprogramme.alexandrite.sdk.chat.TurnInfo import org.foedusprogramme.alexandrite.sdk.di.ContributedSpi @@ -100,6 +101,15 @@ public interface ToolContext { /** What let the call run, null while it is being reviewed. */ public val clearance: Clearance? + /** + * The message this turn was started by, null when no message started it. + * + * A tool that acts on what a person just said reads it from here, because a prompt does not name the message it + * answers: a call that needs an id it was never shown would otherwise have to be told one by the person who wrote + * the message, which is what a turn about that message should not require. + */ + public val trigger: ChannelMessageRef? get() = null + /** Reports that the call brought untrusted content into the conversation, for [reason]. */ public fun untrusted(reason: String) diff --git a/libraries/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) diff --git a/libraries/testkit/src/test/kotlin/org/foedusprogramme/alexandrite/testkit/PublicApiTest.kt b/libraries/testkit/src/test/kotlin/org/foedusprogramme/alexandrite/testkit/PublicApiTest.kt index cc16918e..b34cd298 100644 --- a/libraries/testkit/src/test/kotlin/org/foedusprogramme/alexandrite/testkit/PublicApiTest.kt +++ b/libraries/testkit/src/test/kotlin/org/foedusprogramme/alexandrite/testkit/PublicApiTest.kt @@ -277,6 +277,7 @@ class PublicApiTest { return listOf( mandate, result, tool.definition, tool.calls, tool.cancelled, call.arguments, call.turn, call.call, checked, + context.trigger, context.turn, context.call, context.model, context.workspace, context.touchedPaths, context.openWorkspace(), defined, context.clearance, context.untrustedReasons, context.conversationDirectory, context.openConversationDirectory(), quotaRefusal, background.toString(),