diff --git a/src/Globals/global_functions.php b/src/Globals/global_functions.php index e4ab7b92..214c1694 100644 --- a/src/Globals/global_functions.php +++ b/src/Globals/global_functions.php @@ -6,11 +6,11 @@ use OneBot\Driver\Coroutine\Adaptive; use OneBot\Driver\Coroutine\CoroutineInterface; use OneBot\Driver\Process\ExecutionResult; use OneBot\V12\Object\MessageSegment; +use OneBot\V12\Object\OneBotEvent; use Psr\Log\LoggerInterface; use ZM\Config\ZMConfig; use ZM\Container\Container; use ZM\Container\ContainerInterface; -use ZM\Context\Context; use ZM\Logger\ConsoleLogger; use ZM\Middleware\MiddlewareHandler; use ZM\Store\Database\DBException; @@ -68,6 +68,9 @@ function zm_internal_errcode(int|string $code): string return "[ErrCode:{$code}] "; } +/** + * 返回当前炸毛实例的 ID + */ function zm_instance_id(): string { if (defined('ZM_INSTANCE_ID')) { @@ -101,11 +104,6 @@ function is_assoc_array(array $array): bool return !empty($array) && array_keys($array) !== range(0, count($array) - 1); } -function ctx(): Context -{ - return \container()->get('ctx'); -} - /** * 构建消息段的助手函数 * @@ -208,3 +206,13 @@ function config(array|string $key = null, mixed $default = null) } return $config->get($key, $default); } + +function bot(): ZM\Context\BotContext +{ + if (\container()->has('bot.event')) { + /** @var OneBotEvent $bot_event */ + $bot_event = \container()->get('bot.event'); + return new \ZM\Context\BotContext($bot_event->self['user_id'] ?? '', $bot_event->self['platform']); + } + return new \ZM\Context\BotContext('', ''); +} diff --git a/src/ZM/Annotation/AnnotationHandler.php b/src/ZM/Annotation/AnnotationHandler.php index da856104..f65ade0a 100644 --- a/src/ZM/Annotation/AnnotationHandler.php +++ b/src/ZM/Annotation/AnnotationHandler.php @@ -41,7 +41,7 @@ class AnnotationHandler public function __construct(string $annotation_class) { $this->annotation_class = $annotation_class; - logger()->debug('开始分发注解 {annotation}', ['annotation' => $annotation_class]); + logger()->debug('声明注解分发器 {annotation}', ['annotation' => $annotation_class]); } /** @@ -88,6 +88,7 @@ class AnnotationHandler */ public function handleAll(mixed ...$params) { + logger()->debug('开始分发注解 ' . $this->annotation_class); try { // 遍历注册的注解 foreach ((AnnotationMap::$_list[$this->annotation_class] ?? []) as $v) { diff --git a/src/ZM/Annotation/OneBot/BotAction.php b/src/ZM/Annotation/OneBot/BotAction.php new file mode 100644 index 00000000..e151574d --- /dev/null +++ b/src/ZM/Annotation/OneBot/BotAction.php @@ -0,0 +1,33 @@ +level; + } + + public function setLevel($level) + { + $this->level = $level; + } +} diff --git a/src/ZM/Annotation/OneBot/BotActionResponse.php b/src/ZM/Annotation/OneBot/BotActionResponse.php new file mode 100644 index 00000000..b74fd2f5 --- /dev/null +++ b/src/ZM/Annotation/OneBot/BotActionResponse.php @@ -0,0 +1,36 @@ +level; + } + + public function setLevel($level) + { + $this->level = $level; + } +} diff --git a/src/ZM/Annotation/OneBot/BotCommand.php b/src/ZM/Annotation/OneBot/BotCommand.php index 3d0f242a..e29c7b9f 100644 --- a/src/ZM/Annotation/OneBot/BotCommand.php +++ b/src/ZM/Annotation/OneBot/BotCommand.php @@ -27,8 +27,20 @@ class BotCommand extends AnnotationBase implements Level /** * @param string[] $alias */ - public function __construct(public $name = '', public $match = '', public $pattern = '', public $regex = '', public $start_with = '', public $end_with = '', public $keyword = '', public $alias = [], public $message_type = '', public $user_id = '', public $group_id = '', public $level = 20) - { + public function __construct( + public $name = '', + public $match = '', + public $pattern = '', + public $regex = '', + public $start_with = '', + public $end_with = '', + public $keyword = '', + public $alias = [], + public $detail_type = '', + public $user_id = '', + public $group_id = '', + public $level = 20 + ) { } public static function make( diff --git a/src/ZM/Annotation/OneBot/BotEvent.php b/src/ZM/Annotation/OneBot/BotEvent.php index 7a573ce0..e1dd8e99 100644 --- a/src/ZM/Annotation/OneBot/BotEvent.php +++ b/src/ZM/Annotation/OneBot/BotEvent.php @@ -8,6 +8,7 @@ use Attribute; use Doctrine\Common\Annotations\Annotation\NamedArgumentConstructor; use Doctrine\Common\Annotations\Annotation\Target; use ZM\Annotation\AnnotationBase; +use ZM\Annotation\Interfaces\Level; /** * 机器人相关事件注解 @@ -17,20 +18,28 @@ use ZM\Annotation\AnnotationBase; * @NamedArgumentConstructor() */ #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] -class BotEvent extends AnnotationBase +class BotEvent extends AnnotationBase implements Level { - public function __construct(public ?string $type = null, public ?string $detail_type = null, public ?string $impl = null, public ?string $platform = null, public ?string $self_id = null, public ?string $sub_type = null) + public function __construct(public ?string $type = null, public ?string $detail_type = null, public ?string $sub_type = null, public int $level = 20) { } public static function make( ?string $type = null, ?string $detail_type = null, - ?string $impl = null, - ?string $platform = null, - ?string $self_id = null, - ?string $sub_type = null + ?string $sub_type = null, + int $level = 20, ): BotEvent { return new static(...func_get_args()); } + + public function getLevel(): int + { + return $this->level; + } + + public function setLevel($level) + { + $this->level = $level; + } } diff --git a/src/ZM/Context/BotContext.php b/src/ZM/Context/BotContext.php new file mode 100644 index 00000000..1df911c1 --- /dev/null +++ b/src/ZM/Context/BotContext.php @@ -0,0 +1,134 @@ +self = ['user_id' => $bot_id, 'platform' => $platform]; + } + + public function getEvent(): OneBotEvent + { + return container()->get('bot.event'); + } + + /** + * 快速回复机器人消息文本 + * + * @param array|MessageSegment|string|\Stringable $message 消息内容、消息段或消息段数组 + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws OneBot12Exception + * @throws \Throwable + */ + public function reply(\Stringable|MessageSegment|array|string $message) + { + if (container()->has('bot.event')) { + // 这里直接使用当前上下文的事件里面的参数,不再重新挨个获取怎么发消息的参数 + /** @var OneBotEvent $event */ + $event = container()->get('bot.event'); + + // reply 的条件是必须 type=message + if ($event->getType() !== 'message') { + throw new OneBot12Exception('bot()->reply() can only be used in message event.'); + } + $msg = (is_string($message) ? [new MessageSegment('text', ['text' => $message])] : ($message instanceof MessageSegment ? [$message] : $message)); + return $this->sendMessage($msg, $event->detail_type, $event->jsonSerialize()); + } + throw new OneBot12Exception('bot()->reply() can only be used in message event.'); + } + + /** + * 返回是否已经调用过回复了 + */ + public function hasReplied(): bool + { + // TODO: 完成是否已经回复的记录和返回 + return false; + } + + /** + * 获取其他机器人的上下文操作对象 + * + * @param string $bot_id 机器人的 self.user_id 对应的 ID + * @param string $platform 机器人的 self.platform 对应的 platform + * @return $this + */ + public function getBot(string $bot_id, string $platform = ''): BotContext + { + // TODO: 完善多机器人支持 + return $this; + } + + /** + * @throws \Throwable + */ + public function sendMessage(\Stringable|array|MessageSegment|string $message, string $detail_type, array $params = []) + { + $message = MessageUtil::convertToArr($message); + $params['message'] = $message; + $params['detail_type'] = $detail_type; + return $this->sendAction(Utils::camelToSeparator(__FUNCTION__), $params, $this->self); + } + + public function getEchoAction(mixed $echo): ?Action + { + return self::$echo_id_list[$echo] ?? null; + } + + /** + * @throws \Throwable + */ + private function sendAction(string $action, array $params = [], ?array $self = null) + { + // 声明 Action 对象 + $a = new Action($action, $params, ob_uuidgen(), $self); + self::$echo_id_list[$a->echo] = $a; + // 调用事件在回复之前的回调 + $handler = new AnnotationHandler(BotAction::class); + $handler->setRuleCallback(fn (BotAction $act) => $act->action === $action && !$act->need_response); + $handler->handleAll($a); + // 被阻断时候,就不发送了 + if ($handler->getStatus() === AnnotationHandler::STATUS_INTERRUPTED) { + return false; + } + + // 调用机器人连接发送 Action + if (container()->has('ws.message.event')) { + /** @var WebSocketMessageEvent $ws */ + $ws = container()->get('ws.message.event'); + return $ws->send(json_encode($a->jsonSerialize())); + } + // 如果是 HTTP WebHook 的形式,那么直接调用 Response + if (container()->has('http.request.event')) { + /** @var HttpRequestEvent $event */ + $event = container()->get('http.request.event'); + $response = HttpFactory::createResponse(headers: ['Content-Type' => 'application/json'], body: json_encode([$a->jsonSerialize()])); + $event->withResponse($response); + return true; + } + throw new OneBot12Exception('No bot connection found.'); + } +} diff --git a/src/ZM/Context/ContextInterface.php b/src/ZM/Context/ContextInterface.php index 0786b1cb..1003c56a 100644 --- a/src/ZM/Context/ContextInterface.php +++ b/src/ZM/Context/ContextInterface.php @@ -4,27 +4,6 @@ declare(strict_types=1); namespace ZM\Context; -use OneBot\Driver\Event\Http\HttpRequestEvent; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; - interface ContextInterface { - /** - * 获取 Http Request 请求对象 - */ - public function getRequest(): ServerRequestInterface; - - /** - * 获取 Http 请求事件对象 - */ - public function getHttpRequestEvent(): HttpRequestEvent; - - /** - * 使用 Response 对象响应 Http 请求 - * Wrapper of HttpRequestEvent::withResponse method - * - * @param ResponseInterface $response 响应对象 - */ - public function withResponse(ResponseInterface $response); } diff --git a/src/ZM/Event/Listener/WSEventListener.php b/src/ZM/Event/Listener/WSEventListener.php index ede6d14c..a2e7b029 100644 --- a/src/ZM/Event/Listener/WSEventListener.php +++ b/src/ZM/Event/Listener/WSEventListener.php @@ -12,6 +12,7 @@ use OneBot\Util\Singleton; use ZM\Annotation\AnnotationHandler; use ZM\Annotation\Framework\BindEvent; use ZM\Container\ContainerServicesProvider; +use ZM\Exception\Handler; use ZM\Utils\ConnectionUtil; class WSEventListener @@ -31,19 +32,32 @@ class WSEventListener } // 注册容器 resolve(ContainerServicesProvider::class)->registerServices('connection'); + container()->instance(WebSocketOpenEvent::class, $event); + container()->alias(WebSocketOpenEvent::class, 'ws.open.event'); // 调用注解 $handler = new AnnotationHandler(BindEvent::class); $handler->setRuleCallback(fn ($x) => is_a($x->event_class, WebSocketOpenEvent::class, true)); $handler->handleAll($event); + + resolve(ContainerServicesProvider::class)->cleanup(); } public function onWebSocketMessage(WebSocketMessageEvent $event): void { + container()->instance(WebSocketMessageEvent::class, $event); + container()->alias(WebSocketMessageEvent::class, 'ws.message.event'); // 调用注解 - $handler = new AnnotationHandler(BindEvent::class); - $handler->setRuleCallback(fn ($x) => is_a($x->event_class, WebSocketMessageEvent::class, true)); - $handler->handleAll($event); + try { + $handler = new AnnotationHandler(BindEvent::class); + $handler->setRuleCallback(fn ($x) => is_a($x->event_class, WebSocketMessageEvent::class, true)); + $handler->handleAll(); + } catch (\Throwable $e) { + logger()->error("处理 WebSocket 消息时出现异常:{$e->getMessage()}"); + Handler::getInstance()->handle($e); + } finally { + resolve(ContainerServicesProvider::class)->cleanup(); + } } /** @@ -52,6 +66,9 @@ class WSEventListener public function onWebSocketClose(WebSocketCloseEvent $event): void { logger()->info('关闭连接: ' . $event->getFd()); + // 绑定容器 + container()->instance(WebSocketCloseEvent::class, $event); + container()->alias(WebSocketCloseEvent::class, 'ws.close.event'); // 调用注解 $handler = new AnnotationHandler(BindEvent::class); $handler->setRuleCallback(fn ($x) => is_a($x->event_class, WebSocketCloseEvent::class, true)); diff --git a/src/ZM/Exception/OneBot12Exception.php b/src/ZM/Exception/OneBot12Exception.php new file mode 100644 index 00000000..85a9d764 --- /dev/null +++ b/src/ZM/Exception/OneBot12Exception.php @@ -0,0 +1,9 @@ +addEvent(WebSocketOpenEvent::class, [$this, 'handleWSReverseInput']); + $this->addEvent(WebSocketOpenEvent::class, [$this, 'handleWSReverseOpen']); + $this->addEvent(\WebSocketMessageEvent::class, [$this, 'handleWSReverseMessage']); + // 在 BotEvent 内处理 BotCommand + // $cmd_event = BotEvent::make(type: 'message', level: 15)->on([$this, 'handleBotCommand']); + // $this->addBotEvent($cmd_event); // 处理和声明所有 BotCommand 下的 CommandArgument $parser->addSpecialParser(BotCommand::class, [$this, 'parseBotCommand']); // 不需要给列表写入 CommandArgument @@ -61,6 +76,33 @@ class OneBot12Adapter extends ZMPlugin return true; } + /** + * 调用 BotCommand 注解的方法 + * + * @param BotEvent $event BotEvent 事件 + * @param BotContext $ctx 机器人环境上下文 + */ + public function handleBotCommand(BotEvent $event, BotContext $ctx) + { + $handler = new AnnotationHandler(BotCommand::class); + $handler->setReturnCallback(function ($result) use ($ctx) { + if (is_string($result)) { + $ctx->reply($result); + return; + } + try { + Validator::validateMessageSegment($result); + $ctx->reply($result); + } catch (\Throwable) { + } + if ($ctx->hasReplied()) { + AnnotationHandler::interrupt(); + } + }); + // 匹配消息 + $match_result = $this->matchBotCommand($ctx->getEvent()); + } + /** * @throws StopException */ @@ -79,14 +121,16 @@ class OneBot12Adapter extends ZMPlugin * 接入和认证反向 WS 的连接 * @throws StopException */ - public function handleWSReverseInput(WebSocketOpenEvent $event): void + public function handleWSReverseOpen(WebSocketOpenEvent $event): void { + logger()->info('收到握手请求:' . json_encode($event->getRequest()->getHeaders(), JSON_PRETTY_PRINT)); // 判断是不是 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(); + $info = ['impl' => $line[1] ?? 'unknown']; if (($stored_token = $event->getSocketConfig()['access_token'] ?? '') !== '') { // 测试 Header $token = $request->getHeaderLine('Authorization'); @@ -95,15 +139,94 @@ class OneBot12Adapter extends ZMPlugin $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')); $event->stopPropagation(); } } + logger()->info('OneBot 12 反向 WS 连接鉴权成功,接入成功[' . $event->getFd() . ']'); } // 设置 OneBot 相关的东西 ConnectionUtil::setConnection($event->getFd(), $info ?? []); } + + /** + * 处理 WebSocket 消息 + * + * @param WebSocketMessageEvent $event 事件对象 + * @throws OneBotException + * @throws \Throwable + */ + public function handleWSReverseMessage(WebSocketMessageEvent $event): void + { + // 忽略非 OneBot 12 的消息 + $impl = ConnectionUtil::getConnection($event->getFd())['impl'] ?? null; + if ($impl === null) { + return; + } + + // 处理 + resolve(ContainerServicesProvider::class)->registerServices('message'); + + // 解析 Frame 到 UTF-8 JSON + $body = $event->getFrame()->getData(); + $body = json_decode($body, true); + if ($body === null) { + logger()->warning('收到非 JSON 格式的消息,已忽略'); + return; + } + + if (isset($body['type'], $body['detail_type'])) { + // 如果含有 type,detail_type 字段,表明是 event + try { + $obj = new OneBotEvent($body); + } catch (OneBotException $e) { + logger()->debug('收到非 OneBot 12 标准的消息,已忽略'); + return; + } + + // 绑定容器 + container()->instance(OneBotEvent::class, $obj); + container()->alias(OneBotEvent::class, 'bot.event'); + + // 调用 BotEvent 事件 + $handler = new AnnotationHandler(BotEvent::class); + $handler->setRuleCallback(function (BotEvent $event) use ($obj) { + return ($event->type === null || $event->type === $obj->type) + && ($event->sub_type === null || $event->sub_type === $obj->sub_type) + && ($event->detail_type === null || $event->detail_type === $obj->detail_type); + }); + $handler->handleAll($obj); + } elseif (isset($body['status'], $body['retcode'])) { + // 如果含有 status,retcode 字段,表明是 action 的 response + $resp = new ActionResponse(); + $resp->retcode = $body['retcode']; + $resp->status = $body['status']; + $resp->message = $body['message'] ?? ''; + $resp->data = $body['data'] ?? null; + + container()->instance(ActionResponse::class, $resp); + container()->alias(ActionResponse::class, 'bot.action.response'); + + // 调用 BotActionResponse 事件 + $handler = new AnnotationHandler(BotActionResponse::class); + $handler->setRuleCallback(function (BotActionResponse $event) use ($resp) { + return $event->retcode === null || $event->retcode === $resp->retcode; + }); + $handler->handleAll($resp); + + $act = bot()->getEchoAction($resp->echo); + if ($act !== null) { + } + } + } + + private function matchBotCommand(OneBotEvent $event): array + { + $ls = AnnotationMap::$_list[BotCommand::class] ?? []; + $msg = $event->getMessageString(); + // TODO: 还没写完匹配 BotCommand + return []; + } } diff --git a/src/ZM/Plugin/PluginManager.php b/src/ZM/Plugin/PluginManager.php index e5c54a55..3792b43f 100644 --- a/src/ZM/Plugin/PluginManager.php +++ b/src/ZM/Plugin/PluginManager.php @@ -43,6 +43,9 @@ class PluginManager public static function addPluginsFromDir(string $dir): int { // 遍历插件目录 + if (!is_dir($dir)) { + return 0; + } $list = FileSystem::scanDirFiles($dir, false, false, true); $cnt = 0; foreach ($list as $item) { @@ -159,6 +162,11 @@ class PluginManager throw new PluginException('插件 ' . $meta['name'] . ' 无法加载,因为没有入口文件,也没有自动加载文件和内建 Composer'); } + /** + * 启用所有插件 + * + * @param AnnotationParser $parser 传入注解解析器,用于将插件中的事件注解解析出来 + */ public static function enablePlugins(AnnotationParser $parser): void { foreach (self::$plugins as $name => $plugin) { diff --git a/src/ZM/Utils/CatCode.php b/src/ZM/Utils/CatCode.php new file mode 100644 index 00000000..15d6b6e8 --- /dev/null +++ b/src/ZM/Utils/CatCode.php @@ -0,0 +1,85 @@ +type === 'text') { + return $segment->data['text']; + } + $str = '[CatCode:' . $segment->type; + foreach ($segment->data as $key => $value) { + $str .= ',' . $key . '=' . self::encode($value, true); + } + $str .= ']'; + return $str; + } +} diff --git a/src/ZM/Utils/ConnectionUtil.php b/src/ZM/Utils/ConnectionUtil.php index 4e3f999f..4eb2830e 100644 --- a/src/ZM/Utils/ConnectionUtil.php +++ b/src/ZM/Utils/ConnectionUtil.php @@ -49,6 +49,7 @@ class ConnectionUtil */ public static function setConnection(int $fd, array $handle): void { + logger()->notice('设置连接情况:' . json_encode($handle)); self::$connection_handles[$fd] = array_merge(self::$connection_handles[$fd] ?? [], $handle); // 这里下面为连接准入,允许接入反向 WS if (ProcessStateManager::$process_mode['worker'] > 1) { @@ -72,4 +73,15 @@ class ConnectionUtil @unlink(zm_dir(ZM_STATE_DIR . '/.WS' . $fd . '.' . ProcessManager::getProcessId())); } } + + /** + * 获取记录连接内容的特殊信息 + * + * @param int $fd WS 连接 ID + * @return null|mixed + */ + public static function getConnection(int $fd) + { + return self::$connection_handles[$fd] ?? null; + } } diff --git a/src/ZM/Utils/MessageUtil.php b/src/ZM/Utils/MessageUtil.php new file mode 100644 index 00000000..ac9ecf03 --- /dev/null +++ b/src/ZM/Utils/MessageUtil.php @@ -0,0 +1,76 @@ + 'text', 'data' => ['text' => $text]] : new MessageSegment('text', ['text' => $text]); + } + // 处理 CatCode + $content = mb_substr($end, 4); + $cq = explode(',', $content); + $object_type = array_shift($cq); + $object_params = []; + foreach ($cq as $v) { + $key = mb_strstr($v, '=', true); + $object_params[$key] = CatCode::decode(mb_substr(mb_strstr($v, '='), 1), true); + } + $arr[] = $assoc_result ? ['type' => $object_type, 'data' => $object_params] : new MessageSegment($object_type, $object_params); + $msg = mb_substr(mb_strstr($rear, ']'), 1); + } + if (($trim_msg = trim($msg)) !== '' || ($msg !== '' && !$ignore_space)) { + $text = CatCode::decode($trim_text ? $trim_msg : $msg); + $arr[] = $assoc_result ? ['type' => 'text', 'data' => ['text' => $text]] : new MessageSegment('text', ['text' => $text]); + } + return $arr; + } + + public static function convertToArr(MessageSegment|\Stringable|array|string $message) + { + if (is_array($message)) { + return $message; + } + if ($message instanceof MessageSegment) { + return [$message]; + } + if ($message instanceof \Stringable) { + return new MessageSegment('text', ['text' => $message->__toString()]); + } + return new MessageSegment('text', ['text' => $message]); + } +}