Merge pull request #202 from zhamao-robot/plugin-make-command

添加插件生成功能
This commit is contained in:
Jerry 2022-12-26 03:17:00 +08:00 committed by GitHub
commit 73151db726
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 253 additions and 1 deletions

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace {namespace};
class {class}
{
#[\BotCommand(match: '测试{name}')]
public function firstBotCommand(\BotContext $ctx): void
{
$ctx->reply('这是{name}插件的第一个命令!');
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
$plugin = new ZMPlugin(__DIR__);
/*
* 发送 "测试{name}",回复 "这是{name}插件的第一个命令!"
*/
$cmd1 = BotCommand::make('{name}', match: '测试{name}')->on(fn () => '这是{name}插件的第一个命令!');
$plugin->addBotCommand($cmd1);
return $plugin;

View File

@ -33,6 +33,11 @@ abstract class Command extends \Symfony\Component\Console\Command\Command
$this->input = $input;
$this->output = $output;
if ($this->shouldExecute()) {
if (property_exists($this, 'bootstrappers')) {
foreach ($this->bootstrappers as $bootstrapper) {
(new $bootstrapper())->bootstrap($this->input->getOptions());
}
}
return $this->handle();
}
return self::SUCCESS;

View File

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace ZM\Command\Plugin;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\Question;
use ZM\Bootstrap;
use ZM\Command\Command;
use ZM\Store\FileSystem;
use ZM\Utils\CodeGenerator\PluginGenerator;
#[AsCommand(name: 'plugin:make', description: '创建一个新的插件')]
class PluginMakeCommand extends Command
{
protected array $bootstrappers = [
BootStrap\RegisterLogger::class,
Bootstrap\SetInternalTimezone::class,
Bootstrap\LoadConfiguration::class,
];
protected function configure()
{
$this->addArgument('name', InputArgument::OPTIONAL, '插件名称', null);
$this->addOption('author', 'a', InputOption::VALUE_OPTIONAL, '作者名称', null);
$this->addOption('description', 'd', InputOption::VALUE_OPTIONAL, '插件描述', null);
$this->addOption('plugin-version', null, InputOption::VALUE_OPTIONAL, '插件版本', '1.0.0');
$this->addOption('type', 'T', InputOption::VALUE_OPTIONAL, '插件类型', null);
// 下面是 type=psr4 的选项
$this->addOption('namespace', null, InputOption::VALUE_OPTIONAL, '插件命名空间', null);
// 下面是辅助用的,和 server:start 一样
$this->addOption('config-dir', null, InputOption::VALUE_REQUIRED, '指定其他配置文件目录');
}
/**
* {@inheritDoc}
*/
protected function handle(): int
{
$load_dir = config('global.plugin.load_dir');
if (empty($load_dir)) {
$load_dir = SOURCE_ROOT_DIR . '/plugins';
} elseif (FileSystem::isRelativePath($load_dir)) {
$load_dir = SOURCE_ROOT_DIR . '/' . $load_dir;
}
$plugin_dir = zm_dir($load_dir);
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$other_plugins = is_dir($plugin_dir) ? FileSystem::scanDirFiles($plugin_dir, false, true, true) : [];
// 询问插件名称
if ($this->input->getArgument('name') === null) {
$question = new Question('<question>请输入插件名称:</question>');
$question->setValidator(function ($answer) use ($plugin_dir, $other_plugins) {
if (empty($answer)) {
throw new \RuntimeException('插件名称不能为空');
}
if (is_numeric(mb_substr($answer, 0, 1))) {
throw new \RuntimeException('插件名称不能以数字开头,且只能包含字母、数字、下划线、短横线');
}
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $answer)) {
throw new \RuntimeException('插件名称只能包含字母、数字、下划线、短横线');
}
if (is_dir($plugin_dir . '/' . strtolower($answer))) {
throw new \RuntimeException('插件目录已存在,请换个名字');
}
foreach ($other_plugins as $dir_name) {
$plugin_name = file_exists($plugin_dir . '/' . $dir_name . '/zmplugin.json') ? (json_decode(file_get_contents($plugin_dir . '/' . $dir_name . '/zmplugin.json'), true)['name'] ?? null) : null;
if ($plugin_name !== null && $plugin_name === $answer) {
throw new \RuntimeException('插件名称已存在,请换个名字');
}
}
return $answer;
});
$this->input->setArgument('name', $helper->ask($this->input, $this->output, $question));
}
// 询问插件类型
if ($this->input->getOption('type') === null) {
$question = new ChoiceQuestion(
'<question>请输入要生成的插件结构类型</question>',
['file' => 'file 类型为单文件,方便写简单功能', 'psr4' => 'psr4 类型为目录,按照 psr-4 结构生成,同时将生成 composer.json 用来支持自动加载']
);
$this->input->setOption('type', $helper->ask($this->input, $this->output, $question));
}
if ($this->input->getOption('type') === 'psr4') {
// 询问命名空间
if ($this->input->getOption('namespace') === null) {
$question = new Question('<question>请输入插件命名空间:</question>');
$question->setValidator(function ($answer) {
if (empty($answer)) {
throw new \RuntimeException('插件命名空间不能为空');
}
if (is_numeric(mb_substr($answer, 0, 1))) {
throw new \RuntimeException('插件命名空间不能以数字开头,且只能包含字母、数字、反斜线');
}
// 只能包含字母、数字和反斜线
if (!preg_match('/^[a-zA-Z0-9\\\\]+$/', $answer)) {
throw new \RuntimeException('插件命名空间只能包含字母、数字、反斜线');
}
return $answer;
});
$this->input->setOption('namespace', $helper->ask($this->input, $this->output, $question));
}
}
$generator = new PluginGenerator($this->input->getArgument('name'), $plugin_dir);
$generator->generate($this->input->getOptions());
$this->info('已生成插件:' . $this->input->getArgument('name'));
$this->info('目录位置:' . zm_dir($plugin_dir . '/' . $this->input->getArgument('name')));
return self::SUCCESS;
}
}

