Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
fc18ad8
onebot: Add the OneBot v11 protocol and transports
Yos-X Oct 18, 2026
2b49ff2
onebot: Map OneBot events and chats
Yos-X Oct 18, 2026
3bfa51b
onebot: Deliver replies, approvals and progress
Yos-X Oct 18, 2026
8c0a9d5
onebot: Add platform tools and media
Yos-X Oct 18, 2026
9c3acb6
sdk: Add channel notices, points and the trigger message
Yos-X Oct 18, 2026
addbe2b
agent: Frame and pass the message a turn answers
Yos-X Oct 18, 2026
446b337
provider: Read the media type of an image
Yos-X Oct 18, 2026
cea0c71
onebot: Add its tests
Yos-X Oct 18, 2026
f4e87f2
qqbot: Add the official bot protocol and gateway
Yos-X Oct 18, 2026
1e03a5d
qqbot: Map chats and route commands
Yos-X Oct 18, 2026
c763578
qqbot: Send replies, Markdown and streamed answers
Yos-X Oct 18, 2026
7d2a492
qqbot: Add approvals, menus and platform tools
Yos-X Oct 18, 2026
277a138
qqbot: Add its tests
Yos-X Oct 18, 2026
e8bd05d
app: Load and document the OneBot and QQ bot channels
Yos-X Oct 18, 2026
a48dd51
onebot, qqbot: Add the English texts
Yos-X Oct 18, 2026
1b84b04
qqbot: Register an approval recipient before sending its message
Yos-X Oct 18, 2026
6352e85
qqbot: Serialize a reply and close its stream by chat
Yos-X Oct 19, 2026
9a9a495
onebot, qqbot: Match the command rules with the other channels
Yos-X Oct 19, 2026
f925b7d
app: Validate the example's channel sections
Yos-X Oct 19, 2026
7793d34
onebot: Keep activity progress and its sender safe
Yos-X Oct 19, 2026
011fe88
onebot: Report socket timeouts as failures and drop repeated reports
Yos-X Oct 19, 2026
940dd9b
qqbot: Keep reconnecting when the gateway or its token fails
Yos-X Oct 19, 2026
1911866
onebot: Render only public image URLs as images
Yos-X Oct 19, 2026
092400a
qqbot, onebot: Bound the state an approval keeps
Yos-X Oct 19, 2026
2929993
qqbot: Retry a failed bot-state lookup
Yos-X Oct 19, 2026
7fca8d3
qqbot: Cover denying an approval by a press
Yos-X Oct 19, 2026
266c3c7
Merge origin/master into the onebot channel branch
Yos-X Oct 19, 2026
75b2477
Merge fix/platform into the onebot channel branch
Yos-X Oct 20, 2026
2e04382
agent: Wrap the call that executes a turn's tool
Yos-X Oct 20, 2026
05eb749
onebot, qqbot: Take the controls that master split the SDK's control …
Yos-X Oct 20, 2026
b839eb8
qqbot: Publish only the command items the platform takes
Yos-X Oct 20, 2026
7e1a926
tools: Treat a Windows shell as a program that runs code
Yos-X Oct 20, 2026
2e06d59
qqbot: Publish fewer command items when the platform names no limit
Yos-X Oct 20, 2026
fd77541
qqbot: Cut a command panel's text to the units the platform counts
Yos-X Oct 20, 2026
82679aa
Merge fix/platform into the onebot channel branch
Yos-X Oct 21, 2026
708538b
qqbot: Keep the gateway reconnecting when the platform is unreachable
Yos-X Oct 21, 2026
ad6471f
Merge origin/master into the onebot channel branch
Yos-X Oct 21, 2026
d844d3f
onebot, qqbot: Follow the publishing conventions of the build
Yos-X Oct 21, 2026
9af881e
Merge remote-tracking branch 'origin/master' into feature/channel-onebot
Yos-X Oct 24, 2026
7c5582c
onebot, qqbot: Follow the merged master and make OneBot's operators i…
Yos-X Oct 24, 2026
5e56fb5
onebot, qqbot: Show every rule an approval grants and run the QQ test…
Yos-X Oct 26, 2026
f2ec581
app: Add the new approval texts to the Chinese packs
Yos-X Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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:provider-common"))
implementation(project(":libraries:providers:openai-compatible"))
implementation(project(":libraries:providers:openai-responses"))
Expand Down
32 changes: 32 additions & 0 deletions app/src/dist/config/alexandrite.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,37 @@
"enabled": false
}
}
},
"channels": {
"onebot": {
"enabled": false,
"settings": {
"eventQueueCapacity": 1024
},
"instances": {
"main": {
"transport": "ws_reverse",
"listenHost": "127.0.0.1",
"listenPort": 8080,
"allowedChats": [],
"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": []
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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}
approval.always_rules=永久允许将授予:{rules}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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}。
approval.always_short=始终
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ class AppTextsTest {
"alexandrite-automation-heartbeat",
"alexandrite-automation-schedule",
"alexandrite-channel-telegram",
"alexandrite-channel-onebot",
"alexandrite-channel-qqbot",
"alexandrite-commands",
"alexandrite-mcp",
),
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
jline = "3.30.17"
maven-publish = "0.37.0"

Expand All @@ -37,6 +38,7 @@ jline-terminal-jni = { module = "org.jline:jline-terminal-jni", version.ref = "j
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]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import org.unitedsoftware.alexandrite.agent.sandbox.CallSandboxes
import org.unitedsoftware.alexandrite.agent.trust.CallTrust
import org.unitedsoftware.alexandrite.agent.trust.TurnTaint
import org.unitedsoftware.alexandrite.agent.workspace.Workspaces
import org.unitedsoftware.alexandrite.sdk.chat.ChannelMessageRef
import org.unitedsoftware.alexandrite.sdk.chat.ToolCallId
import org.unitedsoftware.alexandrite.sdk.chat.TurnInfo
import org.unitedsoftware.alexandrite.sdk.chat.TurnKind
Expand All @@ -32,6 +33,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. */
Expand Down Expand Up @@ -85,6 +88,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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import org.unitedsoftware.alexandrite.agent.workspace.Workspaces
import org.unitedsoftware.alexandrite.internal.text.fittingLength
import org.unitedsoftware.alexandrite.sdk.channel.ToolCallStatus
import org.unitedsoftware.alexandrite.sdk.channel.TurnEvent
import org.unitedsoftware.alexandrite.sdk.chat.ChannelMessageRef
import org.unitedsoftware.alexandrite.sdk.chat.TurnInfo
import org.unitedsoftware.alexandrite.sdk.di.Singleton
import org.unitedsoftware.alexandrite.sdk.permission.ClearanceKind
Expand Down Expand Up @@ -93,6 +94,7 @@ internal class ToolExecutor(
callable: Set<String>,
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)
Expand All @@ -102,7 +104,16 @@ 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, sandboxes = sandboxes)
val reviewing = AgentToolContext(
turn,
call.id,
workspaces,
directories,
null,
taint,
trigger,
sandboxes = sandboxes,
)
val admission = try {
gate.admit(GateCall(turn, call, tool, arguments, limit, taint, reviewing, calls))
} finally {
Expand All @@ -125,6 +136,7 @@ internal class ToolExecutor(
directories,
admission.clearance,
taint,
trigger,
delegating,
startsInBackground = admission.background && tool !is InTurnTool,
granting = { grants.grant(turn, tool) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,7 @@ internal class AgentTurnRunner(
callable,
taint,
tracked,
plan.trigger,
)
reads.reason(turn, call.name, end.tainting)?.let { taint.mark(it) }
taint.record()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ class JobsTest {
NoTaintLedger,
)
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<RunId> = JobRecords(records.of("alexandrite-agent")).all().keys
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ class PermissionStatusToolTest {
ConversationDirectories(ConventionalAgentPlaces(hostPaths(null)), settings("{}")),
null,
taint,
null,
)

private suspend fun status(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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()
}

Expand Down
18 changes: 18 additions & 0 deletions libraries/channels/onebot/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
plugins {
id("alexandrite.kotlin-library")
id("alexandrite.kotlin-serialization")
}

description = "The OneBot v11 channel of Alexandrite"

dependencies {
implementation(project(":libraries:channel-common"))
api(project(":libraries:plugin-sdk"))
implementation(project(":libraries:internal"))

// The client and the server of the Websocket transports, of which the JDK offers a client only.
implementation(libs.java.websocket)

testImplementation(project(":libraries:testkit"))
testImplementation(libs.logback.classic)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.unitedsoftware.alexandrite.channel.onebot

import org.unitedsoftware.alexandrite.sdk.plugin.Plugin

/** The OneBot v11 channel plugin, which talks to an external OneBot implementation such as NapCat. */
@Plugin(name = "OneBot", description = "OneBot v11 channel")
internal class OneBotPlugin
Loading
Loading