jx3dev/onebot-v11

OneBot V11 reverse WebSocket server for Hyperf, with multi-bot support.

Maintainers

Package info

github.com/YiwanGo/onebot-v11

pkg:composer/jx3dev/onebot-v11

Transparency log

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

1.0.0 2026-08-22 19:29 UTC

This package is auto-updated.

Last update: 2026-08-23 12:44:28 UTC


README

OneBot V11 反向 WebSocket 服务端。多机器人接入、多 Worker 可用。

use OneBot\Annotation\BotCommand;
use OneBot\Context\CommandContext;

class Ping
{
    #[BotCommand('ping')]
    public function ping(CommandContext $ctx): void
    {
        $ctx->reply('pong');
    }
}

功能

  • 反向 WS 服务端:只处理 Universal 连接,多机器人按 X-Self-ID 区分
  • 多 Worker:API 调用/响应靠 echo 编码 workerId 精确回投
  • 消息段Segment / Message 与 CQ 码字符串双向互转;发出去的裸字符串一律当纯文本,用户可控内容拼进 reply() 不会被解析成 @全体成员
  • 注解分发:命令 → 四个事件糖(#[BotMessage] / #[BotNotice] / #[BotRequest] / #[BotMetaEvent])→ 全局 #[BotEvent],分层执行;#[BotEventBefore] 排在会话拦截之前,是「每条消息都过」的全局闸门
  • 命令 #[BotCommand]:前缀 + 别名 / 正则、作用域、toMe、参数切分;runCommand() 可把当前消息转给别的命令,走完整 管线(权限不绕过)
  • 权限:内置 super / owner / admin / member + 自定义 PermissionInterface,OR 逻辑
  • 钩子:事件 / 命令 / API / 异常 / 连接 / 生命周期各类前后置(API 钩子可 abort() 短路)
  • 中间件 #[BotMiddleware]:类级 / 方法级,内置冷却 #[Cooldown] / 互斥 #[Exclusive]
  • 上下文 BotContextreply()、多轮追问 prompt() / 捕获 capture()、临时数据、切换机器人
  • 分发控制stop() 阻断本条事件的剩余分发(任意 handler 可用);stopTier() 仅在事件分层内、只停当前层;finish() 阻断并回复
  • 可扩展:继承 BotContext / Bot + 换工厂即可替换,无需改本包
  • 日志:内置结构化,收包 + 完成汇总、按 message_id 对齐、长内容截断

目录

安装

composer require jx3dev/onebot-v11
php bin/hyperf.php vendor:publish jx3dev/onebot-v11

配置

1. 加一个 WebSocket Server

config/autoload/server.phpservers 里追加:

[
    'name' => 'onebot',
    'type' => Hyperf\Server\ServerInterface::SERVER_WEBSOCKET,
    'host' => '0.0.0.0',
    'port' => 9502,
    'sock_type' => SWOOLE_SOCK_TCP,
    'callbacks' => [
        Hyperf\Server\Event::ON_HAND_SHAKE => [Hyperf\WebSocketServer\Server::class, 'onHandShake'],
        Hyperf\Server\Event::ON_MESSAGE    => [Hyperf\WebSocketServer\Server::class, 'onMessage'],
        Hyperf\Server\Event::ON_CLOSE      => [Hyperf\WebSocketServer\Server::class, 'onClose'],
    ],
],

需要「同一条连接的帧恒定落在同一个 Worker」——prompt() / capture() 的会话等待,以及冷却 #[Cooldown] / 互斥 #[Exclusive] 的 per-worker static 状态全靠它。

  • SWOOLE_BASE:天然满足(谁 accept 谁持有),dispatch_mode 在该模式下不生效。
  • SWOOLE_PROCESS:需要有连接亲和性的 dispatch_mode —— 2(按 fd,Swoole 默认,推荐)或 4(按 IP; 注意同一出口 IP 的多个机器人会全挤在同一个 Worker)。

别用 1(轮询)/ 3(抢占):它们把同一连接的帧打散到多个 Worker。Swoole 不报错也不警告、握手照样成功, 但事件会大面积丢失,上述功能静默失效。(开了 open_http2_protocol 时 Swoole 会在启动期拦下 1/3,但那是 http2 的守卫,不能指望它兜底。)

2. 注册路由

config/routes.php

Router::addServer('onebot', function () {
    Router::get('/onebot/v11/ws', OneBot\WebSocketController::class);
});

对端(go-cqhttp / NapCat / Lagrange / LLOneBot)的反向 WS 地址填 ws://<host>:9502/onebot/v11/ws。本包只处理 Universal 连接,不要用 API / Event 分离的那两个地址。

想在握手阶段就返回真 401(而不是升级后再断开),给路由挂 AccessTokenMiddleware

Router::get('/onebot/v11/ws', OneBot\WebSocketController::class, [
    'middleware' => [OneBot\Middleware\AccessTokenMiddleware::class],
]);

挂了它,token 不对的连接在握手时直接收到 401、不会升级;不挂也有鉴权,只是退化成 onOpen 里「升级后关闭」。两者共用 access_token 配置。

3. config/autoload/onebot.php

配置项 默认值 说明
access_token '' 握手鉴权,留空不校验
call_timeout 30.0 call() 默认超时(秒)
disabled_classes [] 启动时不注册这些类的任何 #[Bot*] 注解(按类禁用整个插件),写 Foo::class
command_prefixes ['/', ''] #[BotCommand] 的 command/别名叠加的前缀
super_users '' 超级管理员 QQ。.envONEBOT_SUPER_USERS 单个或逗号分隔多个;也可写数组 [1243, 1122]
nicknames '' 机器人昵称。以昵称开头(小明 天气)算「叫我」、剥掉后再匹配命令;.envONEBOT_NICKNAMES 单个或逗号分隔,或数组
log 日志 内置日志开关:log.events(收包)/ log.commands(完成)默认 true

同时在线机器人的容量写死在 ConnectionRegistry::MAX_BOTS(4096,约 1~2MB 共享内存),运行期不可扩容;真有超过的极端需求,改常量重启即可。

或者:复用已有的 HTTP server

不想多开端口的话,把骨架里现有的 http server 的 type 改成 SERVER_WEBSOCKET,在原有的 ON_REQUEST 之外补上三个 ws 回调,同一个端口就能同时跑 HTTP 和 WS:

'name' => 'http',
'type' => Hyperf\Server\ServerInterface::SERVER_WEBSOCKET,
'callbacks' => [
    Hyperf\Server\Event::ON_REQUEST     => [Hyperf\HttpServer\Server::class, 'onRequest'],
    Hyperf\Server\Event::ON_HAND_SHAKE  => [Hyperf\WebSocketServer\Server::class, 'onHandShake'],
    Hyperf\Server\Event::ON_MESSAGE     => [Hyperf\WebSocketServer\Server::class, 'onMessage'],
    Hyperf\Server\Event::ON_CLOSE       => [Hyperf\WebSocketServer\Server::class, 'onClose'],
],

这种情况下路由不用 addServer 包一层 —— Router 的默认 server 名就是 http,顶层注册即可:

Router::addRoute(['GET'], '/onebot/v11/ws', OneBot\WebSocketController::class);

注意这条路径同时也会被 HTTP 路由表命中:不带 Upgrade: websocket 头去访问它会走到 onRequest,把 WebSocketController 当普通控制器调其 __invoke()。控制器已实现该方法,对这类请求抛 HttpException(426, 'Upgrade Required'),由你 app 的异常处理器渲染(需注册 HTTP 异常处理器)。

写第一个命令

任意类的方法挂上 #[Bot*] 注解即可,无需继承、无需注册——启动时自动扫描收集。

use OneBot\Annotation\{BotCommand, BotMessage};
use OneBot\Context\{BotContext, CommandContext};

class Hello
{
    #[BotCommand('天气')]                              // "/天气 北京" 命中,前缀见 command_prefixes
    public function weather(CommandContext $ctx): void
    {
        $city = $ctx->args()->get(0, '本地');          // 第 0 个参数,越界给默认
        $ctx->reply("{$city}:晴");
    }

    #[BotMessage('group')]                             // 每条群消息都跑(命令之后)
    public function log(BotContext $ctx): void
    {
        // $ctx->userId() / $ctx->groupId() / $ctx->text() ...
    }
}

注解总览

全部注解在 OneBot\Annotation 命名空间下。带 ★ 的可重复(一个方法可挂多个)。

注解 作用 handler 收
#[BotCommand] 命令:前缀词 / 正则 + 作用域 / 权限 CommandContext
#[BotMessage] 消息事件糖,按 message_type.sub_type 收窄 BotContext
#[BotNotice] 通知事件糖 BotContext
#[BotRequest] 请求事件糖 BotContext
#[BotMetaEvent] 元事件糖(心跳 / 生命周期) BotContext
#[BotEvent] 匹配所有事件(分发最后一层) BotContext
#[BotEventBefore] 三层主 handler 之前 BotContext
#[BotEventAfter] 三层主 handler 之后(finally 语义) BotContext
#[BotCommandBefore] 全局命令前置(guarded 定位权限前/后) CommandContext
#[BotCommandAfter] 全局命令后置 CommandContext
#[BotActionBefore] 每次 API 调用发出前(可改参 / abort() ActionContext
#[BotActionAfter] 每次 API 调用收到响应后 ActionContext
#[BotException] 按异常类型分派处理 ExceptionContext
#[BotMiddleware] 给事件 handler 挂中间件(类级 / 方法级) —(作用于上面各事件类 handler)
#[BotConnect] 机器人连接建立后 ConnectionContext
#[BotDisconnect] 机器人连接断开后 ConnectionContext
#[BotServerStart] fork 前、主进程一次 参数按类型注入
#[BotWorkerStart] 每个 Worker 启动后各一次 参数按类型注入

所有事件类注解都带 priority(大者先执行,只在同层内比较)。

事件路由与分层

一条事件按类别分三层,按先后跑,三层默认都执行(分层只定先后、不互相兜底):

消息 message:  #[BotCommand]  →  #[BotMessage]   →  #[BotEvent]
通知 notice:    (无命令层)    →  #[BotNotice]    →  #[BotEvent]
请求 request:   (无命令层)    →  #[BotRequest]   →  #[BotEvent]
元事件 meta:    (无命令层)    →  #[BotMetaEvent] →  #[BotEvent]
  • 层内:命中的 handler 挨个执行,按 priority 降序(同级按类名 + 行号,稳定可复现)。
  • 默认三层全跑:命令命中不会自动挡住下面的 #[BotMessage] / #[BotEvent];想挡得自己调。
  • priority 只在同层内比较,不跨层。
  • 中途停两档$ctx->stopTier() 只结束当前这一层、继续下一层;$ctx->stop() 停全部
  • #[BotEvent] 是最后一层,适合放全局兜底 / 未处理事件日志(但它照样每条都跑,不是「没人接才跑」)。

四个事件糖#[BotMessage] / #[BotNotice] / #[BotRequest] / #[BotMetaEvent]):type 是点分二级 二级类型.三级类型 ,留空匹配该类全部:

#[BotNotice('notify.poke')]        // 只在戳一戳时跑
#[BotNotice('group_ban')]          // 群禁言(不限 sub_type)
#[BotNotice]                       // 所有通知

#[BotEvent(type)]type 是点分三级 post_type.二级.三级,如 messagemessage.groupnotice.notify.poke ,留空匹配全部事件。#[BotEventBefore] / #[BotEventAfter] 同样按这个三级 type 收窄。

命令 #[BotCommand]

命令是消息事件的细化:先按消息事件筛,再按下面规则命中。可重复(一个方法多条命令,任一命中即以该命令跑)。

参数 说明
name 主命令词,叠加全局前缀(command_prefixes,如 ['/', '']/helphelp 都中)
alias name 的别名,同样叠加前缀
regex 正则;完全 /^x$/、前缀 /^x/、后缀 /x$/、包含 /x/、忽略大小写 /x/i。具名捕获进 args()->param(name),编号捕获进 args()
scope 限定 'group' / 'private',留空不限
toMe 仅「叫我」才命中:私聊一律算;群里要 @我 或以昵称开头
permission 权限(OR),见下
denyReply 权限不满足时回的一句话;留空静默
priority 同层优先级

nameregex 是「或」:任一命中即算命中;都留空则不匹配任何消息(启动时会打一条 warning,多半是漏填命令词)。匹配用消息纯文本( extractPlainText,已滤掉 at/image 等非文本段)。

name / alias 不能含空白:首尾空白(含全角空格)会自动清掉,内部还含空白(多词)的命令词永远匹配不上、启动时会打一条 warning——多词匹配请用 regex

命令参数 Args

#[BotCommand('echo')]
public function echo(CommandContext $ctx): void
{
    $args = $ctx->args();     // Args 值对象
    $args->get(0, 'def');     // 第 N 个位置参数,越界给默认(不抛)
    $args->int(0);            // 取整,非整 / 越界返回 null
    $args->rest(1);           // 从第 1 个参数起的剩余原文(保留中间空白)
    $args->all();             // 全部位置参数 list<string>
    $args->param('name');     // 正则具名捕获
    count($args);             // 位置参数个数;foreach ($args as $a) 迭代
}

非命令 handler 参数类型写 BotContext 即可。

权限

permission 是 OR 逻辑,任一通过即命中,不满足则静默不命中。内置名用 Permission::* 常量(supersuper_users 配置; owner/admin/member 看群消息的 sender.roleadmin 含群主),可与自定义 PermissionInterface 类混用:

use OneBot\Permission\Permission;
use OneBot\Contract\PermissionInterface;

#[BotCommand('ban', permission: [Permission::SUPER, Permission::ADMIN])]   // 超管或群管
public function ban(BotContext $ctx): void { /* ... */ }

class InWhitelist implements PermissionInterface {
    public function check(BotContext $ctx): bool { return true; /* 你的逻辑 */ }
}
#[BotCommand('x', permission: [Permission::SUPER, InWhitelist::class])]

被拒默认静默。想回一句:注解加 denyReply(检查器里 stop() 了则不发),或方法里用 $ctx->isSuper() / isOwner() / isAdmin() / senderRole() 自行处理。

重定向命令 runCommand() / hasCommand()

让 handler 把当前消息手动转给某条命令执行,走完整命令管线(权限 + 中间件 + 命令前后置):

#[BotMessage]                                   // 判个格式就转给命令
public function onAnswer(CommandContext $ctx): void
{
    if ($this->isIdiom($ctx->text())) {
        $ctx->runCommand('接龙答案', $ctx->text());
    }
}

#[BotCommand('天气预报')]                        // 别名 / 同义词转发
public function alias(CommandContext $ctx): void
{
    $ctx->runCommand('天气', $ctx->args()->rest());
}

if ($ctx->hasCommand('天气')) {                  // 装了某插件才转、没装自己处理:先探避免 warning
    $ctx->runCommand('天气', $city);
}
  • runCommand(string $name, string $args = ''): int —— $name 认命令的 name 或 alias$args 是原始字符串、内部按空白切成位置参数(不支持具名参数)。
  • 同名多条按优先级全跑,返回实际派发的条数;命令不存在返回 0(且打一条 warning——转发基本没人接返回值,静默就成了「消息凭空消失、零线索」)。不想吵 warning 就先 hasCommand() 探。
  • 权限不绕过,命令前后置 / 中间件 / stop() 全部照常继承;只绕过 scope / toMe(那是文本匹配期的约束,显式点名就不该再被文本规则拦)。
  • 正则命令转不过去(没命令词)。
  • 相互转发有深度上限 3,转成环会抛 OneBotException(走 #[BotException]、不致命)。
  • 只能在被 dispatch() 分发的上下文里用(手工构造的 BotContext 没绑分发器,调了会抛)。

钩子

事件前后置 #[BotEventBefore] / #[BotEventAfter]

在三层之外:before 在所有主 handler 前、after 在所有之后(after 同 finally,始终执行)。#[BotEventBefore] 排在会话拦截 (prompt() / capture())之前,是真正「每一条消息都过」的全局闸门——即便某个会话正活着、这条消息将被 prompt / capture 收走,前置也照跑;在前置里 stop() 就彻底拦下:消息既不投给会话、也不进命令层(黑名单 / 全局限流 / 埋点靠的就是这个)。也能 像 BotEvent 一样按三级 type 收窄(#[BotEventBefore('message.group')] 只在群消息前跑)。

分层放对地方:#[BotEventBefore]便宜的全局闸门(查缓存黑名单、敏感词),它对每条消息都跑、包括多轮问答 / 接龙里的 每条答案;的富化(查群信息 / 群成员、setTemp() 预备数据)放 #[BotCommandBefore],只在命令真命中时才跑,别让每条闲聊都付这个代价。

「便宜」还有个时序理由:前置链排在会话投递之前,链里任何一个 await 都会推迟消息送到挂起的 prompt() / capture()、 吃掉它的超时预算——极端情况那句回答会因 prompt 已超时清理而落回常规分发、甚至误触命令。另外,想「无论如何都跑」的收尾 / 埋点别靠调低 priority(高优先级前置 stop() 会让低优先级的整个不跑),放 #[BotEventAfter],它不受 stop() 影响。

命令前后置 #[BotCommandBefore] / #[BotCommandAfter]

所有命令统一生效(不用逐个挂中间件)。guarded 决定前置落在权限检查的哪一侧——权限检查正夹在两组前置中间:

#[BotCommandBefore]                 // 默认 guarded=true:权限**之后**,只有过了权限的命令才过这里
public function prepare(CommandContext $ctx): void { /* 统一准备;stop()=拦截 */ }

#[BotCommandBefore(guarded: false)] // 权限**之前**、命中就过
public function guard(CommandContext $ctx): void { /* 想拦就 stop() */ }

#[BotCommandAfter]                  // 命令执行后(handler 抛异常也照跑)
public function done(CommandContext $ctx): void { /* ... */ }
  • 分发顺序:命中 → guarded=false 前置 → 判 permissionguarded=true 前置 → handler → 后置。
  • stop() = 硬拦截:命令 handler 与所有命令后置都不执行。
  • #[BotCommandAfter] 默认只对真正执行过的命令触发;guarded=false 则命中即触发,包括权限被拒的

API 钩子 #[BotActionBefore] / #[BotActionAfter]

包在每次 call() / callRaw() 外:action 留空匹配全部,填具体 action(如 send_group_msg)只匹配该调用。before 能改参数( setParams()),after 能读响应(getResponse())。它们挨个执行、无法用 stop 阻断

before 里可用 abort() 短路这次调用——不碰网络,直接用你给的响应走完 after 和成功校验:

use OneBot\Context\ActionContext;

#[BotActionBefore('send_group_msg')]          // 限速拒绝:给 failed 响应,call() 抛 ApiCallException
public function rateLimit(ActionContext $ctx): void
{
    if ($this->overLimit($ctx->selfId())) {
        $ctx->abort(['status' => 'failed', 'retcode' => 1400, 'message' => 'rate limited']);
    }
}

#[BotActionBefore('get_group_member_list')]   // 缓存命中:给 ok 响应,call() 直接返回其中 data、不发请求
public function cache(ActionContext $ctx): void
{
    if ($hit = $this->cacheGet($ctx->getParams())) {
        $ctx->abort(['status' => 'ok', 'retcode' => 0, 'data' => $hit]);
    }
}

abort() 切断剩余的 before 钩子after 照常执行(用 $ctx->isAborted() 区分是不是短路来的)。

异常 #[BotException]

按异常类型分派,只兜分发链路内(事件 handler、中间件、事件解析、连接钩子、action 钩子)抛出的异常;分发链路之外自己调 call()(定时任务、控制器里)抛的不归它管,请自行 try/catch。

$exception 指定要接的异常类型(含子类),默认 Throwable 兜底全部。一次异常会通知所有类型匹配的 handler(按 priority 依次执行、互不影响)——全局 logger 与具体处理器可共存。可重复:

use OneBot\Context\ExceptionContext;
use OneBot\Exception\ApiTimeoutException;

#[BotException(ApiTimeoutException::class)]
public function onTimeout(ExceptionContext $ctx): void { /* ... */ }

#[BotException]                                        // 兜底全部
public function onAny(ExceptionContext $ctx): void { /* ... */ }

#[BotEventAfter] 里可用 $ctx->exceptions() 拿到本次事件被捕获的所有异常(list<Throwable>),做统一成功/失败统计。

连接钩子 #[BotConnect] / #[BotDisconnect]

机器人连接建立 / 断开后触发,handler 收 ConnectionContext。断开时连接已断,勿再发消息。

生命周期 #[BotServerStart] / #[BotWorkerStart]

方法参数按类型注入(声明对应事件就拿事件、声明任意 DI 服务就注入它,顺序随意,不要就空着):

  • #[BotServerStart] —— fork 出 Worker 之前、在主进程只调一次(BeforeMainServerStart)。用于「一次性、且最好在 fork 前」的准备:建共享 Swoole\Table(fork 前建各 Worker 才共享同一块内存),或给命令动态加别名(加一次、各 Worker fork 继承,最省)。
  • #[BotWorkerStart] —— 每个 Worker 启动后各调一次(AfterWorkerStart)。用于必须 fork 后、每 Worker 各自 的初始化(协程级 / Worker 本地资源、本地缓存)。
use Hyperf\Framework\Event\AfterWorkerStart;
use OneBot\Dispatch\HandlerRegistry;

class Setup
{
    #[BotServerStart]
    public function boot(HandlerRegistry $registry): void
    {
        $registry->addAlias('天气', ['weather', 'tq']);   // fork 前加一次,各 Worker 继承
    }

    #[BotWorkerStart]
    public function warm(AfterWorkerStart $e): void
    {
        if ($e->workerId === 0) { /* 想「只做一次」判 workerId===0 */ }
    }
}

HandlerRegistry::addAlias(string $commandName, array $aliases): int:给已注册命令追加别名,等价于注解里的 alias ,只是启动时动态加(可从配置 / 数据库来)。返回实际新增的别名数0 = 命令不存在、全重复、或都含空白被跳过)。重复自动去重;别名同样不能含空白(首尾自动清、内部含空白的跳过不计);正则命令没命令词、无法加别名。

⚠ 两个钩子都每次触发各跑一遍;共享 Swoole\Table 只能#[BotServerStart](fork 前)建;钩子里抛异常只记日志、不影响其余。

中间件

#[BotMiddleware]

给事件 handler 挂中间件,可用在类上(该类所有事件 handler)或方法上(只该方法),可重复。执行顺序:类级先于方法级,同级按书写顺序。 $middleware 是实现 MiddlewareInterface 的类名,$args 作为构造参数经容器 make() 注入。

#[BotCommand('foo')]
#[BotMiddleware(MyMiddleware::class, args: ['limit' => 5])]
public function foo(CommandContext $ctx): void { /* ... */ }

中间件的 process()BotContext,因此只对「事件类」handler 生效——#[BotMessage] / #[BotNotice] / #[BotRequest] / #[BotMetaEvent] / #[BotEvent] / #[BotCommand],以及四个事件/命令前后置。挂在连接钩子 / action 钩子上**不生效也不报错 **,请勿这么挂。

内置:冷却 #[Cooldown] / 互斥 #[Exclusive]

两个开箱即用的命令中间件,都是 #[BotMiddleware] 的类型化子类注解,挂命令上即用。

冷却 #[Cooldown]

use OneBot\Middleware\Cooldown;

#[BotCommand('查询')]
#[Cooldown(seconds: 60, reply: '手速太快,{remain}秒后再来')]   // 固定 60s;{remain}=剩余秒,reply 留空静默
public function query(CommandContext $ctx): void { /* ... */ }

#[Cooldown(seconds: 3, parallel: 2)]        // 窗口内允许 2 次(突发额度)
#[Cooldown(seconds: [10, 30, 120])]         // 递增:冷却中再犯逐级 10→30→120(封顶末级),熬过则复位
  • seconds数字 = 固定滑动窗口;数组 int[] = 递增各级。
  • parallel:固定窗口内允许次数(默认 1;递增模式忽略)。

互斥 #[Exclusive](同 key 的命令不并发、一次一个,后到的排队):

use OneBot\Middleware\Exclusive;

#[BotCommand('加入')]  #[Exclusive(key: 'seats', reply: '有人在操作,请稍候')]
#[BotCommand('退出')]  #[Exclusive(key: 'seats', reply: '有人在操作,请稍候')]
  • 用于「读共享状态 →(reply / prompt / 查 API)→ 写回」这种临界区跨了 await 的场景(否则并发会丢更新 / 超额);纯同步的读改写协程不抢占、本就安全,不必上锁。⚠ 要互斥的命令必须给同一个 key
  • wait:锁被占时最多等几秒(锁一空就立刻拿到,等满没拿到才拒;默认 100 = 不等直接拒);reply:等超时回一句。

两维分桶(共用 ScopedBucketscope 定「谁和谁共享」、key 定「哪些命令共享」。

  • scope(用 Cooldown::SCOPE_* / Exclusive::SCOPE_* 常量):GLOBAL 全局 / GROUP 群(私聊退化为该用户)/ GROUP_USER 群+个人 / USER 个人(跨群)。Cooldown 默认 GROUP_USER,Exclusive 默认 GROUP
  • key:留空 = 分发器给的默认桶——命令层是命令名(别名归一到一桶),其余 handler(糖层 / 事件层 / 各类钩子)是 类::方法、各自独立。要多个共用一桶,给它们同一个 key

⚠ 于是类级 #[Cooldown] / #[Exclusive] 挂在有多个方法的类上,是每个方法各自一桶、不是全类共享(默认桶按 类::方法 区分)。想全类共享,显式给同一个 key

状态存 static、per-worker(同一条连接恒定落同一 Worker,无需 Table,见 配置)。想自扩展(令牌桶限流、递增冷却的变体…),照这俩: class X extends BotMiddleware implements MiddlewareInterface { use ScopedBucket; ... }process()$this->bucket($ctx, $this->scope, $this->key) 拿桶键写自己的逻辑(extends BotMiddleware 后靠 IS_INSTANCEOF 自动被收)。

主动调用 API:call / send / 群发 / 定时任务

从任意 DI 服务里注入 BotManager 取 bot:

use OneBot\BotManager;

class Foo
{
    public function __construct(private BotManager $bots) {}

    public function bar(): void
    {
        $bot = $this->bots->get('123456789');   // 不在线抛 BotNotFoundException;find() 则返回 null
        // ...
    }
}

call() / callRaw()(要等响应)

API 一律不做封装,只有 call() / callRaw();协议里的 action 也可通过 __call() 用驼峰直接调(转成 snake_case),Bot 类顶部的方法注解仅供 IDE 补全。

$bot->call('send_private_msg', ['user_id' => 10001, 'message' => 'hello']);
$bot->sendPrivateMsg(['user_id' => 10001, 'message' => 'hello']);        // 等价,驼峰转 send_private_msg
$bot->sendPrivateMsgAsync(['user_id' => 10001, 'message' => 'hi']);      // 后缀同理:send_private_msg_async
$bot->call('get_group_member_list', ['group_id' => 233], timeout: 60.0); // 单次覆盖超时

call() 返回响应里的 datacallRaw() 返回完整响应体。两者都要等对端响应,只能在 Worker / Task Worker 里调用

send()(即发即忘)

不关心返回、或要在自定义进程里调,用 send()——即发即忘、返回 bool任意进程可用

$bot->send('send_group_msg', ['group_id' => 233, 'message' => '公告']);

群发

send() 任意进程可用,天然适合群发。给所有在线 bot 的某个目标发:

foreach ($this->bots->all() as $bot) {
    $bot->send('send_group_msg', ['group_id' => 233, 'message' => '公告']);
}

要发到某个 bot 的所有群,先取群列表(call(),需在 Worker / Task Worker 里)再逐个 send()

$bot = $this->bots->get('123456789');
foreach ($bot->call('get_group_list') as $g) {          // call 要等响应
    $bot->send('send_group_msg', ['group_id' => $g['group_id'], 'message' => '公告']);
}

定时任务

本包不含定时器;在 Hyperf 里用 hyperf/crontab(或自定义进程 AbstractProcess )驱动,任务体里推送用 send()——最稳、任意进程可用:

composer require hyperf/crontab   # 另需在 config 里开启 crontab
use Hyperf\Crontab\Annotation\Crontab;
use OneBot\BotManager;

#[Crontab(rule: '0 9 * * *', name: 'morning', callback: 'run', memo: '每早 9 点问好')]
class MorningPush
{
    public function __construct(private BotManager $bots) {}

    public function run(): void
    {
        foreach ($this->bots->all() as $bot) {
            $bot->send('send_group_msg', ['group_id' => 233, 'message' => '早安']);
        }
    }
}
  • 推送用 send()(即发即忘)最省心。Hyperf 默认的 crontab 策略会把任务派到 Worker 执行,那种情况下需要响应的 call() 也能用;不确定就只用 send()
  • 定时任务里的异常不归 #[BotException](那只兜分发链路内的),请自行 try/catch。

上下文 BotContext

事件 handler 收到的 $ctx。命令 handler 收 CommandContextBotContext 子类,多一个 args())。

身份与消息查询

$ctx->selfId();        // 机器人 QQ(恒为字符串)
$ctx->userId();        // 发送者,可能 null
$ctx->groupId();       // 群号,可能 null
$ctx->messageId();
$ctx->text();          // 纯文本,已剥昵称前缀、归一空格
$ctx->rawText();       // 未剥昵称的纯文本
$ctx->message();       // Message 对象
$ctx->senderRole();    // owner / admin / member(群消息)
$ctx->isSuper(); $ctx->isOwner(); $ctx->isAdmin();
$ctx->isGroup(); $ctx->isPrivate(); $ctx->isToMe(); $ctx->isAtMe();
$ctx->field('sender.nickname');   // 读 payload 原始字段(支持点路径),取不到给默认
$ctx->identify();      // 一行事件标识,如 "Message 10050 from 12345@67890"
$ctx->describe();      // identify + 正文摘要(长内容截断),日志用

回复

$ctx->reply('文本');                          // 回复;支持 string / Message / Segment / 段数组
$ctx->reply('内容', at: true, quote: true);   // 群里 @ 发送者、引用原消息
$ctx->finish('收工');                          // = reply + stop(),回一句并停掉整条事件的后续分发

裸字符串一律当纯文本发reply() / finish() / prompt() 收到 string 时,里面的 CQ 码([CQ:at,qq=all][CQ:image,...]不解析——所以把用户可控文本(群名片、echo 参数…)直接 reply() 是安全的,不会被诱导 @全体 / 发任意卡片。真要发 at / 图片,用段构造器(Segment::at(...))或 Message;真要按 CQ 串解析(罕见),显式 Message::fromCQ($s)入站消息不受影响,仍按 CQ 解析成 at / image 等段。

会话:多轮追问 prompt() / 捕获 capture()

同一个人 / 群的后续消息,不用再靠命令绕:

#[BotCommand('删除')]
public function del(CommandContext $ctx): void
{
    $reply = $ctx->prompt('确定删除?回复 y 确认', timeout: 30.0);  // 挂起等下一条,超时返回 null
    if ($reply?->extractPlainText() === 'y') {
        $ctx->reply('已删除');
    }
}
  • prompt(string|Message $prompt = '', float $timeout = 60.0, bool $at = false, bool $quote = false, ?callable $match = null): ?Message —— 先把 $prompt 回出去(非空时),再挂起等下一条消息,返回它的 Message;超时返回 null。同一会话再发起 prompt 会顶掉前一个。 $match(收 BotContext)过滤答案:不过的消息不算答案、照常走常规分发、prompt 继续等下一条(于是放行 /命令 就等于允许追问中途插别的命令)。prompt 超时是一次性的(不因收到不匹配消息而重置),capture 才是滑动超时。

capture(string $scope = 'user', ?callable $match = null, float $timeout = 60.0, ?float $deadline = null, bool $consume = true): Capture —— 连续收一串消息(foreach 迭代 Capture),scope 定按人还是按群、match 过滤、timeout 是滑动超时、deadline 是总时长上限、 consume 决定收到的消息是否还继续走常规分发。

会话等待是纯本地的(本地 Channel),依赖「同一条连接恒定落同一 Worker」。反向 WS 每端一条连接, SWOOLE_BASE 天然满足、SWOOLE_PROCESSdispatch_mode2/4 时满足;换成打散分派的 1/3prompt/capture 会静默等不到。见 配置

临时数据

事件内跨 handler / 中间件共享(区别于 field() 读的原始 payload):

$ctx->setTemp('key', $value);   // 可链式
$ctx->getTemp('key', $default);
$ctx->hasTemp('key');
$ctx->delTemp('key');

停止分发 / 切换机器人

$ctx->stop();       // 停掉本条事件剩余的全部 handler(任意 handler 里都能调)
$ctx->stopTier();   // 只停当前这一层、继续下一层(仅事件分层内有效)
$ctx->bot();                 // 当前机器人
$ctx->bot('987654321');      // 换一个机器人来发

替换上下文 / 机器人对象

想给上下文加方法、或给 call()/send() 包一层限速/重试/日志:继承 → 写工厂 → dependencies.php 重绑,无需改本包。

// 上下文:继承 CommandContext(这样命令 handler 也能拿 args()),写工厂实现 BotContextFactoryInterface
class MyContext extends OneBot\Context\CommandContext { public function tag(): string { return '...'; } }

// 机器人:继承 Bot,实现 BotFactoryInterface(构造参数照抄默认工厂)
class MyBot extends OneBot\Bot
{
    public function call(string $action, array $params = [], ?float $timeout = null): mixed
    {
        return parent::call($action, $params, $timeout);   // 前后包你的逻辑
    }
}
// config/autoload/dependencies.php
return [
    OneBot\Contract\BotContextFactoryInterface::class => MyContextFactory::class,
    OneBot\Contract\BotFactoryInterface::class        => MyBotFactory::class,
];

之后 $bots->get() / $ctx->bot() / handler 里的 $ctx 都是你的子类(类型标注仍写 BotContext / Bot,子类满足)。

消息段 Segment / Message

reply() / call()message 字段收 string(走 CQ 码解析)、SegmentMessage 或段数组,都会归一。构造段:

use OneBot\Message\{Segment, Message};

Segment::text('你好'); Segment::at('123'); Segment::atAll();
Segment::image('a.jpg'); Segment::face(1); Segment::reply(10050);
Segment::record('a.amr'); Segment::poke(1, 2); Segment::music('163', 126);

Message::text('hi')->append(Segment::at('123'));   // 不可变,append/merge 返回新对象
Message::parse('hi[CQ:at,qq=9]');                  // CQ 码字符串 → Message
(string) $msg;                                     // Message → CQ 码字符串

异常与日志

异常

异常 触发时机
BotNotFoundException 目标 self_id 不在线
ConnectionException 帧没能写进连接
ApiTimeoutException 超时未收到响应
ApiCallException 对端返回 status=failed,带 retcode 和原始响应

都在 OneBot\Exception 下,共同基类 OneBotException。注意 status=async_async 后缀调用返回的 retcode=1)是「已受理」, 不会抛异常。分发链路里的异常交给 #[BotException](见上)。

日志

内置三类,走 onebot channel(正文前缀 [onebot]),格式 / 级别 / 去向在 config/autoload/logger.php 调:

INFO   [onebot] Message 10050 from 12345@67890: /天气 北京        ← 收包(谁在哪发了啥)
INFO   [onebot] Message 10050 command 天气 sent (12ms)            ← 完成(结果 + 耗时)
DEBUG  [onebot] dispatch … / command X matched / run Class::method  ← 逐 handler 细节
  • 收包 + 完成走 INFO,行首带 message_id,grep 10050 能捞一条消息全程;完成结果 = sent / done / denied / blocked / captured / handled / unhandled
  • 逐 handler 细节走 DEBUG(把 onebot channel 调到 DEBUG 才出)。
  • 元事件(心跳)压到 DEBUG、不刷 INFO;长内容(base64 大图、长 URL)自动截断。
  • 开关:onebot.phplog.events(收包)/ log.commands(完成),默认开。

多 Worker 原理与已知限制

不用 Redis,靠两样东西:

  1. 连接注册表建在 Swoole\Table(共享内存)上,由 CreateRegistryListener 在 Master fork 之前创建,每个 Worker 看到同一份 self_id -> fd 映射。
  2. 下行推送直接用 Hyperf 的 Sender(自带跨 Worker 的 fd 代理);上行响应workerId 编进 echophp.{workerId}.{随机串}),响应帧落在连接所属 Worker,解出前缀后用 sendMessage 回投给真正挂起的那个 Worker。
