Skip to content

libraries: Add the OneBot v11 channel - #1

Open
Yos-X wants to merge 37 commits into
masterfrom
feature/channel-onebot
Open

Yos-X wants to merge 37 commits into
masterfrom
feature/channel-onebot

Conversation

@Yos-X

@Yos-X Yos-X commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

OneBot v11 的客户端 Channel,位于 libraries/channels/onebot,按协议域、传输、消息映射、渠道与实例级 API、agent 集成推进。

协议域不涉及传输,也不引库。含 21 种消息段(含实现方上报的 file 段),认不出的段保留原始 data;三种消息形态;CQ 编解码;四类事件,认不出的保留原始 JSON;38 个公开动作与隐藏动作,_async 与 _rate_limited 由后缀派生;结果分层。

四种通信各自独立,每实例选一种。http 的对测覆盖成功、拒绝、连不上、鉴权失败、畸形应答,另有 token 不泄漏的断言;http_post 覆盖验签、路径、账号绑定与快速操作回写;ws 按 echo 关联读回应答并读事件;ws_reverse 由对端拨号接入,并经该连接完成一次调用。另有一个运行于真实 runtime 的集成测试,覆盖实现上报消息至回复经 send_private_msg 发出。

消息映射把上报的消息、引用、媒体与未寻址的群消息分开处理:群消息需 @ 本实例或引用本实例的消息才交给 agent,命令按 / 前缀识别并查管理员门禁;每个聊天需先配对,未配对的聊天只放行配对码。批准请求以回复该条消息的方式应答,/approve 与 /deny 走命令路径。非消息上报经 ChannelNotice 与 ChannelPoints.NOTICE 发布,订阅方贡献 ObserverHook 即可收到,未配对的聊天照常发布。工具面是一张声明表加两个工具(onebot.read 只读、onebot.act 改动),共 22 个动作,加动作即加一行;凭据类、raw、快速操作、重启与清缓存不在表内,无法由模型提出。文件参数经 SDK 的 HardFloor 判定路径,与 fs 工具同一套判决;超过单次请求可携带的文件走实现方的分块流上传。回合进行中的动作经渠道事件上报到聊天:第一步在开始时以现在时发出,已结束的步骤按 8 秒限流合并为过去时的一条消息,措辞与 telegram 渠道同一套键值,可由实例配置项 progress 关闭。回合进行中模型自己说的话经 preview 上报,同样受该间隔限流,每条只含未说过的部分;只在回合已经开始使用工具之后才发,因为在那之前的话就是紧随其后的回答。两个开关分别控制:progress 管动作,streaming 管模型的话。大文件上传声明了 5 分钟超时,文件读取与写入在 IO 调度器上进行。

对渠道之外模块的改动:plugin-sdk 增加 ToolContext.trigger(带默认值,实现者不必改)与 ChannelNotice、ChannelPoints.NOTICE;agent 的框架标明消息 id 与引用 id,并把 trigger 交给工具;provider-common 在渠道只报了图片种类时按字节判断格式。依赖方面引入 org.java-websocket:Java-WebSocket:1.6.0,仅出现在插件模块与版本目录,唯一传递依赖为 slf4j-api。

以下边界本 PR 不处理:

  • preview 为空实现。OneBot 没有改写已发消息的能力,streaming 为 false;回合进行中的进展改由限流后的新消息呈现,每个动作只有开始态被订阅。
  • 反向 ws 无法在握手期返回 401。Java-WebSocket 1.6.0 的 WebSocketServer 没有可覆写的手握钩子,只能在 onOpen 校验后按 1008 关闭。
  • CommandRules 是 telegram 那份的副本,形状正确而落点待定,应随 telegram 那份一起上移 channel-common。
  • ReplySink.abandon 为空实现,是撤回本实例回复的天然位置。
  • 本实例已发消息的 id 未随记录持久化,跨渠道的“我发过什么消息”仍无来源。
  • 群文件覆盖上传、列出与删除;移动、改名、转发与文件夹的增删不在本 PR。
  • message.v1 这一格式名是否视为冻结契约尚未确定,本 PR 原地修改了 v1 的内容。

验证命令为 ./gradlew :libraries:channels:onebot:spotlessCheck :libraries:channels:onebot:test :app:installDist,插件模块 214 个测试通过,spotless 与 allWarningsAsErrors 通过。最近一次 CI 运行报出两处失败,已在本分支修复:testkit 的样本未列出 ToolContext 新增的 trigger 声明;Telegram 的批准引用测试未跟上框架中消息 id 与引用 id 的变化。

