diff --git a/composer.json b/composer.json index da298c14..5fc87aee 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "require-dev": { "brainmaestro/composer-git-hooks": "^2.8", "friendsofphp/php-cs-fixer": "^3.2 != 3.7.0", + "jetbrains/phpstorm-attributes": "^1.0", "phpstan/phpstan": "^1.1", "phpunit/phpunit": "^8.5 || ^9.0", "roave/security-advisories": "dev-latest", diff --git a/src/Module/Example/Hello.php b/src/Module/Example/Hello.php index 02d97154..2bcafa91 100644 --- a/src/Module/Example/Hello.php +++ b/src/Module/Example/Hello.php @@ -13,6 +13,7 @@ use ZM\Annotation\Http\RequestMapping; use ZM\Annotation\Swoole\OnCloseEvent; use ZM\Annotation\Swoole\OnOpenEvent; use ZM\Annotation\Swoole\OnRequestEvent; +use ZM\Annotation\Swoole\OnStart; use ZM\API\CQ; use ZM\API\OneBotV11; use ZM\API\TuringAPI; @@ -24,6 +25,7 @@ use ZM\Event\EventDispatcher; use ZM\Exception\InterruptException; use ZM\Module\QQBot; use ZM\Requests\ZMRequest; +use ZM\Utils\CommandInfoUtil; use ZM\Utils\MessageUtil; use ZM\Utils\ZMUtil; @@ -232,7 +234,11 @@ class Hello #[CQCommand('帮助')] public function help(): string { - $helps = MessageUtil::generateCommandHelp(); + $util = resolve(CommandInfoUtil::class); + $commands = $util->get(); + $helps = array_map(static function ($command) use ($util) { + return $util->getHelp($command['id']); + }, $commands); array_unshift($helps, '帮助:'); return implode("\n", $helps); } diff --git a/src/ZM/Utils/CommandInfoUtil.php b/src/ZM/Utils/CommandInfoUtil.php new file mode 100644 index 00000000..4b849093 --- /dev/null +++ b/src/ZM/Utils/CommandInfoUtil.php @@ -0,0 +1,222 @@ + 'string', 'call' => 'callable', 'descriptions' => ['string'], 'triggers' => ['trigger_name' => ['string']], 'args' => ['arg_name' => ['name' => 'string', 'type' => 'string', 'description' => 'string', 'default' => 'mixed', 'required' => 'bool']]]])] + public function get(): array + { + if (!$this->exists()) { + return $this->generateCommandList(); + } + return WorkerCache::get('commands'); + } + + /** + * 重新生成命令信息 + */ + public function regenerate(): void + { + $this->generateCommandList(); + } + + /** + * 获取命令帮助 + * + * @param string $command_id 命令ID,为 `class@method` 格式 + * @param bool $simple 是否仅输出简易信息(只有命令触发条件和描述) + */ + public function getHelp(string $command_id, bool $simple = false): string + { + $command = $this->get()[$command_id]; + + $formats = [ + 'match' => '%s', + 'pattern' => '符合”%s“', + 'regex' => '匹配“%s”', + 'start_with' => '以”%s“开头', + 'end_with' => '以”%s“结尾', + 'keyword' => '包含“%s”', + 'alias' => '%s', + ]; + $triggers = []; + foreach ($command['triggers'] as $trigger => $conditions) { + if (count($conditions) === 0) { + continue; + } + if (isset($formats[$trigger])) { + $format = $formats[$trigger]; + } else { + Console::warning("未知的命令触发条件:{$trigger}"); + continue; + } + foreach ($conditions as $condition) { + $condition = sprintf($format, $condition); + $triggers[] = $condition; + } + } + $name = array_shift($triggers); + if (count($triggers) > 0) { + $name .= '(' . implode(',', $triggers) . ')'; + } + + if (empty($command['descriptions'])) { + $description = '作者很懒,啥也没说'; + } else { + $description = implode(';', $command['descriptions']); + } + + if ($simple) { + return "{$name}:{$description}"; + } + + $lines = []; + + $lines[0][] = $name; + $lines[1][] = $description; + + foreach ($command['args'] as $arg_name => $arg_info) { + if ($arg_info['required']) { + $lines[0][] = "<{$arg_name}: {$arg_info['type']}>"; + } else { + $buffer = "[{$arg_name}: {$arg_info['type']}"; + if (!empty($arg_info['default'])) { + $buffer .= " = {$arg_info['default']}"; + } + $lines[0][] = $buffer . ']'; + } + + $lines[][] = "{$arg_name};{$arg_info['description']}"; + } + + $buffer = []; + foreach ($lines as $line) { + $buffer[] = implode(' ', $line); + } + return implode("\n", $buffer); + } + + /** + * 缓存命令信息 + */ + protected function save(array $helps): void + { + WorkerCache::set('commands', $helps); + } + + /** + * 根据注解树生成命令信息(内部) + */ + protected function generateCommandList(): array + { + $commands = []; + + foreach (EventManager::$events[CQCommand::class] ?? [] as $annotation) { + $id = "{$annotation->class}@{$annotation->method}"; + + try { + $reflection = new ReflectionMethod($annotation->class, $annotation->method); + } catch (ReflectionException $e) { + Console::warning('命令 ' . $id . ' 注解解析错误:' . $e->getMessage()); + continue; + } + + $doc = $reflection->getDocComment(); + if ($doc) { + // 匹配出不以@开头,且后接中文或任意非空格字符,并以换行符结尾的字符串,也就是命令描述 + preg_match_all('/\*\s((?!@)[\x{4e00}-\x{9fa5}\S]+)(\r\n|\r|\n)/u', $doc, $descriptions); + $descriptions = $descriptions[1]; + } + + $command = [ + 'id' => $id, + 'call' => [$annotation->class, $annotation->method], + 'descriptions' => $descriptions ?? [], + 'triggers' => [], + 'args' => [], + ]; + + if (empty($command['descriptions'])) { + Console::warning("命令没有描述信息:{$id}"); + } + + // 可能的触发条件,顺序会影响命令帮助的生成结果 + $possible_triggers = ['match', 'pattern', 'regex', 'start_with', 'end_with', 'keyword', 'alias']; + foreach ($possible_triggers as $trigger) { + if (isset($annotation->{$trigger}) && !empty($annotation->{$trigger})) { + // 部分触发条件可能存在多个 + if (is_iterable($annotation->{$trigger})) { + foreach ($annotation->{$trigger} as $item) { + $command['triggers'][$trigger][] = $item; + } + } else { + $command['triggers'][$trigger][] = $annotation->{$trigger}; + } + } + } + if (empty($command['triggers'])) { + Console::warning("命令没有触发条件:{$id}"); + continue; + } + + $command['args'] = $this->generateCommandArgumentList($id); + + $commands[$id] = $command; + } + + $this->save($commands); + return $commands; + } + + /** + * 生成指定命令的参数列表 + * + * @param string $id 命令 ID + */ + protected function generateCommandArgumentList(string $id): array + { + [$class, $method] = explode('@', $id); + $map = EventManager::$event_map[$class][$method]; + + $args = []; + + foreach ($map as $annotation) { + if (!$annotation instanceof CommandArgument) { + continue; + } + + $args[$annotation->name] = [ + 'name' => $annotation->name, + 'type' => $annotation->type, + 'description' => $annotation->description, + 'default' => $annotation->default, + 'required' => $annotation->required, + ]; + } + + return $args; + } +} diff --git a/src/ZM/Utils/MessageUtil.php b/src/ZM/Utils/MessageUtil.php index 16217602..028d7c5c 100644 --- a/src/ZM/Utils/MessageUtil.php +++ b/src/ZM/Utils/MessageUtil.php @@ -6,8 +6,6 @@ namespace ZM\Utils; use Exception; use Iterator; -use ReflectionException; -use ReflectionMethod; use ZM\Annotation\CQ\CommandArgument; use ZM\Annotation\CQ\CQCommand; use ZM\API\CQ; @@ -19,7 +17,6 @@ use ZM\Event\EventManager; use ZM\Event\EventMapIterator; use ZM\Exception\WaitTimeoutException; use ZM\Requests\ZMRequest; -use ZM\Store\WorkerCache; use ZM\Utils\Manager\WorkerManager; class MessageUtil @@ -258,80 +255,6 @@ class MessageUtil return $str; } - /** - * 根据注解树生成命令列表、帮助 - * - * @return array 帮助信息,每个元素对应一个命令的帮助信息,格式为:命令名(其他触发条件):命令描述 - */ - public static function generateCommandHelp(): array - { - try { - if ($cache = WorkerCache::get('command_help')) { - return $cache; - } - } catch (Exception $e) { - // 不做任何处理,尝试重新生成 - } - $helps = []; - foreach (EventManager::$events[CQCommand::class] as $annotation) { - if ($annotation instanceof CQCommand) { - try { - $reflection = new ReflectionMethod($annotation->class, $annotation->method); - } catch (ReflectionException $e) { - Console::warning('注解解析错误:' . $e->getMessage()); - continue; - } - $doc = $reflection->getDocComment(); - if ($doc) { - // 匹配出不以@开头,且后接中文或任意非空格字符,并以换行符结尾的字符串,也就是命令描述 - preg_match_all('/\*\s((?!@)[\x{4e00}-\x{9fa5}\S]+)(\r\n|\r|\n)/u', $doc, $matches); - // 多行描述用分号分隔 - $help = implode(';', $matches[1]); - if (empty($help)) { - Console::warning('命令 ' . $annotation->class . '::' . $annotation->method . ' 没有描述!'); - $help = '无描述'; - } - } else { - Console::warning('命令 ' . $annotation->class . '::' . $annotation->method . ' 没有描述!'); - $help = '无描述'; - } - - // 可以触发命令的参数 - $possible_keys = [ - 'match' => '%s', - 'pattern' => '符合”%s“', - 'regex' => '匹配“%s”', - 'start_with' => '以”%s“开头', - 'end_with' => '以”%s“结尾', - 'keyword' => '包含“%s”', - 'alias' => '%s', - ]; - $command_seg = []; - foreach ($possible_keys as $key => $help_format) { - // 如果定义了该参数,则添加到帮助信息中 - if (isset($annotation->{$key}) && !empty($annotation->{$key})) { - if (is_iterable($annotation->{$key})) { - foreach ($annotation->{$key} as $item) { - $command_seg[] = sprintf($help_format, $item); - } - } else { - $command_seg[] = sprintf($help_format, $annotation->{$key}); - } - } - } - // 第一个触发参数为主命令名 - $command = array_shift($command_seg); - if (count($command_seg) > 0) { - $command .= '(' . implode(',', $command_seg) . ')'; - } - $helps[] = sprintf('%s:%s', $command, $help); - } - } - // 放到跨进程缓存以供取用 - WorkerCache::set('command_helps', $helps); - return $helps; - } - /** * @throws WaitTimeoutException */ diff --git a/tests/ZM/Utils/CommandInfoUtilTest.php b/tests/ZM/Utils/CommandInfoUtilTest.php new file mode 100644 index 00000000..42acfb9d --- /dev/null +++ b/tests/ZM/Utils/CommandInfoUtilTest.php @@ -0,0 +1,71 @@ +class = self::class; + $cmd->method = __FUNCTION__; + + $args = [ + new CommandArgument('文本', '一个神奇的文本', 'string', true), + new CommandArgument('数字', '一个神奇的数字', 'int', false, '', '233'), + ]; + + self::$command_id = "{$cmd->class}@{$cmd->method}"; + + EventManager::$events[CQCommand::class] = []; + EventManager::$event_map = []; + EventManager::addEvent(CQCommand::class, $cmd); + EventManager::$event_map[$cmd->class][$cmd->method] = $args; + + self::$util = resolve(CommandInfoUtil::class); + } + + public function testGet(): void + { + $commands = self::$util->get(); + $this->assertIsArray($commands); + $this->assertCount(1, $commands); + $this->assertArrayHasKey(self::$command_id, $commands); + } + + public function testGetHelp(): void + { + $help = self::$util->getHelp(self::$command_id); + $this->assertIsString($help); + $this->assertNotEmpty($help); + + $expected = <<<'EOF' +测试命令 <文本: string> [数字: number = 233] +作者很懒,啥也没说 +文本;一个神奇的文本 +数字;一个神奇的数字 +EOF; + $this->assertEquals($expected, $help); + } +} diff --git a/tests/ZM/Utils/MessageUtilTest.php b/tests/ZM/Utils/MessageUtilTest.php index 0b9f17a6..fa5d18ff 100644 --- a/tests/ZM/Utils/MessageUtilTest.php +++ b/tests/ZM/Utils/MessageUtilTest.php @@ -44,17 +44,6 @@ class MessageUtilTest extends TestCase ]; } - public function testGenerateCommandHelp(): void - { - EventManager::$events[CQCommand::class] = []; - $cmd = new CQCommand('测试命令'); - $cmd->class = self::class; - $cmd->method = __FUNCTION__; - EventManager::addEvent(CQCommand::class, $cmd); - $help = MessageUtil::generateCommandHelp(); - $this->assertEquals('测试命令:无描述', $help[0]); - } - /** * @dataProvider providerTestArrayToStr */