View File

@ -82,7 +82,7 @@ class FileSystem
*/
public static function createDir(string $path): void
{
if (!is_dir($path) && !mkdir($path, 0777, true) && !is_dir($path)) {
if (!is_dir($path) && !mkdir($path, 0755, true) && !is_dir($path)) {
throw new \RuntimeException(sprintf('无法建立目录:%s', $path));
}
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace ZM\Utils\CodeGenerator;
use ZM\Store\FileSystem;
/**
* Class PluginGenerator
* 插件脚手架生成器
*/
class PluginGenerator
{
public function __construct(private string $name, private string $plugin_dir)
{
}
/**
* 开始生成
*
* @param array $options 传入的命令行选项
*/
public function generate(array $options): void
{
// 先检查插件目录是否存在,不存在则创建
FileSystem::createDir($this->plugin_dir);
// 创建插件目录
$plugin_base_dir = $this->plugin_dir . '/' . $this->name;
FileSystem::createDir($plugin_base_dir);
// 这里开始写入 zmplugin.json
// 创建插件信息文件
$zmplugin['name'] = $this->name;
// 设置版本
if ($options['plugin-version'] !== null) {
$zmplugin['version'] = $options['plugin-version'];
}
// 设置作者
if ($options['author'] !== null) {
$zmplugin['author'] = $options['author'];
}
// 判断单文件还是 psr-4 类型
if ($options['type'] === 'file') {
// 设置入口文件为 main.php
$zmplugin['main'] = 'main.php';
}
// 到这里就可以写入文件了
file_put_contents(zm_dir($plugin_base_dir . '/zmplugin.json'), json_encode($zmplugin, JSON_PRETTY_PRINT));
// 接着写入 main.php
if ($options['type'] === 'file') {
$template = file_get_contents(zm_dir(FRAMEWORK_ROOT_DIR . '/src/Templates/main.php.template'));
$replace = ['{name}' => $this->name];
$main_php = str_replace(array_keys($replace), array_values($replace), $template);
file_put_contents(zm_dir($plugin_base_dir . '/main.php'), $main_php);
} else {
// 如果是 psr4 就复杂一点,但也不麻烦
// 先创建 src 目录
FileSystem::createDir($plugin_base_dir . '/src');
// 再创建 src/PluginMain.php
$template = file_get_contents(zm_dir(FRAMEWORK_ROOT_DIR . '/src/Templates/PluginMain.php.template'));
$replace = [
'{name}' => $this->name,
'{namespace}' => $options['namespace'],
'{class}' => $this->convertClassName(),
];
$main_php = str_replace(array_keys($replace), array_values($replace), $template);
file_put_contents(zm_dir($plugin_base_dir . '/src/PluginMain.php'), $main_php);
// 写入 composer.json
$composer_json = [
'autoload' => [
'psr-4' => [
$options['namespace'] . '\\' => 'src/',
],
],
];
file_put_contents(zm_dir($plugin_base_dir . '/composer.json'), json_encode($composer_json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
// TODO: 寻找 PHP 运行环境和 Composer 是否在当前目录的情况
chdir($plugin_base_dir);
passthru('composer dump-autoload');
chdir(WORKING_DIR);
}
}
/**
* 根据传入的名称,生成相应的驼峰类名
*/
public function convertClassName(): string
{
$name = $this->name;
$string = str_replace(['-', '_'], ' ', $name);
return str_replace(' ', '', ucwords($string));
}
}