Worker 3  call()  ──push──> Sender ──> 机器人
                                        │
Worker 1  onMessage <──响应帧───────────┘
   │ 解出 echo 前缀 = 3
   └─ sendMessage ──> Worker 3 的 onPipeMessage ──> 唤醒挂起的协程

已知限制

  • call()(要等响应)只能在 Worker / Task Worker 进程内调用;自定义进程 / 定时任务里用即发即忘的 send()
  • 不挂 AccessTokenMiddleware 时,鉴权在 onOpen 里做,非法连接看到的是「101 之后立刻被断开」而不是 401。
  • 同时在线机器人容量写死为 ConnectionRegistry::MAX_BOTS(4096),运行期不可调整。
  • 同一个 self_id 重连时,新连接顶掉旧连接;被顶替的旧连接在它自己的 onClose 到来前推来的事件推送会被丢弃(在途的 API 响应仍放行——那可能是顶替前发起的调用的回包)。
  • 依赖「同一条连接的帧恒定落在同一个 Worker」(SWOOLE_BASE 天然满足;SWOOLE_PROCESSdispatch_mode2 (默认)或 4,见 配置):prompt() / capture() 与冷却 / 互斥中间件的 per-worker 状态都靠它;换成 1/3 这类打散分派,这些会静默失效。

测试

composer test

协程相关用例需要 ext-swoole(没装则自动跳过)。覆盖消息段与 CQ 码互转、命令匹配(前缀 / 别名 / 正则 / 作用域 / 权限)、冷却与互斥中间件(含高并发下的原子性与上限不超卖)、call / send 与成功判定、会话 prompt / capture、上下文与分发管线。

许可

MIT