Windows 上的失败与本 PR 无关,分两类,均由 fix/platform 分支(PR #3)处理:一类是断言写死 POSIX 的路径、换行与错误文案;另一类是上游 e7747d8 把 Path.of("/dev") 写入默认保护位置清单,Windows 上该路径为无盘符的 \dev,PathCanonicalizer 无法处理,AgentFloor 构造失败导致 runtime 无法启动,同一缺陷另使 writeAtomically 在无 POSIX 权限的宿主上无法建文件。

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Transport result handling, HTTP POST operation, temporary-session routing, and receiver resource limits contain correctness and security defects.

12 open findings
What changed in this PR

Adds a OneBot v11 channel with protocol models, authenticated HTTP/WebSocket transports, runtime mapping, typed APIs, tests, and app integration.

Changes:

  • Implements OneBot messages, events, actions, results, and authentication.
  • Adds HTTP, HTTP POST, forward WebSocket, and reverse WebSocket communication.
  • Integrates the channel into the runtime and example configuration with broad tests.
File Description
app/​build.gradle.kts Adds the OneBot module.
app/​src/​dist/​config/​alexandrite.example.json Adds example OneBot configuration.
gradle/​libs.versions.toml Registers Java-WebSocket.
libraries/​channels/​onebot/​build.gradle.kts Configures module dependencies.
OneBotPlugin.kt Declares the plugin.
api/​OneBotApi.kt Defines the public typed API.
api/​OneBotApiImpl.kt Implements API dispatch and decoding.
auth/​OneBotAuth.kt Implements tokens, signatures, and redaction.
channel/​OneBotChannel.kt Implements channel delivery.
channel/​OneBotInbox.kt Submits incoming messages.
channel/​OneBotLink.kt Selects and manages transports.
config/​OneBotConfig.kt Defines plugin and instance configuration.
mapping/​OneBotMessages.kt Maps SDK and OneBot messages.
protocol/​OneBotIds.kt Defines typed identifiers.
protocol/​OneBotStatus.kt Defines statuses and return codes.
protocol/​api/​OneBotAction.kt Models actions and suffixes.
protocol/​api/​OneBotApiTypes.kt Defines typed response payloads.
protocol/​api/​OneBotRegistry.kt Registers standard actions.
protocol/​api/​OneBotRequests.kt Defines request payloads.
protocol/​event/​OneBotEvent.kt Models OneBot events.
protocol/​event/​OneBotEventCodec.kt Decodes event objects.
protocol/​message/​CqCode.kt Implements CQ encoding.
protocol/​message/​OneBotMessage.kt Models message shapes.
protocol/​message/​OneBotSegment.kt Models message segments.
protocol/​message/​OneBotSegmentCodec.kt Encodes and decodes segments.
protocol/​result/​OneBotResult.kt Models layered call results.
transport/​Connection.kt Provides shared connection state.
transport/​OneBotForwardWebSocket.kt Implements forward WebSocket transport.
transport/​OneBotHttpApi.kt Implements HTTP API calls.
transport/​OneBotHttpPostReceiver.kt Receives HTTP event reports.
transport/​OneBotReverseWebSocket.kt Implements reverse WebSocket transport.
transport/​OneBotSettings.kt Builds transport settings.
transport/​OneBotWire.kt Configures wire JSON handling.
OneBotChannelIntegrationTest.kt Tests runtime message round trips.
auth/​OneBotAuthTest.kt Tests authentication behavior.
channel/​OneBotHttpTransportTest.kt Tests HTTP calls and failures.
mapping/​OneBotMessagesTest.kt Tests SDK message mapping.
protocol/​api/​OneBotRegistryTest.kt Tests action registration.
protocol/​event/​OneBotEventCodecTest.kt Tests event decoding.
protocol/​message/​CqCodeTest.kt Tests CQ and segment codecs.
transport/​OneBotHttpPostReceiverTest.kt Tests report reception.
transport/​WsTransportTest.kt Tests both WebSocket modes.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@Yos-X

Yos-X commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

已按评审处理,逐条说明对应提交。

  • X-Self-ID 大小写:请求头在读 head 时已折成小写,查询却用标准拼写,因此该头从未被读到——已修复 6469a4c。原先那条"缺少账号则拒绝"的用例是靠这个缺陷才通过的,现改为报文不带账号,另加一条只靠请求头命名账号的用例。
  • 通过 socket 返回的应答按应答处理:已修复 6469a4c。正向与反向 WebSocket 的应答与 HTTP 应答走同一套状态分类。
  • echo 计数器非原子:已修复 6469a4c。
  • 声明长度超限的报文不返回状态:已修复 f5ac753。分配前比较上限,并返回 413;声明的那点字节以 8 KiB 为块读出丢弃,使仍在发送的客户端能读到该响应。
  • 临时会话的回复发到群:已修复 951cbaf。新增 OneBotMessages.privateTemporary,action 与 params 都先判断临时会话。
  • autoEscape 无法保留 CQ 原文:已修复 951cbaf。消息按写入时的形状发送,auto_escape 只由"整条消息是一段文本"这一种形状请求。
  • 应答的 data 按对象读取,返回数组的动作对不上类型:已修复 371ffb5。data 按原形状保留,Ok 的类型参数放宽为 JsonElement,补了数组与对象两条用例。
  • 未知 message_type 被写入 postType:已修复 371ffb5。
  • 两个配置项可配置但未被消费:已修复 371ffb5,采用删除。
  • 事件溢出丢弃没有计数:已修复 371ffb5。trySend 在两种丢弃策略下都返回成功,因此计数由"已报告减已读减容量"得出。
  • 反向连接关闭时挂起的调用不结束:已修复 ee04a72。

未处理的最后一条:http_post 实例发出的动作一律 unreachable。这一条不是实现缺陷,而是该传输本身的形状。标准里的 http_post 是事件推送通道:实现方作为 HTTP 客户端把事件 POST 到本实例,本实例应答后连接即结束,因此没有可供回程的连接,本实例也没有指向实现方的地址。发出调用需要指向实现方的连接,而这条传输不提供,标准也没有要求它提供。标准另在 api 段规定了实现方自己的 HTTP 服务端地址,所以需要回程时应当配置一个 http 或 ws 实例,与 http_post 实例并存,共用同一账号。

因此这里有一个选择,属于配置面的决定:保持现状,用一个 http_post 实例收事件、另一个 http 或 ws 实例发调用;或者为 http_post 增加一个可选 endpoint,配了就同时建立 HTTP API 客户端,一个实例完成收发。该配置项尚未添加。

验证::libraries:channels:onebot:test 通过,82 个测试。

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Transport error handling, reverse-WebSocket isolation, event accounting, serialization, and configuration contain unresolved correctness and security issues.

5 open findings
12 resolved since last review
Previously missed (10)

In code that hasn't changed since last review

Medium severity Convert WebSocket transport failures to Unreachable results

libraries/​channels/​onebot/​src/​main/​kotlin/​org/​foedusprogramme/​alexandrite/​channel/​onebot/​channel/​OneBotLink.kt:79

WebSocket transport failures escape from this method instead of becoming OneBotResult.Unreachable. A disconnected socket throws IllegalStateException, and the per-call timeout throws TimeoutCancellationException, so both Channel.send and the public typed API can throw while the HTTP path returns a result. Convert transport-owned timeout/connection failures here while still rethrowing caller cancellation.

Medium severity Validate numeric reply IDs before constructing MessageId

libraries/​channels/​onebot/​src/​main/​kotlin/​org/​foedusprogramme/​alexandrite/​channel/​onebot/​mapping/​OneBotMessages.kt:121

This blindly constructs a numeric MessageId, but this same mapper creates delivered refs named sent and async when no platform ID is available. Reusing either delivered ref as replyTo therefore throws before sending, rather than returning a delivery failure. Validate that the target is a numeric OneBot message ID and handle unavailable/synthetic refs explicitly.

Medium severity Preserve retryability when mapping HTTP failures

libraries/​channels/​onebot/​src/​main/​kotlin/​org/​foedusprogramme/​alexandrite/​channel/​onebot/​mapping/​OneBotMessages.kt:176

The mapping discards OneBotFailure.retryable for HTTP failures. In particular, an HTTP 5xx result is retryable by OneBotFailure, but it reaches Delivery.NotDelivered as non-retryable UNKNOWN, preventing normal retries. Pass the failure's retryability through (and classify retryable HTTP status failures as transient if appropriate).

Medium severity Do not use member card as group chat title

libraries/​channels/​onebot/​src/​main/​kotlin/​org/​foedusprogramme/​alexandrite/​channel/​onebot/​mapping/​OneBotMessages.kt:224

A group member's card is the sender's display name, not the group's title. Putting it into ChatInfo.title mislabels every group chat with whichever member spoke most recently. The event has no standard group-name field, so leave the title null unless it is obtained separately.

Medium severity Serialize typed request IDs using numeric ID serializers

libraries/​channels/​onebot/​src/​main/​kotlin/​org/​foedusprogramme/​alexandrite/​channel/​onebot/​protocol/​api/​OneBotRequests.kt:32

All typed request IDs are written as JSON strings here and in the remaining request classes, bypassing the ID serializers that deliberately emit a number when it fits (OneBotIds.kt:109-112). OneBot declares these parameters as numeric, so strict WS/JSON implementations can reject otherwise valid typed calls. Encode UserId, GroupId, and MessageId through their serializers (falling back to strings only when outside Long).

Medium severity Reject non-object JSON report elements

libraries/​channels/​onebot/​src/​main/​kotlin/​org/​foedusprogramme/​alexandrite/​channel/​onebot/​protocol/​event/​OneBotEventCodec.kt:26

A valid JSON scalar or array is converted into a synthetic Unknown event and therefore accepted by the HTTP receiver as a report, even though events must be objects; its original payload is also lost. Reject non-object elements (and have each transport classify/ignore that decode failure) instead of fabricating an event with zero IDs and empty raw JSON.

Medium severity Preserve GroupBan subType during decoding

libraries/​channels/​onebot/​src/​main/​kotlin/​org/​foedusprogramme/​alexandrite/​channel/​onebot/​protocol/​event/​OneBotEventCodec.kt:123

The codec computes subType but does not pass it to GroupBan, so both ban and lift_ban events expose subType == null despite the public model having that property. Preserve the wire value as the other notice decoders do.

Medium severity Omit null optional segment parameters from JSON

libraries/​channels/​onebot/​src/​main/​kotlin/​org/​foedusprogramme/​alexandrite/​channel/​onebot/​protocol/​message/​OneBotSegmentCodec.kt:79

Optional segment parameters are emitted as explicit JSON null values. explicitNulls = false does not remove JsonNull already inserted into a manually built object, so a simple Image(file = ...) sends null type, url, flags, and timeout instead of omitting optional fields as required by the request contract. Add each optional key only when non-null; the same pattern recurs in the record, video, poke, share, location, music, and node branches.

Medium severity Do not enqueue rejected reports as accepted

libraries/​channels/​onebot/​src/​main/​kotlin/​org/​foedusprogramme/​alexandrite/​channel/​onebot/​transport/​OneBotHttpPostReceiver.kt:171

Rejected reports have already been enqueued and counted as accepted before the callback's decision. This contradicts Rejected's contract and lets downstream processing act on a request answered with 4xx. Decide first, and only report/increment the accepted and quick-operation branches.

Medium severity Use plain text content type for rejection details

libraries/​channels/​onebot/​src/​main/​kotlin/​org/​foedusprogramme/​alexandrite/​channel/​onebot/​transport/​OneBotHttpPostReceiver.kt:195

Rejection bodies are plain text, but this labels every non-204 body as JSON. HTTP clients that honor the media type will try to parse text such as the signature... as JSON and fail to expose the actual reason. Use text/plain; charset=utf-8 for detail, retaining JSON only for quick-operation objects.

🧠 Review effort: Balanced

@Yos-X

Yos-X commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

第二轮评审的处理结果。

按线程给出的 5 条发现,均已在 f1367d5 修复:接收器的读取线程改为有界线程池与排队位、超限以 503 拒绝,整请求另加截止时间(头部与正文的读取循环都检查);反向监听器只读取活动对端的帧,且只在关闭活动对端时才清理状态并结束等待中的调用;丢弃数改为在队列报告"从未交付"时递增;那条请求头用例改为正文不命名账号,使通过只能来自请求头路径。

正文中另一节"Previously missed"的 10 条也已逐条核实,全部修复:

  • 拒绝的报文先入队并计为已接受 —— b7b7346。决策先取,只有被接受或需要快速操作的报文才报告与计数。
  • 拒绝原因以 application/json 返回 —— b7b7346。拒绝为 text/plain; charset=utf-8,快速操作仍为 JSON。
  • GroupBan 丢失 sub_type —— b7b7346。
  • socket 调用抛出而非返回结果 —— b7b7346。连接断开与调用超时都转为 OneBotResult.Unreachable,调用方主动取消仍原样抛出。
  • 失败映射丢弃可重试性 —— b7b7346。先取传输给出的 retryable,5xx 因此成为可重试。
  • 可选段参数写成 JSON null —— b7b7346。仅在存在时写入。
  • 用成员名片作为群标题 —— 771f66d。消息不携带群名,标题保持为 null。
  • 非对象 JSON 被合成为事件 —— 771f66d。改为拒绝,传输层按无法读取的报文处理。
  • 回复目标直接构造数字 MessageId —— 3d 系列提交。合成引用 sent、async 不指向实现的任何消息,遇到时省略回复段并正常发出,而不是在发送前抛出。
  • 类型化请求的 id 以字符串写出 —— 同一提交。UserId、GroupId、MessageId 均经数字序列化器写出,超出 Long 时才回落为字符串。

验证::libraries:channels:onebot:test 通过,85 个测试。

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved configuration, protocol, security, and WebSocket lifecycle defects can cause startup failure, data corruption, denial of service, and delayed calls.

3 open findings
5 resolved since last review
Previously missed (4)

In code that hasn't changed since last review

Medium severity Redact all non-empty configured secrets

libraries/​channels/​onebot/​src/​main/​kotlin/​org/​foedusprogramme/​alexandrite/​channel/​onebot/​auth/​OneBotAuth.kt:85

Values shorter than four characters are deliberately left unmasked, although access tokens and signing secrets have no minimum length. A non-2xx implementation response can echo such a token into OneBotFailure.message, violating this API's no-secret guarantee. Redact every non-empty configured value instead.

Medium severity Avoid using WebSocket echo as message ID

libraries/​channels/​onebot/​src/​main/​kotlin/​org/​foedusprogramme/​alexandrite/​channel/​onebot/​mapping/​OneBotMessages.kt:167

A socket response always carries this client's echo, but that value is only a correlation key, not the platform's message ID; toString() also includes JSON quotes for a string echo. When a successful send omits message_id, this therefore exposes a fabricated quoted ID instead of the documented sent fallback. Do not use echo as a message reference.

Medium severity Separate CQ text escaping from parameter escaping

libraries/​channels/​onebot/​src/​main/​kotlin/​org/​foedusprogramme/​alexandrite/​channel/​onebot/​protocol/​message/​CqCode.kt:174

CQ escaping treats plain text and parameter values differently: commas are escaped only inside parameter values. Because this shared function also encodes Text, ordinary text commas become non-standard ,; the matching decoder also corrupts a literal , received in plain text. Split text escaping/unescaping from parameter escaping/unescaping.

Medium severity Fail pending calls when closing the forward WebSocket

libraries/​channels/​onebot/​src/​main/​kotlin/​org/​foedusprogramme/​alexandrite/​channel/​onebot/​transport/​OneBotForwardWebSocket.kt:72

Closing a forward WebSocket does not complete or clear client.awaiting. Cancelling the connection loop can prevent its post-onClose failPending call, so callers can remain suspended until firstByteTimeoutMillis after close() has returned. Atomically stop new sends and fail all pending deferreds before cancelling the scope.

🧠 Review effort: Balanced

@Yos-X

Yos-X commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator Author

第三轮评审的处理结果,全部已在 a4c5c32 修复。

按线程给出的 3 条:

  • 超限报文的排空路径绕过了整请求截止时间,未认证的发送方可以逐字节慢发占满读取线程 —— 已修复。drain 接收并检查同一个截止时间。
  • 新对端接管时,旧 socket 上等待中的调用被留在映射里,而其帧已不再读取,因此必然等到超时 —— 已修复。先结束这些调用并关闭旧 socket,再设为活动对端。
  • 该用例的发送时长(2 秒)短于截止时间(5 秒),因此没有截止时间也能通过 —— 已修复。改为发送 120 次共 12 秒,断言上界收紧到 8 秒。

正文中"Previously missed"的 4 条:

  • 短于四个字符的配置值未被脱敏,实现方回显该 token 时会进入失败信息 —— 已修复。凡非空值一律替换,token 与签名密钥没有最小长度。
  • 投递把 socket 应答的 echo 当作消息 id,而它只是本客户端用于配对的关联键,toString() 还会带上 JSON 引号 —— 已修复。应答未给出消息 id 时回落到 sent。
  • 正文与参数值共用一套转义,而逗号只在参数内表示结束,导致普通正文的逗号被写成 ,,正文里字面的 , 又被读成逗号 —— 已修复。两者分开转义与反转义,原先断言旧行为的两条用例改为断言新行为。
  • 关闭正向 WebSocket 时,取消作用域可能先于关闭回调结束等待中的调用,close() 返回后调用方仍会挂起到超时 —— 已修复。等待中的调用先结束。

验证::libraries:channels:onebot:test 通过,85 个测试。20 条评审线程全部解决。

Yos-X added 18 commits October 12, 2026 19:12
The OneBot v11 standard, in Kotlin types and nothing else: the ids and statuses, the twenty message segment types with the unknown ones kept whole, a message in each of the three shapes the API takes, the CQ code format, the events of the four kinds plus an unknown one that keeps its JSON, the 38 public actions with the hidden one and the derived async and rate limited calls, and the layered result model. No transport and no library is involved, so the domain is testable on its own.

The module is discovered by AlexandriteLayout as channels.onebot, which gives it the built-in KSP index, the config root channels.onebot and the plugin id alexandrite-channel-onebot.
Four things a review of the standard found missing, each with the test that
would have said so:

- The HTTP transport sent its token only in the `Authorization` header,
  while the standard also allows `?access_token=`, and an implementation
  that reads only the query refused every call. It sends both now.
- An instance that serves one account accepted a reverse connection and an
  HTTP report that named no account at all, because only a value that was
  both present and different was refused. The account is what binds a
  connection to an instance, so a missing one is refused like a wrong one.
  The body of an HTTP report is read before that, so a report that is no
  event is still told apart from one of another account.
- The reverse listener read no `X-Client-Role`, so an `API` client, which
  sends no events, and an `Event` client, which answers no call, were taken
  as if they carried both. The roles that carry both are served, the two
  that do not are closed with their reason, and an implementation that names
  no role keeps working, since older ones report none.
- A port of 0 was documented as the operating system's choice and refused by
  the configuration, so the two now agree.

Verified: :libraries:channels:onebot:test passes, 74 tests.
In: a private chat is private:<user id>, a group is group:<group id>, a temporary session keeps the group as its chat and the sender as its thread, media becomes attachments, a reply becomes a quote, and a segment this version has no shape for becomes a display fact rather than being dropped. Out: plain text, Markdown images as image segments, and a reply leading with the reply segment. A failure of the implementation becomes a delivery failure the SDK knows.
The channel speaks the contract: it maps what it was told into incoming messages and what the agent answers back into segments, and it offers the whole action surface of the standard to other plugins as an instance API. The app loads it and the example config names it.
An answer is told from an event by the echo it carries, and the shape of its data is kept rather than flattened. A call that is still waiting fails when the connection that would answer it goes, instead of waiting for a socket that may never answer it, and a temporary session is answered in the shape it was asked in.
A report that is too large is answered with the status that says so rather than read out, the listener runs a bounded number of reports at once and refuses the rest, each peer is kept to itself, and the queue counts what it drops.
Ids are written as the numbers the standard asks for, a group is named by its id rather than by one of its members, and a scalar is not taken for an event. A refusal is answered as text, a secret is redacted wherever an answer is logged, and a message the agent refused is reported with the reason it gave.
…essed

A report carried into a turn whatever it said, so every message of a group was
answered by the agent. The channel now tells the two apart.

A command starts a message and is named after a `/`, which is the prefix every
channel of this project spells one with. It is submitted as a command rather
than as a message, so the handlers of the runtime see it and the arguments it
carries. A mention before it leaves the command alone, because the text of a
report is its text segments and the mention is a segment of its own.

Everything else becomes a turn only when the bot is addressed: a direct chat
always, a group when the message names the bot or answers one of its messages.
What a message answers is reported as an id and not as an author, so the ids of
the messages this instance sent are kept and a reply is matched against them.

A message the agent refused is reported with the reason, which a chat that no
agent serves is refused for, and which nothing else says.
The channel had left the contract answer that says it cannot ask for approvals, so every call only a person may allow was refused by policy. An approval is shown in the chat of an admin's own turn, or in the direct chats of the admins, and an admin answers it by replying to that message with /approve or /deny, or by writing the id of the request; an id that names several requests is refused and listed rather than guessed at. Its texts come from the plugin's language files in the language of the chat, and an answer that was taken is said once.
A report names the message it answers and says nothing about it, so a quote reached the agent as a name it could not read. What the channel read as it arrives is kept and a quote is answered from it, and only a message older than what is kept is read back from the implementation through get_msg. A card is named by what it says, such as the title and singer of a song, so a quoted card is not a name without content.
Nothing asked. The agent's control takes the operator of a change as a
parameter and does not judge it, so anyone who could write in a chat that
the bot listens to could set the model of that chat, its language, and the
agent it speaks to.

The rules the Telegram channel applies are applied here as well: a command
a handler claims that the table does not list is an admin's alone, settings
is anyone's, model is anyone's without arguments and an admin's with them,
and the rest are for a direct chat. A refusal is said in the chat the
command was written in, because the agent never sees the command at all.

The rules are a copy of the Telegram channel's rather than the same code:
they belong with the rest of what every channel shares, and a copy is what
can be made without deciding that shape for both channels at once.
A bot that was invited into a group answered everything it was addressed
with, and ran the commands of anyone who could write there. No chat had to
be approved, because this channel had no pairing of its own: the runtime
offers every channel a pairing desk, and this one was never asked for.

The pairing of the shared channel code now covers the chats of every OneBot
instance. A chat is served when the config declares it in `allowedChats`, or
when the host approves the code the chat was offered, which is what the
`alexandrite pair` command of the app takes. A chat that is neither is
offered a code when it addresses the bot, and nothing else of it passes:
no turn, no command, and nothing the agent starts itself, since the channel
now reports itself unable to speak unprompted in a chat it does not serve.

The warning the gate writes when an agent's home chat or a linked chat is
not served is the one the Telegram channel writes, down to the config path
it names.
An attachment a report named was handed to the agent as a read that threw,
so every picture and every voice message reached a turn as a file that could
not be loaded: the model was told a message carried something and never what.

The bytes are now read from the address the report names, and a report that
names only what the implementation keeps the file as has that turned into an
address first, through `get_image` for a picture and `get_record` for a
voice message. A read stops at the size the agent allows: a body longer than
that is refused without reading it out, and one whose length says it is too
long is refused before it is read at all.

What still cannot be read is named as before and throws as before, which is
what tells the agent to say the file could not be loaded rather than to
forget that the message carried one.
A report of a membership change, a recall or a request was read and dropped, so no plugin could see one of them. The SDK names such a report as a ChannelNotice and the point a channel fires, which is the hook mechanism the agent's turns already use: a subscriber contributes an ObserverHook and the runtime needs nothing new. onebot maps every report that is not a message to one and publishes it, whether or not the chat is served, and keeps the name the implementation gave to an event the standard does not have.
The channel could report and answer, and the whole action surface of the
standard was offered to other plugins as an instance API, but nothing called
it: the API belongs to one channel instance and a tool is one object for the
whole runtime, so a tool had no way to reach the API of the chat its turn is
in.

An instance is now served from the registry while it runs, which is where a
tool reads the API of the chat of its turn. The registry is written by the
onebot plugin itself, so nothing about it reaches the SDK or the agent: a
tool of this plugin calls its own API, and the agent learns of the tool the
way it learns of any other.

Three tools act on a chat: taking a message back, adding an emoji response,
and answering a friend request or a request to join a group, which is what a
turn that was told about one can now do something with. Each names the risk
of the action, so the permission layer gates them like any other call.
…about

A tool that acts on what a person just wrote needs the id of that message,
and a prompt names no id: the framing gives the sender, the time, the chat
and what was quoted, but never the id of the message it answers. A tool that
demanded one could therefore only be answered by a person who went and found
it, which is what the onebot tools for taking a message back and adding an
emoji response asked for.

The context of a call now carries the message its turn was started by. The
agent already held it, as the same value its reply is threaded onto, so
nothing new is derived. A turn that no message started, such as a heartbeat,
carries none, and a tool that needs one then says so as before. The field has
a default, so a channel or plugin that implements the context keeps
compiling, and every channel gets it without doing anything.

The tools of a channel also stop marking every argument required. A schema
that says an argument must be given stops a caller from leaving it out, which
is the opposite of what a tool that can work without one needs: the id of a
message is now optional and says in its description what it defaults to, and
the arguments a call cannot do without say that they are required and are
checked where they are read. This is how the other tools of this project
declare theirs.
A prompt said who wrote a message, when, where, and what it answered, but
never the id of the message itself. So a person could not be told the id of
what they sent, and a tool that acts on a message could only be given one by
somebody who went and found it: the framing did not carry the one thing such
a call needs.

Message framing now names the id of the message, and the id of the message a
message answers, which is reported as an id and nothing else. Both are
escaped like every other name the framing shows. The ids are what the
channel reports, so nothing is derived and no channel does anything: every
channel that reports a message gets both.

The golden files of the format are updated with the line, and the two of
them are written with the line endings the format is compared by.
…tool

Every action of the platform that the agent may be asked for is one row of a table now: what it is for, which arguments it takes, what they mean and whether it changes something. Two tools read that table, one for the actions that only read and one for the actions that change, so an action is added by adding a row rather than by writing a tool.

The actions that hand out the account's credentials, that restart the implementation and that carry out anything at all by name are therefore not reachable, since a call can only name a row. A call cannot put an argument of its own into a request either: only the arguments a row declares are sent.

Three tools that each served one action are gone, their actions are rows now, and an action about a message still means the message of the turn where the row allows the argument to be left out.
Yos-X added 17 commits October 12, 2026 20:34
…reach

The table is the only thing between a call and the platform, so what it holds is asserted rather than trusted: every action it holds is one the standard declares, unless the row says the standard does not have it, and no action is declared twice.

The actions that must not be reachable are named in the test that they are not, which is what keeps them out: nothing of the account, nothing that carries out anything by name, nothing that restarts the implementation. An action about a message is asserted to let its id be left out, and every argument of every action is asserted to be named and explained.
…annot be sent

An argument that names a message, a group, a member or a length is one the standard writes as a number, and a model writes every argument as text. A call that gave one was sent as text while one left to the message of the turn was sent as a number, so the same argument reached the platform in two forms. An argument the table says is numeric is written as a number either way.

An action about a message takes the message of this turn when a call named none. A call that named none on a turn no message started was sent without the argument at all, which the platform answers in a way that says nothing about what the call got wrong. It is refused now, naming the argument and what it is for.

What a call sends is built by a function of the row, the arguments and the message of the turn, which is where the tests assert it. They say that the arguments a row declares are the ones sent, so nothing a call writes can reach the platform by a name the action does not take, and that an argument left out where the row does not allow it is refused rather than sent as nothing.
…ns that read and change

An argument that asks whether to do something was sent as the text a call wrote, and the same was true of every other argument. A row now says how each of its arguments is written: a name, a title or a reason stays the text it was, a message, a group, a member or a length is the number it names, and a question is the yes or no it asks. What a call writes as yes, no, true, false, 1 or 0 is read as the flag it means, and anything else is refused, naming the argument and what it is for, rather than sent as something the platform would answer without saying what the call got wrong.

The table holds twenty-two actions instead of nine. What it reads: a message, a forwarded message, an account, a group, its members, its honors. What it changes: taking a message back, an emoji response, a like, a friend or a join request answered, a member kicked, muted, made an administrator or renamed in the group, a group muted whole, renamed or left, and a title given. What it keeps out of reach is unchanged: nothing of the account's credentials, nothing that carries out anything by name, nothing that restarts the implementation.

The arguments of every row are the names the platform's own requests are written with, read from them rather than guessed, and the tests say that the arguments a row declares are the ones sent, so nothing a call writes can reach the platform by a name an action does not take.
A call that only reads was answered with the sentence that the action was carried out, and the platform's answer was dropped. A read of a message therefore told the model that the message was read and nothing else, which is what a turn about a picture in a quoted message got: the answer held the message, and the model saw none of it.

A read is answered with the platform's answer now, cut where it is longer than a turn should carry and saying that it was cut, and an answer that holds nothing says so. A call that changes something keeps the sentence that says what it did, since the platform's answer to it holds nothing the model can use.

What a read answers is what other people wrote, so a turn that carries it is marked as one that carries content this bot cannot vouch for, the way any other content read from outside is marked.

This corrects what was said when the actions were first declared: the reads did not answer with the platform's data then, whatever the note said.
A report answers a message and says nothing about it, so what the channel read of each message is kept: its text and, from now, what it was reported to hold as well. Only the text was kept, so a picture inside a quoted message reached the model as the marker for a picture and no picture, and a question about that picture could not be answered however the model reads pictures. That is what a quoted picture and a question about it got.

What the quoted message held is attached to the turn beside the media of the message itself, read the same way, so a reader that can read the picture is asked for it. What cannot be read still leaves its marker in the quoted text, which says what it was.

The test reports a picture and then a message that answers it and names the bot, and says that the turn carries the picture and its bytes.
The standard sends a file through the actions for files rather than in a message, and an implementation that offers the extension carries one as a segment of its own. A report carrying that segment was kept as something unknown, so what it named was never read, and the channel had no way to write one.

The segment is modelled now, read and written like the others: what the implementation knows the file by, what it is called and where it can be read. The compiler named the two places that had to be told as well, which is what a sealed set of segments is for. What an implementation says of a file beyond that is left behind, as it is for the other segments this version models, and a type that stays unknown is still kept whole.

Reading a file's bytes out of a chat, and uploading a file of the workspace, are not part of this. The actions for them need a reading of the workspace that this plugin cannot yet be given: the floor that decides what may be read is bound by no plugin of this one, so asking for it stops the channel from starting at all, which eighteen of its tests said.
The actions for files were declared but a call could not reach one: the platform is usually on another machine, where a path of this one means nothing, so what such a call carries is the bytes rather than the path, and what may be read is not this plugin's to decide.

A row now says that an argument is a file of the workspace, and the tool reads it as the bytes the platform takes, written the way it takes them. What may be read is asked of the floor the tools for files ask: a path it denies is refused here with the reason it gave, and one it allows is read at the path it names. A floor is asked for as one of the things a runtime may have rather than one it must, the way this channel already asks for an agent: a runtime that decides nothing about what may be read refuses a call that needs a file instead of reading it of its own accord, and the channel still starts without one.

A file larger than a call should carry is refused, naming the limit, since the bytes grow on the way and an implementation refuses what is too large without saying what the call got wrong.

The distinction between a file of the workspace and a name the platform knows one by is asserted, since reading a file of the platform takes its name rather than a path.
The framing names the id of the message a turn answers. Three tests of the first answer build the entry they expect in the test rather than from a golden file, so they went on expecting the framing without that line, and failed from the change that added it.

That change updated the two golden files and missed these three, which is what the count of failing tests in this suite said: three of one class on top of the ones this platform already fails.
…l named only the kind

A channel that cannot say what format its bytes are says the kind alone, and that was sent as the type of the image: a request carried data:image/*;base64,... and the endpoint refused it, which fails the whole request and so every turn of a conversation that holds the image, since the attachment is part of its history.

The bytes are there when the request is built, so the format is read from them where the channel named only the kind: png, jpeg, gif and webp, the four the endpoints take. A channel that names a format is still taken at its word.

An image that is in none of those formats is left out and said to be, the way a kind the model cannot take already was: sending it as a type the endpoint refuses fails the turn that carries it, and there is nothing the model could do with it either way.
…es one

A call that uploads a file names a path, and that path was handed to the floor as it was written. The floor is told of an absolute path, so a call naming one relative to the workspace failed with 'A path to canonicalize is absolute and of the default file system', and the file was never sent.

A path is resolved the way the tools for files resolve one: one that is absolute as it is, one under the home directory where it begins with a tilde, and a relative one against the workspace, refused where the call has none. What a call may name is then what a tool for files may name, and the floor is asked about the path that comes of it, which is where what may be read is decided.

The tests say that a relative path is resolved against the workspace and its bytes are carried, and that one with no workspace is refused rather than resolved against something else.
An implementation of NapCat's stream API takes a file in pieces and names it in answer, which is how a file larger than one request is sent at all. Until now a call refused anything larger than the eight mebibytes a request carries, so a file of any size beyond that could not be sent.

The file is read twice and never held whole: once for its size and the digest that every piece is sent with, and once to send the pieces themselves, each of 256 KiB, ending with the call that says the file is complete. What the action carries is then the name the implementation answered with, which is what sending the file on refers to.

A piece the implementation refuses ends the sending and nothing is sent on, so a file that was only partly streamed is never sent as if it were whole, and an answer naming no file leaves nothing to send on by and is refused as well.

What may be read is unchanged: the floor is asked of the path before the file is read, whether it then goes in one request or in pieces.
The tools declared no time of their own, so a call took the timeout of the turn, and a file of the workspace makes a call long: one larger than a request carries is sent to the implementation in pieces, each of them a call of its own, and is then sent on. A call ended that way is one whose outcome nobody knows, which is what a file that reached a group twice came of.

The tool that changes something declares five minutes now, which is a ceiling rather than a wait: a call that is done sooner ends sooner. The tool that only reads needs none, which the test says as well.
A file of the workspace is read where the call runs, and the executor runs a tool where it is called, so reading a large file held the dispatcher the turn runs on and a cancellation could not take effect until the read was done. The files are read on the dispatcher for reading files now, which is what the rest of the project does with a file, and the sending of the pieces waits the same way it did.

A call cancelled while it waits ends cancelled rather than answered, which the test says: nothing is known of what such a call did, and the count of it must not be taken as one that did nothing.
A call that changes something can be ended while its request is on its way, and nothing is then known of whether it was carried out. A model that takes such a call for one that did nothing sends the same thing again, which is how one file reached a group twice.

The tool that changes something says so now, and says to look at what the chat or the group holds before calling again. The tool that only reads says no such thing, since a reading changes nothing.
A file that reached a group could only be sent: nothing asked what a group holds, and nothing took a file away, so a file that was sent there twice had to be taken away by hand. The actions an implementation offers for a group's files are in the table now, on the side each belongs to: what a group holds is read by the tool that reads, and a file is taken away by the tool that changes.

Taking a file away is given what listing a group's files gives, which is why both halves are here: the id a file is known by and the part it is in come from the listing, and a deletion that cannot be given them is of no use.

The actions that move, rename, forward or make folders of a group's files are not part of this, nor are the ones that delete a folder.
# Conflicts:
#	libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/AgentToolContext.kt
#	libraries/agent/src/main/kotlin/org/foedusprogramme/alexandrite/agent/tool/ToolExecutor.kt
The test builds a tool call and hands it to the executor, which takes the message the turn answers as well. It passes none, since a call of a background job in that test answers no message of a chat.

That parameter is what lets a tool act on the message a turn is about. The test says nothing about it, so it passes nothing rather than a message no chat sent.
@Yos-X
Yos-X force-pushed the feature/channel-onebot branch from a4c5c32 to d9b9dd6 Compare October 13, 2026 09:27
Yos-X added 2 commits October 13, 2026 17:36
The set of text packs a checkout holds grew by the id of the new channel, and the line naming them is longer than a line may be: the names go one to a line now.

The context a tool runs in is built with the message its turn answers and, since the merge, with what a sub-agent run takes as well, and that call is longer than a line may be: the call goes on the line after.

The check of the app and of the agent was not run when those lines were written, since the check was run for the channel alone. That is what the failing build said.
…ntext carries

The framing names the id of the message a message answers, so the quote of a bot's own line about an approval carries one, and the test that reads that quote expects it now. The test keeps the message it reads in the same place a test already keeps the one it presses, so both are there when the framing is read.

The context of a call carries the message its turn answers, and the testkit's context is one: a declaration the sample of the testkit's public API had never named, which the test of that sample said.

Both are what two checks of this branch reported, and both were left behind when those declarations were added.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants