Merge pull request #184 from zhamao-robot/plugin-update-pre

插件和 OneBot 12 适配器前置内容
This commit is contained in:
Jerry 2022-12-18 20:13:50 +08:00 committed by GitHub
commit 2633e91d4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 240 additions and 72 deletions

View File

@ -69,6 +69,11 @@ $config['plugin'] = [
'load_dir' => 'plugins',
];
/* 内部默认启用的插件 */
$config['native_plugin'] = [
'onebot12' => true,
];
/* 静态文件读取器 */
$config['file_server'] = [
'enable' => true,

View File

@ -15,7 +15,7 @@ class CheckConfigCommand extends Command
protected function handle(): int
{
$current_cfg = getcwd() . '/config/';
$current_cfg = SOURCE_ROOT_DIR . '/config/';
$remote_cfg = include FRAMEWORK_ROOT_DIR . '/config/global_old.php';
if (file_exists($current_cfg . 'global.php')) {
$this->check($remote_cfg, 'global.php');
@ -38,11 +38,7 @@ class CheckConfigCommand extends Command
return self::SUCCESS;
}
/**
* @param mixed $remote
* @param mixed $local
*/
private function check($remote, $local)
private function check(mixed $remote, mixed $local)
{
$local_file = include WORKING_DIR . '/config/' . $local;
if ($local_file === true) {

View File

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace ZM\Event\Listener;
use Choir\Http\HttpFactory;
use Choir\Http\Stream;
use OneBot\Driver\Event\Http\HttpRequestEvent;
use OneBot\Http\HttpFactory;
use OneBot\Http\Stream;
use OneBot\Util\Singleton;
use ZM\Annotation\AnnotationHandler;
use ZM\Annotation\Framework\BindEvent;
@ -33,9 +33,7 @@ class HttpEventListener
// TODO: 这里有个bug如果是用的Workerman+Fiber协程的话有个前置协程挂起这里获取到的Event是被挂起的Event对象触发两次事件才能归正
// 跑一遍 BindEvent 绑定了 HttpRequestEvent 的注解
$handler = new AnnotationHandler(BindEvent::class);
$handler->setRuleCallback(function (BindEvent $anno) {
return $anno->event_class === HttpRequestEvent::class;
});
$handler->setRuleCallback(fn (BindEvent $anno) => $anno->event_class === HttpRequestEvent::class);
$handler->handleAll($event);
// dump($event->getResponse());
$node = null;
@ -51,14 +49,15 @@ class HttpEventListener
$div = new Route($node['route']);
$div->params = $params;
$div->method = $node['method'];
$div->request_method = $node['request_method'];
// TODO这里有个bug逻辑上 request_method 应该是个数组,而不是字符串,但是这里 $node['method'] 是字符串,所以这里只能用字符串来判断
// $div->request_method = $node['request_method'];
$div->class = $node['class'];
$starttime = microtime(true);
$handler->handle($div, null, $params, $event->getRequest(), $event);
if (is_string($val = $handler->getReturnVal()) || ($val instanceof \Stringable)) {
$event->withResponse(HttpFactory::getInstance()->createResponse(200, null, [], Stream::create($val)));
$event->withResponse(HttpFactory::createResponse(200, null, [], Stream::create($val)));
} elseif ($event->getResponse() === null) {
$event->withResponse(HttpFactory::getInstance()->createResponse(500));
$event->withResponse(HttpFactory::createResponse(500));
}
logger()->warning('Used ' . round((microtime(true) - $starttime) * 1000, 3) . ' ms');
break;
@ -74,7 +73,7 @@ class HttpEventListener
*
* @throws ConfigException
*/
public function onRequest1(HttpRequestEvent $event)
public function onRequest1(HttpRequestEvent $event): void
{
if ($event->getResponse() === null) {
$response = HttpUtil::handleStaticPage($event->getRequest()->getUri()->getPath());

View File

@ -4,65 +4,48 @@ declare(strict_types=1);
namespace ZM\Event\Listener;
use Choir\Http\HttpFactory;
use OneBot\Driver\Event\WebSocket\WebSocketCloseEvent;
use OneBot\Driver\Event\WebSocket\WebSocketMessageEvent;
use OneBot\Driver\Event\WebSocket\WebSocketOpenEvent;
use OneBot\Driver\Process\ProcessManager;
use OneBot\Http\HttpFactory;
use OneBot\Util\Singleton;
use ZM\Annotation\AnnotationHandler;
use ZM\Annotation\Framework\BindEvent;
use ZM\Container\ContainerServicesProvider;
use ZM\Process\ProcessStateManager;
use ZM\Utils\ConnectionUtil;
class WSEventListener
{
use Singleton;
private static int $ws_counter = 0;
private static array $conn_handle = [];
public function onWebSocketOpen(WebSocketOpenEvent $event)
/**
* @throws \Throwable
*/
public function onWebSocketOpen(WebSocketOpenEvent $event): void
{
logger()->info('接入连接: ' . $event->getFd());
// 计数,最多只能接入 1024 个连接,为了适配多进程
++self::$ws_counter;
if (self::$ws_counter >= 1024) {
$event->withResponse(HttpFactory::getInstance()->createResponse(503));
if (!ConnectionUtil::addConnection($event->getFd(), [])) {
$event->withResponse(HttpFactory::createResponse(503));
return;
}
// 注册容器
resolve(ContainerServicesProvider::class)->registerServices('connection');
// 判断是不是 OneBot 12 反向 WS 连进来的,通过 Sec-WebSocket-Protocol 头
$line = explode('.', $event->getRequest()->getHeaderLine('Sec-WebSocket-Protocol'), 2);
if ($line[0] === '12') {
// 是 OneBot 12 标准的,准许接入,进行鉴权
$request = $event->getRequest();
if (($stored_token = $event->getSocketConfig()['access_token'] ?? '') !== '') {
$token = $request->getHeaderLine('Authorization');
$token = explode('Bearer ', $token);
if (!isset($token[1]) || $token[1] !== $stored_token) { // 没有 token鉴权失败
$event->withResponse(HttpFactory::getInstance()->createResponse(401, 'Unauthorized'));
return;
}
}
// 这里下面为连接准入,允许接入反向 WSTODO
if (ProcessStateManager::$process_mode['worker'] > 1) {
// 如果开了多 Worker则需要将连接信息写入文件以便跨进程读取
$info = ['impl' => $line[1] ?? 'unknown'];
self::$conn_handle[$event->getFd()] = $info;
file_put_contents(zm_dir(ZM_STATE_DIR . '/.WS' . $event->getFd() . '.' . ProcessManager::getProcessId()), json_encode($info));
}
}
// 调用注解
$handler = new AnnotationHandler(BindEvent::class);
$handler->setRuleCallback(fn ($x) => is_a($x->event_class, WebSocketOpenEvent::class, true));
$handler->handleAll($event);
}
public function onWebSocketClose(WebSocketCloseEvent $event)
public function onWebSocketMessage(WebSocketMessageEvent $event): void
{
--self::$ws_counter;
// 删除连接信息
$fd = $event->getFd();
$filename = zm_dir(ZM_STATE_DIR . '/.WS' . $fd . '.' . ProcessManager::getProcessId());
if (file_exists($filename)) {
unlink($filename);
}
unset(self::$conn_handle[$fd]);
}
public function onWebSocketClose(WebSocketCloseEvent $event): void
{
logger()->info('关闭连接: ' . $event->getFd());
ConnectionUtil::removeConnection($event->getFd());
resolve(ContainerServicesProvider::class)->cleanup();
}
}

View File

@ -13,6 +13,8 @@ use ZM\Annotation\Framework\Init;
use ZM\Container\ContainerServicesProvider;
use ZM\Exception\ZMKnownException;
use ZM\Framework;
use ZM\Plugin\OneBot12Adapter;
use ZM\Plugin\PluginManager;
use ZM\Process\ProcessStateManager;
use ZM\Store\Database\DBException;
use ZM\Store\Database\DBPool;
@ -134,6 +136,10 @@ class WorkerEventListener
}
// TODO: 然后加载插件目录下的插件
PluginManager::addPlugin([
'name' => 'onebot12-adapter',
'plugin' => new OneBot12Adapter(),
]);
// 解析所有注册路径的文件,获取注解
$parser->parseAll();

View File

@ -4,6 +4,9 @@ declare(strict_types=1);
namespace ZM\Exception;
/**
* 初始化命令(./zhamao init出现的错误
*/
class InitException extends ZMException
{
}

View File

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace ZM\Exception;
/**
* 插件加载器出现的错误
*/
class PluginException extends ZMException
{
}

View File

@ -12,16 +12,15 @@ use OneBot\Driver\Event\Process\ManagerStopEvent;
use OneBot\Driver\Event\Process\WorkerStartEvent;
use OneBot\Driver\Event\Process\WorkerStopEvent;
use OneBot\Driver\Event\WebSocket\WebSocketCloseEvent;
use OneBot\Driver\Event\WebSocket\WebSocketMessageEvent;
use OneBot\Driver\Event\WebSocket\WebSocketOpenEvent;
use OneBot\Driver\Interfaces\DriverInitPolicy;
use OneBot\Driver\Swoole\SwooleDriver;
use OneBot\Driver\Workerman\Worker;
use OneBot\Driver\Workerman\WorkermanDriver;
use OneBot\Util\Singleton;
use Phar;
use ZM\Command\Server\ServerStartCommand;
use ZM\Config\ZMConfig;
use ZM\Event\EventProvider;
use ZM\Event\Listener\HttpEventListener;
use ZM\Event\Listener\ManagerEventListener;
use ZM\Event\Listener\MasterEventListener;
@ -45,7 +44,7 @@ class Framework
public const VERSION_ID = 633;
/** @var string 版本名称 */
public const VERSION = '3.0.0-alpha4';
public const VERSION = '3.0.0-alpha5';
/** @var array 传入的参数 */
protected array $argv;
@ -239,6 +238,7 @@ class Framework
// websocket 事件
ob_event_provider()->addEventListener(WebSocketOpenEvent::getName(), [WSEventListener::getInstance(), 'onWebSocketOpen'], 999);
ob_event_provider()->addEventListener(WebSocketCloseEvent::getName(), [WSEventListener::getInstance(), 'onWebSocketClose'], 999);
ob_event_provider()->addEventListener(WebSocketMessageEvent::getName(), [WSEventListener::getInstance(), 'onWebSocketMessage'], 999);
// 框架多进程依赖
if (defined('ZM_STATE_DIR') && !is_dir(ZM_STATE_DIR)) {

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace ZM\Plugin;
use Choir\Http\HttpFactory;
use OneBot\Driver\Event\WebSocket\WebSocketOpenEvent;
use ZM\Utils\ConnectionUtil;
class OneBot12Adapter extends ZMPlugin
{
public function __construct()
{
parent::__construct(__DIR__);
$this->addEvent(WebSocketOpenEvent::class, [$this, 'handleWSReverseInput']);
}
/**
* 接入和认证反向 WS 的连接
*/
public function handleWSReverseInput(WebSocketOpenEvent $event): void
{
// 判断是不是 OneBot 12 反向 WS 连进来的,通过 Sec-WebSocket-Protocol 头
$line = explode('.', $event->getRequest()->getHeaderLine('Sec-WebSocket-Protocol'), 2);
if ($line[0] === '12') {
logger()->info('检测到 OneBot 12 反向 WS 连接,正在进行认证...');
// 是 OneBot 12 标准的,准许接入,进行鉴权
$request = $event->getRequest();
if (($stored_token = $event->getSocketConfig()['access_token'] ?? '') !== '') {
// 测试 Header
$token = $request->getHeaderLine('Authorization');
if ($token === '') {
// 测试 Query
$token = $request->getQueryParams()['access_token'] ?? '';
}
$token = explode('Bearer ', $token);
$info = ['impl' => $line[1] ?? 'unknown'];
if (!isset($token[1]) || $token[1] !== $stored_token) { // 没有 token鉴权失败
logger()->warning('OneBot 12 反向 WS 连接鉴权失败,拒绝接入');
$event->withResponse(HttpFactory::createResponse(401, 'Unauthorized'));
return;
}
}
}
// 设置 OneBot 相关的东西
ConnectionUtil::setConnection($event->getFd(), $info ?? []);
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace ZM\Plugin;
use ZM\Exception\PluginException;
class PluginManager
{
/** @var array 插件信息列表 */
private static array $plugins = [];
/**
* @throws PluginException
*/
public static function addPlugin(array $meta = []): void
{
// 首先检测 meta 是否存在 plugin 对象
if (isset($meta['plugin'])) {
// 存在的话,说明是单例插件,调用对象内的方法注册事件就行了
$meta['type'] = 'instant';
self::$plugins[$meta['name']] = $meta;
return;
}
if (isset($meta['dir'])) {
// 不存在的话,说明是多文件插件,是设置了 zmplugin.json 的目录,此目录为自动加载的
$meta['type'] = 'dir';
self::$plugins[$meta['name']] = $meta;
return;
}
// 两者都不存在的话,说明是错误的插件
throw new PluginException('plugin meta must have plugin or dir');
}
public static function getPlugins(): array
{
return self::$plugins;
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace ZM\Utils;
use OneBot\Driver\Process\ProcessManager;
use ZM\Process\ProcessStateManager;
class ConnectionUtil
{
/**
* @internal
* @var int WebSocket 连接统计
*/
public static int $connection_count = 0;
/**
* @var array WebSocket 连接元信息
*/
private static array $connection_handles = [];
/**
* 添加连接元信息
*
* @param int $fd WS 连接 ID
* @param array $handle WS 连接元信息
*/
public static function addConnection(int $fd, array $handle = []): bool
{
++self::$connection_count;
// 超过1024不行
if (self::$connection_count >= 1024) {
return false;
}
self::$connection_handles[$fd] = $handle;
// 这里下面为连接准入,允许接入反向 WS
if (ProcessStateManager::$process_mode['worker'] > 1) {
// 文件名格式为 .WS{fd}.{pid},文件内容是 impl 名称的 JSON 格式
file_put_contents(zm_dir(ZM_STATE_DIR . '/.WS' . $fd . '.' . ProcessManager::getProcessId()), json_encode($handle));
}
return true;
}
/**
* 更改、覆盖或合并连接元信息
* @param int $fd WS 连接 ID
* @param array $handle WS 连接元信息
*/
public static function setConnection(int $fd, array $handle): void
{
self::$connection_handles[$fd] = array_merge(self::$connection_handles[$fd] ?? [], $handle);
// 这里下面为连接准入,允许接入反向 WS
if (ProcessStateManager::$process_mode['worker'] > 1) {
// 文件名格式为 .WS{fd}.{pid},文件内容是 impl 名称的 JSON 格式
file_put_contents(zm_dir(ZM_STATE_DIR . '/.WS' . $fd . '.' . ProcessManager::getProcessId()), json_encode(self::$connection_handles[$fd]));
}
}
/**
* 删除连接元信息
*
* @param int $fd WS 连接 ID
*/
public static function removeConnection(int $fd): void
{
--self::$connection_count;
unset(self::$connection_handles[$fd]);
// 这里下面为连接准入,允许接入反向 WS
if (ProcessStateManager::$process_mode['worker'] > 1) {
// 文件名格式为 .WS{fd}.{pid},文件内容是 impl 名称的 JSON 格式
@unlink(zm_dir(ZM_STATE_DIR . '/.WS' . $fd . '.' . ProcessManager::getProcessId()));
}
}
}

View File

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace ZM\Utils;
use OneBot\Http\HttpFactory;
use OneBot\Http\ServerRequest;
use OneBot\Http\Stream;
use Choir\Http\HttpFactory;
use Choir\Http\Stream;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Routing\Exception\MethodNotAllowedException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
@ -32,7 +32,7 @@ class HttpUtil
* 第二个参数为路由节点
* 第三个参数为动态路由节点中匹配到的参数列表
*/
public static function parseUri(ServerRequest $request, mixed &$node, mixed &$params): int
public static function parseUri(RequestInterface $request, mixed &$node, mixed &$params): int
{
// 建立上下文,设置当前请求的方法
$context = new RequestContext();
@ -90,7 +90,7 @@ class HttpUtil
if ($path !== false) {
// 安全问题,防止目录穿越,只能囚禁到规定的 Web 根目录下获取文件
$work = realpath($base_dir) . '/';
if (strpos($path, $work) !== 0) {
if (!str_starts_with($path, $work)) {
logger()->info('[403] ' . $uri);
return static::handleHttpCodePage(403);
}
@ -98,25 +98,25 @@ class HttpUtil
if (is_dir($path)) {
if (mb_substr($uri, -1, 1) != '/') {
logger()->info('[302] ' . $uri);
return HttpFactory::getInstance()->createResponse(302, null, ['Location' => $uri . '/']);
return HttpFactory::createResponse(302, null, ['Location' => $uri . '/']);
}
// 如果结尾有 /,那么就根据默认搜索的文件名进行搜索文件是否存在,存在则直接返回对应文件
foreach ($base_index as $vp) {
if (is_file($path . '/' . $vp)) {
logger()->info('[200] ' . $uri);
$exp = strtolower(pathinfo($path . $vp)['extension'] ?? 'unknown');
return HttpFactory::getInstance()->createResponse()
return HttpFactory::createResponse()
->withAddedHeader('Content-Type', config('file_header')[$exp] ?? 'application/octet-stream')
->withBody(HttpFactory::getInstance()->createStream(file_get_contents($path . '/' . $vp)));
->withBody(HttpFactory::createStream(file_get_contents($path . '/' . $vp)));
}
}
} elseif (is_file($path)) {
// 如果文件存在,则直接返回文件内容
logger()->info('[200] ' . $uri);
$exp = strtolower(pathinfo($path)['extension'] ?? 'unknown');
return HttpFactory::getInstance()->createResponse()
return HttpFactory::createResponse()
->withAddedHeader('Content-Type', config('file_header')[$exp] ?? 'application/octet-stream')
->withBody(HttpFactory::getInstance()->createStream(file_get_contents($path)));
->withBody(HttpFactory::createStream(file_get_contents($path)));
}
}
// 否则最终肯定只能返回 404 了
@ -137,9 +137,9 @@ class HttpUtil
$code_page = null;
}
if ($code_page === null) {
return HttpFactory::getInstance()->createResponse($code);
return HttpFactory::createResponse($code);
}
return HttpFactory::getInstance()->createResponse($code, null, [], file_get_contents(config('global.file_server.document_root') . '/' . $code_page));
return HttpFactory::createResponse($code, null, [], file_get_contents(config('global.file_server.document_root') . '/' . $code_page));
}
/**
@ -151,7 +151,7 @@ class HttpUtil
*/
public static function createJsonResponse(array $data, int $http_code = 200, int $json_flag = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE): ResponseInterface
{
return HttpFactory::getInstance()->createResponse($http_code)
return HttpFactory::createResponse($http_code)
->withAddedHeader('Content-Type', 'application/json')
->withBody(Stream::create(json_encode($data, $json_flag)));
}