jx3dev / onebot-v11
OneBot V11 reverse WebSocket server for Hyperf, with multi-bot support.
Requires
- php: >=8.2
- hyperf/contract: ^3.2
- hyperf/di: ^3.2
- hyperf/framework: ^3.2
- hyperf/logger: ^3.2
- hyperf/websocket-server: ^3.2
Requires (Dev)
- phpunit/phpunit: ^11.0
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] - 上下文
BotContext:reply()、多轮追问prompt()/ 捕获capture()、临时数据、切换机器人 - 分发控制:
stop()阻断本条事件的剩余分发(任意 handler 可用);stopTier()仅在事件分层内、只停当前层;finish()阻断并回复 - 可扩展:继承
BotContext/Bot+ 换工厂即可替换,无需改本包 - 日志:内置结构化,收包 + 完成汇总、按 message_id 对齐、长内容截断
目录
- 安装
- 配置
- 写第一个命令
- 注解总览
- 事件路由与分层
- 命令
#[BotCommand] - 钩子
- 中间件
- 主动调用 API:call / send / 群发 / 定时任务
- 上下文
BotContext - 消息段 Segment / Message
- 异常与日志
- 多 Worker 原理与已知限制
- 测试
- 许可
安装
composer require jx3dev/onebot-v11 php bin/hyperf.php vendor:publish jx3dev/onebot-v11
配置
1. 加一个 WebSocket Server
config/autoload/server.php 的 servers 里追加:
[
'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-workerstatic状态全靠它。
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。.env 的 ONEBOT_SUPER_USERS 单个或逗号分隔多个;也可写数组 [1243, 1122] |
nicknames |
'' |
机器人昵称。以昵称开头(小明 天气)算「叫我」、剥掉后再匹配命令;.env 的 ONEBOT_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.二级.三级,如 message、message.group、notice.notify.poke
,留空匹配全部事件。#[BotEventBefore] / #[BotEventAfter] 同样按这个三级 type 收窄。
命令 #[BotCommand]
命令是消息事件的细化:先按消息事件筛,再按下面规则命中。可重复(一个方法多条命令,任一命中即以该命令跑)。
| 参数 | 说明 |
|---|---|
name |
主命令词,叠加全局前缀(command_prefixes,如 ['/', ''] 则 /help 和 help 都中) |
alias |
name 的别名,同样叠加前缀 |
regex |
正则;完全 /^x$/、前缀 /^x/、后缀 /x$/、包含 /x/、忽略大小写 /x/i。具名捕获进 args()->param(name),编号捕获进 args() |
scope |
限定 'group' / 'private',留空不限 |
toMe |
仅「叫我」才命中:私聊一律算;群里要 @我 或以昵称开头 |
permission |
权限(OR),见下 |
denyReply |
权限不满足时回的一句话;留空静默 |
priority |
同层优先级 |
name 与 regex 是「或」:任一命中即算命中;都留空则不匹配任何消息(启动时会打一条 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::* 常量(super 看 super_users 配置;
owner/admin/member 看群消息的 sender.role,admin 含群主),可与自定义 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前置 → 判permission→guarded=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:锁被占时最多等几秒(锁一空就立刻拿到,等满没拿到才拒;默认10;0= 不等直接拒);reply:等超时回一句。
两维分桶(共用 ScopedBucket):scope 定「谁和谁共享」、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() 返回响应里的 data,callRaw() 返回完整响应体。两者都要等对端响应,只能在 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 收 CommandContext(BotContext 子类,多一个 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_PROCESS在dispatch_mode为2/4时满足;换成打散分派的1/3,prompt/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 码解析)、Segment、Message 或段数组,都会归一。构造段:
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(把
onebotchannel 调到 DEBUG 才出)。 - 元事件(心跳)压到 DEBUG、不刷 INFO;长内容(base64 大图、长 URL)自动截断。
- 开关:
onebot.php的log.events(收包)/log.commands(完成),默认开。
多 Worker 原理与已知限制
不用 Redis,靠两样东西:
- 连接注册表建在
Swoole\Table(共享内存)上,由CreateRegistryListener在 Master fork 之前创建,每个 Worker 看到同一份self_id -> fd映射。 - 下行推送直接用 Hyperf 的
Sender(自带跨 Worker 的 fd 代理);上行响应把workerId编进echo(php.{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_PROCESS需dispatch_mode为2(默认)或4,见 配置):prompt()/capture()与冷却 / 互斥中间件的 per-worker 状态都靠它;换成1/3这类打散分派,这些会静默失效。
测试
composer test
协程相关用例需要 ext-swoole(没装则自动跳过)。覆盖消息段与 CQ 码互转、命令匹配(前缀 / 别名 / 正则 / 作用域 /
权限)、冷却与互斥中间件(含高并发下的原子性与上限不超卖)、call / send 与成功判定、会话 prompt / capture、上下文与分发管线。