Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
6d10e10
onebot: Add the protocol domain and the action registry
Yos-X Oct 10, 2026
94b6629
onebot: Carry the token both ways and bind reports to their account
Yos-X Oct 10, 2026
a0a3a31
onebot: Map OneBot messages to the channel contract
Yos-X Oct 10, 2026
b055170
onebot: Contribute the channel, its instance API and the app wiring
Yos-X Oct 12, 2026
0349d59
onebot: Read a socket answer as an answer, and keep the socket honest
Yos-X Oct 12, 2026
7f033ce
onebot: Bound the report listener and answer what it cannot take
Yos-X Oct 12, 2026
63769f6
onebot: Write the values of the standard, and say a refusal as text
Yos-X Oct 12, 2026
4663317
onebot: Take a command, and a group message only when the bot is addr…
Yos-X Oct 12, 2026
bb8ba83
onebot: Answer an approval by replying to the message that asked for it
Yos-X Oct 12, 2026
0162218
onebot: Say what a message that was quoted holds
Yos-X Oct 12, 2026
15dd550
onebot: Ask who may run a command before running it
Yos-X Oct 12, 2026
3eac791
onebot: Serve a chat only once somebody said it may
Yos-X Oct 12, 2026
f58cccd
onebot: Read the bytes a reported picture, voice or video names
Yos-X Oct 12, 2026
c095343
plugin-sdk, onebot: Publish what a report says besides a message
Yos-X Oct 12, 2026
0a9587e
onebot: Let the agent act on a chat of the platform
Yos-X Oct 12, 2026
64d2fc3
plugin-sdk, agent, onebot: Let a tool act on the message its turn is …
Yos-X Oct 12, 2026
ce48f1d
agent: Name the message a turn answers, and the one it quotes
Yos-X Oct 12, 2026
f656a5e
onebot: Declare the platform's actions once, and serve them with one …
Yos-X Oct 12, 2026
18e772f
onebot: Say what the table of actions holds and what it keeps out of …
Yos-X Oct 12, 2026
307213b
onebot: Send what a call gave as the standard asks, and refuse what c…
Yos-X Oct 12, 2026
2016b37
onebot: Write every argument as the standard asks, and hold the actio…
Yos-X Oct 12, 2026
d44dbac
onebot: Answer a read with what the platform gave
Yos-X Oct 12, 2026
0ea6013
onebot: Let a picture in the quoted message reach the turn
Yos-X Oct 12, 2026
bbd0cca
onebot: Read and write the file segment an implementation reports
Yos-X Oct 12, 2026
43aba05
onebot: Let a call hand the platform a file of the workspace
Yos-X Oct 12, 2026
93dba75
agent: Expect the framing that names the message id
Yos-X Oct 12, 2026
ecb93aa
provider: Read the format of an image from its bytes where its channe…
Yos-X Oct 12, 2026
099ada6
onebot: Resolve the path a call names the way a tool for files resolv…
Yos-X Oct 12, 2026
979229a
onebot: Send a file too large for one request in pieces
Yos-X Oct 12, 2026
4eca128
onebot: Let a change take as long as a file of the workspace can need
Yos-X Oct 12, 2026
95c9223
onebot: Read the files of a call on the dispatcher for reading files
Yos-X Oct 12, 2026
edb1c9c
onebot: Say what a call that ends without an answer may have done
Yos-X Oct 12, 2026
2e54ab1
onebot: Let a file of a group be checked and taken away
Yos-X Oct 12, 2026
cffdd76
Merge origin/master into the onebot channel branch
Yos-X Oct 13, 2026
d9b9dd6
agent: Hand the test of background calls the message its turn answers
Yos-X Oct 13, 2026
16a2654
app, agent: Wrap the lines that grew past the length a line may be
Yos-X Oct 13, 2026
bd70e09
app, testkit: Expect the ids the framing names and the trigger the co…
Yos-X Oct 13, 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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies {
implementation(project(":libraries:commands"))
implementation(project(":libraries:channels:telegram"))
implementation(project(":libraries:channel-common"))
implementation(project(":libraries:channels:onebot"))
implementation(project(":libraries:providers:openai-compatible"))
implementation(project(":libraries:providers:deepseek"))
implementation(project(":libraries:providers:openrouter"))
Expand Down
18 changes: 18 additions & 0 deletions app/src/dist/config/alexandrite.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,23 @@
"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
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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} 分钟内有效。
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,15 @@ class AppTextsTest {
val classLoader = AppPlugin::class.java.classLoader
val packs = AppTexts.packs(classLoader)

assertEquals(setOf("alexandrite-agent", "alexandrite-channel-telegram", "alexandrite-commands"), packs.plugins)
assertEquals(
setOf(
"alexandrite-agent",
"alexandrite-channel-telegram",
"alexandrite-channel-onebot",
"alexandrite-commands",
),
packs.plugins,
)
for (plugin in packs.plugins) assertEquals(listOf(chinese), assertTextsComplete(plugin, packs, classLoader))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ConversationStore>().current(app.key(app.adaChat)).id
app.fake.send(app.ada, app.adaChat, "Did it run?", replyTo = asked.id)
Expand All @@ -145,11 +146,11 @@ class TelegramPermissionTest {
val id = Regex("#([a-z0-9]+)").find(asked.text)!!.groupValues[1]
assertContains(
framing("Did it run?"),
"\nQuoting: Alexandrite Test Bot (verified)\n" +
"\nQuoting: Alexandrite Test Bot (verified) [id ${asked.id}]\n" +
"> Alexandrite's approval request $id for notes.write (allowed by Ada Lovelace (@ada_test))\n",
)
assertNull(afterRequest)
assertContains(framing("Really?"), "\nQuoting: Alexandrite Test Bot (unverified)\n> Noted.\n")
assertContains(framing("Really?"), "\nQuoting: Alexandrite Test Bot (unverified) [id ${noted.id}]\n> Noted.\n")
assertEquals("a quote that its channel does not vouch for", afterAnswer?.reason)
}

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"

[libraries]
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
Expand All @@ -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" }
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,20 @@ 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)
appendLine()
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)}") }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.foedusprogramme.alexandrite.agent.permission.TurnTaint
import org.foedusprogramme.alexandrite.agent.workspace.Workspaces
import org.foedusprogramme.alexandrite.sdk.chat.ChannelMessageRef
import org.foedusprogramme.alexandrite.sdk.chat.ToolCallId
import org.foedusprogramme.alexandrite.sdk.chat.TurnInfo
import org.foedusprogramme.alexandrite.sdk.permission.Clearance
Expand All @@ -19,6 +20,7 @@ internal class AgentToolContext(
clearance: Clearance?,
val taint: TurnTaint,
/** What a sub-agent run that the call starts takes from its turn, null outside a turn. */
trigger: ChannelMessageRef?,
val delegating: DelegatingTurn? = null,
) : ToolContext {
@Volatile
Expand All @@ -35,6 +37,8 @@ internal class AgentToolContext(
override val workspace: Path?
get() = workspaces.paths[turn.agent]

override val trigger: ChannelMessageRef? = trigger

override val clearance: Clearance? = clearance
get() = field.also { checkOpen() }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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
Expand Down Expand Up @@ -77,6 +78,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 @@ -86,7 +88,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, null, taint)
val reviewing = AgentToolContext(turn, call.id, workspaces, null, taint, trigger)
val admission = try {
gate.admit(GateCall(turn, call, tool, arguments, limit, taint, reviewing, calls))
} finally {
Expand All @@ -102,7 +104,8 @@ internal class ToolExecutor(
calls.starting(call.id)
calls.running(call.id)
val delegating = calls as? DelegatingTurn
val context = AgentToolContext(turn, call.id, workspaces, admission.clearance, taint, delegating)
val context =
AgentToolContext(turn, call.id, workspaces, admission.clearance, taint, trigger, delegating)
val result = if (tool is InTurnTool) {
inTurn(turn, call, tool, arguments, context)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,16 @@ internal class AgentTurnRunner(
launch {
slots.withPermit {
val end =
executor.execute(turn, call, tools.offered, timeout, tools.callable, taint, tracked)
executor.execute(
turn,
call,
tools.offered,
timeout,
tools.callable,
taint,
tracked,
plan.trigger,
)
reads.reason(turn.agent, call.name, end.touched)?.let { taint.mark(it) }
taint.record()
val found = projects.inspect(turn, end.touched, budget)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ class JobsTest {
): ToolCallEnd {
val executor = ToolExecutor(AdmittingGate, settings("{}"), Workspaces(hostPaths(null), settings("{}")), this)
val name = tool.definition.name
return executor.execute(turn, call, mapOf(name to tool), timeout, setOf(name), taint, DirectCalls)
return executor.execute(turn, call, mapOf(name to tool), timeout, setOf(name), taint, DirectCalls, null)
}

private suspend fun recorded(): Set<RunId> = JobRecords(records.of("alexandrite-agent")).all().keys
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ class PermissionStatusToolTest {
}

private fun context(turn: TurnInfo, taint: TurnTaint): ToolContext =
AgentToolContext(turn, ToolCallId("call-1"), Workspaces(hostPaths(null), settings("{}")), null, taint)
AgentToolContext(turn, ToolCallId("call-1"), Workspaces(hostPaths(null), settings("{}")), null, taint, null)

@Test
fun `the call is of activity other, titled by nothing`() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,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
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -11,4 +12,4 @@ Quoting: unknown (verified)
> Operator: yes
Via: helper bot
Edited: yes
[/alexandrite:message]
[/alexandrite:message]
16 changes: 16 additions & 0 deletions libraries/channels/onebot/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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"))
testRuntimeOnly(libs.logback.classic)
}
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading