Compare commits

...

29 Commits

Author SHA1 Message Date
crazywhalecc
e89d5ad1ac increment build number (build 654) 2022-12-31 08:14:31 +00:00
Jerry
11aed8c6be Merge pull request #217 from zhamao-robot/light-cache-update
新增全新的 LightCache
2022-12-31 16:13:37 +08:00
crazywhalecc
46d2f895e2 increment build number (build 653) 2022-12-31 07:45:20 +00:00
crazywhalecc
ed6b65eb88 cs fix 2022-12-31 15:45:09 +08:00
Jerry
8efb63a334 Merge pull request #216 from zhamao-robot/zm-request-update
新增 ZMRequest
2022-12-31 15:44:15 +08:00
crazywhalecc
13b5c44627 let getDriver() method always work 2022-12-31 15:36:33 +08:00
crazywhalecc
2a3c953c36 add ZMRequest support 2022-12-31 15:35:19 +08:00
crazywhalecc
fd8b3721ae add LightCache support 2022-12-31 15:27:09 +08:00
Jerry
3734f5d476 Merge pull request #215 from zhamao-robot/add-v3-sh
添加 v3 版本的一键安装脚本
2022-12-31 11:21:01 +08:00
crazywhalecc
088f963ad1 add v3.sh (and test my mac gpg) 2022-12-30 17:38:33 +08:00
crazywhalecc
c8a6bc69d3 increment build number (build 652) 2022-12-30 08:32:09 +00:00
Jerry
8584cc647c Merge pull request #214 from zhamao-robot/bot-action-and-some-update
拆分 Bot 动作到 Trait 以及更新一些类型强化的代码
2022-12-30 16:31:02 +08:00
crazywhalecc
d8ac604592 increment build number (build 651) 2022-12-30 08:16:55 +00:00
Jerry
9acb5e760b Merge pull request #213 from zhamao-robot/container-coroutine-support
让容器支持协程
2022-12-30 16:15:56 +08:00
crazywhalecc
96879bf415 update some doc and types 2022-12-30 16:15:22 +08:00
crazywhalecc
d7e815d670 fix windows CtrlC capture bug 2022-12-30 16:13:57 +08:00
crazywhalecc
dfcb8a4550 separate bot action method to BotActionTrait 2022-12-30 16:13:25 +08:00
crazywhalecc
96fa6b105c add coroutine for container 2022-12-30 11:30:29 +08:00
crazywhalecc
b86f51ab46 increment build number (build 650) 2022-12-30 03:23:40 +00:00
Jerry
8473a1152d Merge pull request #211 from zhamao-robot/fix-bot-context-not-sync
修复 BotContext 不同步问题
2022-12-30 11:22:43 +08:00
Jerry
65acfaa0bd Merge pull request #212 from zhamao-robot/add-event-docs
加(一些)事件文档
2022-12-30 10:06:34 +08:00
sunxyw
c58e08998a add event docs 2022-12-29 17:52:21 +08:00
sunxyw
9cf905421e fix bot context not sync 2022-12-29 14:58:18 +08:00
sunxyw
c65694402f increment build number (build 649) 2022-12-28 14:06:04 +00:00
sunxyw
0d24ae6192 add command manual plugin (#210) 2022-12-28 22:05:03 +08:00
sunxyw
383e0e22af increment build number (build 648) 2022-12-27 12:40:06 +00:00
sunxyw
87840930e0 use solution repository instead of built-in (#209)
* use solution repository instead of built-in

* suppress static analysis
2022-12-27 20:39:24 +08:00
sunxyw
05b3321af7 add windows entry binary (#208) 2022-12-27 15:39:36 +08:00
crazywhalecc
19e380d1fb increment build number (build 647) 2022-12-26 13:01:15 +00:00
47 changed files with 1299 additions and 118 deletions

View File

@@ -41,3 +41,11 @@ jobs:
ssh_private_key: ${{ secrets.ZHAMAO_XIN_PRIVATE_KEY }}
local_path: './ext/go.sh'
remote_path: ${{ secrets.ZHAMAO_XIN_MAIN_TARGET }}
- name: deploy script file
uses: wlixcc/SFTP-Deploy-Action@v1.2
with:
username: ${{ secrets.ZHAMAO_XIN_USER }}
server: ${{ secrets.ZHAMAO_XIN_HOST }}
ssh_private_key: ${{ secrets.ZHAMAO_XIN_PRIVATE_KEY }}
local_path: './ext/v3.sh'
remote_path: ${{ secrets.ZHAMAO_XIN_MAIN_TARGET }}

2
.gitignore vendored
View File

@@ -9,7 +9,7 @@
/site/
/plugins/
/doxy/
/walle/
# 框架审计文件
audit.log

View File

@@ -16,7 +16,7 @@ use ZM\Logger\ConsoleLogger;
use ZM\Store\MockAtomic;
// 引入自动加载
require dirname(__DIR__) . '/vendor/autoload.php';
require $_composer_autoload_path ?? dirname(__DIR__) . '/vendor/autoload.php';
// 声明一个全局的原子计数,用于保存整个进程的退出状态码
global $_swoole_atomic;

27
bin/zhamao.bat Normal file
View File

@@ -0,0 +1,27 @@
@echo off
@REM Check if ZM_CUSTOM_PHP_PATH is set
IF /i "%ZM_CUSTOM_PHP_PATH%" neq "" (
@REM Set the path to the custom PHP
echo "* Using custom PHP executable: %ZM_CUSTOM_PHP_PATH%"
SET executable=%ZM_CUSTOM_PHP_PATH%
) ELSE IF exist ./runtime/php.exe (
@REM Set the path to the built-in PHP
echo "* Using built-in PHP executable"
SET executable=.\runtime\php.exe
) ELSE (
@REM Set the path to the system PHP
echo "* Using system PHP executable"
SET executable=php
)
@REM TODO: Phar write support is missing
IF exist src/entry.php (
@REM Run the PHP entry point
%executable% src/entry.php %*
) ELSE IF exist vendor/zhamao/framework/src/entry.php (
@REM Run the PHP entry point
%executable% vendor/zhamao/framework/src/entry.php %*
) ELSE (
@REM No entry point found
echo "[ErrCode:E00015] Cannot find zhamao-framework entry file!"
exit /b 1
)

View File

@@ -73,7 +73,6 @@
}
},
"bin": [
"bin/gendoc",
"bin/phpunit-zm",
"bin/zhamao"
],

View File

@@ -18,12 +18,6 @@ $config['servers'] = [
'type' => 'http',
'flag' => 20002,
],
[
'host' => '0.0.0.0',
'port' => 20003,
'type' => 'http',
'flag' => 20003,
],
];
/* Workerman 驱动相关配置 */
@@ -46,7 +40,7 @@ $config['swoole_options'] = [
];
/* 默认存取炸毛数据的目录相对目录时代表WORKING_DIR下的目录绝对目录按照绝对目录来 */
$config['data_dir'] = 'zm_data';
$config['data_dir'] = WORKING_DIR . '/zm_data';
/* 框架本体运行时的一些可调配置 */
$config['runtime'] = [
@@ -73,6 +67,7 @@ $config['plugin'] = [
$config['native_plugin'] = [
'onebot12' => true, // OneBot v12 协议支持
'onebot12-ban-other-ws' => true, // OneBot v12 协议支持,禁止其他 WebSocket 连接
'command-manual' => true,
];
/* 静态文件读取器 */
@@ -107,4 +102,11 @@ $config['database'] = [
],
];
/* KV 数据库的配置 */
$config['kv'] = [
'use' => \LightCache::class, // 默认在单进程模式下使用 LightCache多进程需要使用 ZMRedis
'light_cache_dir' => $config['data_dir'] . '/lc', // 默认的 LightCache 保存持久化数据的位置
'light_cache_autosave_time' => 600, // LightCache 自动保存时间(秒)
];
return $config;

View File

@@ -26,6 +26,7 @@ module.exports = {
activeHeaderLinks: false,
nav: [
{ text: '指南', link: '/guide/' },
{ text: '事件', link: '/event/' },
{ text: 'API 文档', link: '/doxy/', target: '_blank' },
{ text: '炸毛框架 v2', link: 'https://docs-v2.zhamao.xin/' }
],
@@ -44,6 +45,21 @@ module.exports = {
]
}
],
'/event/': [
{
title: '事件',
collapsable: false,
sidebarDepth: 1,
children: [
'',
'bot',
'http',
'middleware',
'framework',
'extend',
]
}
]
}
}
}

25
docs/event/README.md Normal file
View File

@@ -0,0 +1,25 @@
# 入门
## 事件是什么
简单来说,事件是一个底层的 Event Loop 收到消息后调用对应的方法的一个模型,比如给机器人发送消息后框架会调用你定义的方法来执行你的业务代码。
## 属性和注解是什么
属性Attribute是 PHP 8 最大的新变化之一,是 PHP 官方支持的、内置的注解实现,允许我们通过编程方式获取对应的元数据,可以大大方便我们对某一类代码进行处理。
而注解Annotation则是在 PHP 尚未支持属性的时代,用来代替的社区实现方案,通过解析 PHPDoc 注释来实现自己的注解机制。
炸毛框架同时支持注解和属性,在文档当中,有时会混用两者的字眼,在大多数情况下都可以安全地交换使用,例如 `#[BotEvent]``@BotEvent` 的行为是完全一致的。
## 注解和事件的关系
在炸毛框架中,注解是事件分发的一个重要角色,但注解本身并非事件,更恰当地说,注解代表了事件。
无论是机器人开发过程中场景的 `#[BotCommand]` 或是 HTTP 服务的路由 `#[RequestMapping]` 都是注解代表事件的例子。
## 阻断事件分发
在炸毛框架中,事件由一个统一的事件分发器进行分发,你可以在任意事件中阻断所有后续的分发。
(待考)

86
docs/event/bot.md Normal file
View File

@@ -0,0 +1,86 @@
# 机器人事件
<aside>
🛰️ 此页面下的所有注解命名空间为 `ZM\Annotation\OneBot`
</aside>
> 在使用注解绑定事件时,如果不存在 **必需** 参数,可一个参数都不写,效果就是此事件在任何情况下都会调用此方法,例如 `#[BotEvent()]` 会在收到任意机器人事件时调用。
>
## BotAction
啊?
| 参数名称 | 允许值 | 用途 | 默认 |
| --- | --- | --- | --- |
| action | string | 动作名称 | “” |
| need_response | string | 动作是否需要响应 | false |
| level | int | 事件优先级(越大越先执行) | 20 |
## BotActionResponse
啊??
| 参数名称 | 允许值 | 用途 | 默认 |
| --- | --- | --- | --- |
| retcode | int | 响应码 | null |
| level | int | 事件优先级(越大越先执行) | 20 |
## BotEvent
用于处理所有的机器人事件,具体的参数含义可以参见 [https://12.onebot.dev/connect/data-protocol/event/](https://12.onebot.dev/connect/data-protocol/event/)。
| 参数名称 | 允许值 | 用途 | 默认 |
| --- | --- | --- | --- |
| type | string | 对应标准中的事件类型 | null |
| detail_type | string | 对应标准中的事件详细类型 | null |
| sub_type | string | 对应标准中的事件子类型 | null |
| level | int | 事件优先级(越大越先执行) | 20 |
## BotCommand
对于 `BotEvent` 的封装,用于支持常用的命令式调用(如:”天气 深圳”)。
| 参数名称 | 允许值 | 用途 | 默认 |
| --- | --- | --- | --- |
| name | string | 命令名称,应全局唯一 | “” |
| match | string | 匹配第一个词的命令式消息,如 天气 北京 中的 天气 | “” |
| pattern | string | 根据 * 号通配符进行模式匹配用户消息,如 查询*天气 | “” |
| regex | string 合法的正则表达式 | 匹配正则表达式匹配到的用户消息 | “” |
| start_with | string | 匹配消息开头相匹配的消息,如 我叫炸毛,这里写 我叫 | “” |
| end_with | string | 匹配消息结尾相匹配的消息,以 start_with 类推 | “” |
| keyword | string | 匹配消息中有相关关键词的消息 | “” |
| alias | string | match 匹配到命令的别名,数组形式 | [] |
| detail_type | string | 限定消息事件的详细类型,见 BotEvent | “” |
| prefix | string | | |
| level | int | 事件优先级(越大越先执行) | 20 |
> 机器人命令注册的实例可参见【一堆例子链接】
>
## CommandArgument
与 BotCommand 搭配使用,可以自动识别参数及生成帮助。
| 参数名称 | 允许值 | 用途 | 默认 |
| --- | --- | --- | --- |
| name | string | 参数名称 | “” |
| description | string | 参数描述 | “” |
| required | bool | 参数是否必需 | false |
| prompt | string | 当参数缺失时请求用户输入时提示的消息(需要参数设为必需) | “请输入{$name}” |
| default | mixed | 当参数非必需且未填入时的默认值 | null |
| timeout | int 单位秒 | 请求输入超时时间 | 60 |
## CommandHelp
与 BotCommand 搭配使用,可以补充生成的帮助信息。
| 参数名称 | 允许值 | 用途 | 默认 |
| --- | --- | --- | --- |
| description | string | 命令描述 | “” |
| usage | string | 命令用法 | “” |
| example | string | 命令示例 | “” |
> 关于自动帮助生成的更多信息,请参见 【这里链接】
>

3
docs/event/extend.md Normal file
View File

@@ -0,0 +1,3 @@
# 扩展事件分发器
TODO

31
docs/event/framework.md Normal file
View File

@@ -0,0 +1,31 @@
# 框架事件
<aside>
🛰️ 此页面下的所有注解命名空间为 `ZM\Annotation\Framework`
</aside>
## BindEvent
相对底层的事件绑定,支持绑定所有透过框架分发的事件。
| 参数名称 | 允许值 | 用途 | 默认 |
| --- | --- | --- | --- |
| event_class | string | 时间名 | 必填 |
| level | int | 事件优先级(越大越先执行) | 800 |
## Init
在 Worker 进程初始化时触发,用于进行 Worker 初始化。
| 参数名称 | 允许值 | 用途 | 默认 |
| --- | --- | --- | --- |
| worker | int 由 0 至 (最大Worker数-1) | 限定执行的 Worker 进程,-1 为在所有 Worker 执行 | 0 |
## Setup
在框架初始化时触发,在主进程执行,不可使用协程相关功能。
可用于改变所有进程的设置,相关更改会随着进程创建应用到所有 Worker 和 Manager 进程。
*没有参数*

25
docs/event/http.md Normal file
View File

@@ -0,0 +1,25 @@
# 路由事件
<aside>
🛰️ 此页面下的所有注解命名空间为 `ZM\Annotation\Http`
</aside>
## Controller
对同一类下的路由进行修饰,只可在类上使用。
| 参数名称 | 允许值 | 用途 | 默认 |
| --- | --- | --- | --- |
| prefix | string | 路由前缀,应用到类下的所有路由 | 必填 |
## Route
路由事件,当对应的路由收到请求时触发。
| 参数名称 | 允许值 | 用途 | 默认 |
| --- | --- | --- | --- |
| route | string | 路由 | 必填 |
| name | string | 路由名称 | “” |
| request_method | array<string> | 允许的请求方法 | [GET, POST] |
| params | array<string, string> | 路由参数 | [] |

18
docs/event/middleware.md Normal file
View File

@@ -0,0 +1,18 @@
# 中间件事件
<aside>
🛰️ 此页面下的所有注解命名空间为 `ZM\Annotation\Middleware`
</aside>
## Middleware
当绑定了此中间件的方法被触发时触发。
| 参数名称 | 允许值 | 用途 | 默认 |
| --- | --- | --- | --- |
| name | string | 中间件名称 | 必填 |
| params | array<mixed> | 中间件参数 | [] |
> 关于中间件的具体用法,请参见【再来链接】
>

271
ext/v3.sh Executable file
View File

@@ -0,0 +1,271 @@
#!/usr/bin/env bash
# 支持的环境变量:
# ZM_DOWN_PHP_VERSION php版本默认为8.0
# ZM_NO_LOCAL_PHP 如果填入任意内容则不检查本地PHP直接安装内建PHP仅限Linux
# ZM_TEMP_DIR 脚本下载内建PHP和Composer的临时目录默认为/tmp/.zm-runtime
# ZM_CUSTOM_DIR 自定义新建目录名称默认为zhamao-v3
# ZM_COMPOSER_PACKAGIST 是否使用Composer的国外源默认使用国内阿里云如果设置并填写了内容则自动使用Composer的国外源
ZM_PWD=$(pwd)
_cyan="\033[0;36m"
_reset="\033[0m"
# 彩头
function nhead() {
if [ "$1" = "red" ]; then
echo -ne "\033[0;31m[!]"
elif [ "$1" = "yellow" ]; then
echo -ne "\033[0;33m[?]"
else
echo -ne "\033[0;32m[*]"
fi
echo -e "\033[0m"
}
# 下载文件 $1 到目录 $2自动选择使用 curl 或者 wget
function download_file() {
downloader="wget"
type wget >/dev/null 2>&1 || { downloader="curl"; }
if [ "$downloader" = "wget" ]; then
_down_prefix="O"
else
_down_prefix="o"
fi
_down_symbol=0
if [ ! -f "$2" ]; then
echo -ne "$(nhead) 正在下载 $1 ... "
$downloader "$1" -$_down_prefix "$2" >/dev/null 2>&1 && echo "完成!" && _down_symbol=1
else
echo "已存在!" && _down_symbol=1
fi
if [ $_down_symbol == 0 ]; then
echo "$(nhead red) 下载失败!请检查网络连接!"
rm -rf "$2"
return 1
fi
return 0
}
# 安装下载内建PHP
function install_native_php() {
ZM_PHP_VERSION="8.1"
if [ "$ZM_DOWN_PHP_VERSION" != "" ]; then
ZM_PHP_VERSION="$ZM_DOWN_PHP_VERSION"
fi
echo "$(nhead) 使用的内建 PHP 版本: $ZM_PHP_VERSION"
rm -rf "$ZM_TEMP_DIR"
mkdir "$ZM_TEMP_DIR" >/dev/null 2>&1
if [ ! -f "$ZM_TEMP_DIR/php" ]; then
download_file "https://dl.zhamao.xin/php-bin/down.php?php_ver=$ZM_PHP_VERSION&arch=$(uname -m)" "$ZM_TEMP_DIR/php.tgz" || return 1
tar -xf "$ZM_TEMP_DIR/php.tgz" -C "$ZM_TEMP_DIR/" && rm -rf "$ZM_TEMP_DIR/php.tgz"
fi
echo "$(nhead) 安装内建 PHP 完成!"
php_executable="$ZM_TEMP_DIR/php"
return 0
}
# 安装下载Composer
function install_native_composer() {
if [ ! -f "$ZM_TEMP_DIR/composer.phar" ]; then
# 下载 composer.phar
download_file "https://mirrors.aliyun.com/composer/composer.phar" "$ZM_TEMP_DIR/composer.phar" || return 1
if [ "$php_executable" = "$ZM_TEMP_DIR/php" ]; then
# shellcheck disable=SC2016
txt='#!/usr/bin/env sh
if [ -f "$(dirname $0)/php" ]; then
"$(dirname $0)/php" "$(dirname $0)/composer.phar" $*
else
php "$(dirname $0)/composer.phar" $*
fi'
echo "$txt" >"$ZM_TEMP_DIR/composer"
chmod +x "$ZM_TEMP_DIR/composer"
else
mv "$ZM_TEMP_DIR/composer.phar" "$ZM_TEMP_DIR/composer" && chmod +x "$ZM_TEMP_DIR/composer"
fi
fi
echo "$(nhead) 安装内建 Composer 完成!"
composer_executable="$ZM_TEMP_DIR/composer"
return 0
}
# 检查Composer可用性
function composer_check() {
# 顺带检查一下
echo "$(nhead) 正在检查 Git、unzip、7z 能否正常使用 ... "
type git >/dev/null 2>&1 || {
echo "$(nhead red) 检测到系统不存在 git 命令,可能无法正常使用 Composer 下载 GitHub 等仓库项目!"
}
zip_check_symbol=0
if type unzip >/dev/null 2>&1; then
zip_check_symbol=1
fi
if type 7z >/dev/null 2>&1; then
zip_check_symbol=1
fi
if [ $zip_check_symbol -eq 0 ]; then
if [ "$($php_executable -m | grep zip)" = "" ]; then
echo "$(nhead red) 检测到系统不存在 unzip 或 7z 命令PHP 不存在 zip 扩展,可能无法正常使用 Composer 下载压缩包项目!"
fi
fi
# 测试 Composer 和 PHP 是否能正常使用
if [ "$("$composer_executable" -n about | grep Manager)" = "" ]; then
echo "$(nhead red) Download PHP binary and composer failed!"
return 1
fi
echo "$(nhead) 环境检查完成!"
return 0
}
# 环境检查
function darwin_env_check() {
echo -ne "$(nhead) 检查是否存在 PHP ... "
if type php >/dev/null 2>&1; then
php_executable=$(which php)
echo "位置:$php_executable"
if [ "$($php_executable -m | grep swoole)" = "" ]; then
echo "$(nhead red) PHP 不存在 swoole 扩展,可能无法正常使用 Swoole 框架!" && return 1
fi
else
echo "不存在"
if type brew >/dev/null 2>&1; then
echo -n "$(nhead yellow) 是否使用 Homebrew 安装 PHP[y/N] "
read -r y
if [ "$y" = "" ]; then y="N"; fi
if [ "$y" = "y" ]; then
brew install php || echo "$(nhead red) 安装 PHP 失败!" && return 1
else
echo "$(nhead red) 跳过安装 PHP" && return 1
fi
fi
fi
echo -ne "$(nhead) 检查是否存在 Composer ... "
if type composer >/dev/null 2>&1; then
composer_executable=$(which composer)
echo "位置:$composer_executable"
else
echo "不存在,正在下载 Composer ..."
install_native_composer || return 1
fi
composer_check || return 1
}
# 询问是否安装 native php
function prompt_install_native_php() {
echo -ne "$(nhead yellow) 检测到系统的 PHP 不存在 swoole 扩展,是否下载安装独立的内建 PHP 和 Composer[Y/n] "
read -r y
case $y in
Y|y|"") return 0 ;;
*) echo "$(nhead red) 跳过安装内建 PHP" && return 1 ;;
esac
}
# 环境检查
function linux_env_check() {
if [ "$ZM_NO_LOCAL_PHP" != "" ]; then # 如果指定了不使用本地 php则不检查直接下载
install_native_php && install_native_composer && composer_check && return 0 || return 1
else
echo -ne "$(nhead) 检查是否存在 PHP ... "
if type php >/dev/null 2>&1; then
php_executable=$(which php)
echo "位置:$php_executable"
if [ "$($php_executable -m | grep swoole)" = "" ]; then
echo "$(nhead red) PHP 不存在 swoole 扩展,可能无法正常使用 Swoole 框架!" && \
prompt_install_native_php && \
install_native_php && \
install_native_composer && composer_check && return 0 || return 1
fi
else
echo "不存在,将下载内建 PHP"
install_native_php || return 1
fi
fi
echo -ne "$(nhead) 检查是否存在 Composer ... "
if type composer >/dev/null 2>&1; then
composer_executable=$(which composer)
echo "位置:$composer_executable"
else
echo "不存在,将下载内建 Composer"
install_native_composer || return 1
fi
composer_check || return 1
}
function if_use_aliyun() {
if [ "$ZM_COMPOSER_PACKAGIST" = "" ]; then
$composer_executable -n config repos.packagist composer https://mirrors.aliyun.com/composer
fi
}
function if_restore_native_runtime() {
ZM_RUNTIME_DIR="$ZM_PWD/$ZM_CUSTOM_DIR/runtime/"
if [ "$php_executable" = "$ZM_TEMP_DIR/php" ]; then
echo "$(nhead) 移动内建 PHP 到框架目录 $ZM_RUNTIME_DIR ..." && \
mkdir -p "$ZM_RUNTIME_DIR" && \
mv "$ZM_TEMP_DIR/php" "$ZM_RUNTIME_DIR" || {
echo "$(nhead red) 移动内建 PHP 到框架目录失败!" && return 1
}
php_executable="$ZM_RUNTIME_DIR/php"
fi
if [ "$composer_executable" = "$ZM_TEMP_DIR/composer" ]; then
echo "$(nhead) 移动内建 Composer 到框架目录 $ZM_RUNTIME_DIR ..." && \
mkdir -p "$ZM_CUSTOM_DIR/runtime" && \
mv "$ZM_TEMP_DIR/composer" "$ZM_RUNTIME_DIR" && \
mv "$ZM_TEMP_DIR/composer.phar" "$ZM_RUNTIME_DIR" || {
echo "$(nhead red) 移动内建 Composer 到框架目录失败!" && return 1
}
composer_executable="$ZM_RUNTIME_DIR/composer"
fi
return 0
}
function install_framework() {
echo "$(nhead) 开始安装框架到目录 $ZM_CUSTOM_DIR ..."
export COMPOSER_ALLOW_SUPERUSER=1
mkdir -p "$ZM_PWD/$ZM_CUSTOM_DIR" && \
cd "$ZM_PWD/$ZM_CUSTOM_DIR" && \
$composer_executable init --name="zhamao/zhamao-v3-app" -n -q && \
if_use_aliyun && \
echo "$(nhead) 从 Composer 拉取框架 ..." && \
echo '{"minimum-stability":"dev"}' > composer.json && composer require -n -q zhamao/framework:^3 && \
$composer_executable require -n -q --dev swoole/ide-helper:^4.5 && \
if_restore_native_runtime && \
echo "$(nhead) 初始化框架脚手架文件 ..." && \
vendor/bin/zhamao init >/dev/null 2>&1 && \
$composer_executable dump-autoload -n -q && \
show_success_msg || {
echo "$(nhead red) 安装框架失败!" && cd $ZM_PWD && rm -rf "$ZM_CUSTOM_DIR" && return 1
}
}
function show_success_msg() {
echo -e "$(nhead) 框架安装成功,已安装到目录 $ZM_CUSTOM_DIR" && \
echo -e "$(nhead) 进入应用目录:""$_cyan""cd $ZM_CUSTOM_DIR""$_reset" && \
echo -e "$(nhead) 启动框架命令:""$_cyan""./zhamao server""$_reset" && \
echo -e "$(nhead) 生成插件脚手架目录的命令:""$_cyan""./zhamao plugin:make""$_reset"
}
# 环境变量设置
test "$ZM_TEMP_DIR" = "" && ZM_TEMP_DIR="/tmp/.zm-runtime"
test "$ZM_CUSTOM_DIR" = "" && ZM_CUSTOM_DIR="zhamao-v3"
if [ -d "$ZM_PWD/$ZM_CUSTOM_DIR" ]; then
echo "$(nhead red) 检测到目录 $ZM_CUSTOM_DIR/ 已安装过框架,请更换文件夹名称或删除旧文件夹再试!"
exit 1
fi
# 检查系统环境,目前只支持 Linux
case $(uname -s) in
Linux) linux_env_check || exit 1 ;;
Darwin) darwin_env_check || exit 1 ;;
*) echo "$(nhead red) Only support Linux and macOS!" && exit 1 ;;
esac
# 安装框架
install_framework

View File

@@ -4,6 +4,8 @@ parameters:
paths:
- ./src/
- ./tests/
excludePaths:
- ./src/ZM/Exception/Solution/SolutionRepository.php
ignoreErrors:
- '#Constant .* not found#'
- '#PHPDoc tag @throws with type Psr\\Container\\ContainerExceptionInterface is not subtype of Throwable#'

View File

@@ -15,6 +15,7 @@ class_alias(\ZM\Annotation\Closed::class, 'Closed');
class_alias(\ZM\Plugin\ZMPlugin::class, 'ZMPlugin');
class_alias(\OneBot\V12\Object\OneBotEvent::class, 'OneBotEvent');
class_alias(\ZM\Context\BotContext::class, 'BotContext');
class_alias(\ZM\Store\KV\LightCache::class, 'LightCache');
// 下面是 OneBot 相关类的全局别称
class_alias(\OneBot\Driver\Event\WebSocket\WebSocketOpenEvent::class, 'WebSocketOpenEvent');

View File

@@ -6,14 +6,15 @@ 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\ContainerHolder;
use ZM\Logger\ConsoleLogger;
use ZM\Middleware\MiddlewareHandler;
use ZM\Store\Database\DBException;
use ZM\Store\Database\DBQueryBuilder;
use ZM\Store\Database\DBWrapper;
use ZM\Store\KV\KVInterface;
// 防止重复引用引发报错
if (function_exists('zm_internal_errcode')) {
@@ -210,7 +211,7 @@ function db(string $name = '')
*
* @throws DBException
*/
function sql_builder(string $name = '')
function sql_builder(string $name = ''): DBQueryBuilder
{
return (new DBWrapper($name))->createQueryBuilder();
}
@@ -241,10 +242,17 @@ function config(array|string $key = null, mixed $default = null)
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']);
if (container()->has(ZM\Context\BotContext::class)) {
return container()->get(ZM\Context\BotContext::class);
}
return new \ZM\Context\BotContext('', '');
}
function kv(string $name = ''): KVInterface
{
global $kv_class;
if (!$kv_class) {
$kv_class = config('global.kv.use', \LightCache::class);
}
return $kv_class::open($name);
}

View File

@@ -7,7 +7,11 @@ namespace Module\Example;
use OneBot\Driver\Event\WebSocket\WebSocketMessageEvent;
use ZM\Annotation\Http\Route;
use ZM\Annotation\Middleware\Middleware;
use ZM\Annotation\OneBot\BotCommand;
use ZM\Annotation\OneBot\BotEvent;
use ZM\Annotation\OneBot\CommandArgument;
use ZM\Annotation\OneBot\CommandHelp;
use ZM\Context\BotContext;
use ZM\Middleware\TimerMiddleware;
class Hello123
@@ -24,4 +28,12 @@ class Hello123
{
logger()->info("收到了 {$event->getType()}.{$event->getDetailType()} 事件");
}
#[BotCommand('echo', 'echo')]
#[CommandArgument('text', '要回复的内容', required: true)]
#[CommandHelp('复读机', '只需要发送 echo+内容 即可自动复读', 'echo 你好 会回复 你好')]
public function repeat(\OneBotEvent $event, BotContext $context): void
{
$context->reply($event->getMessage());
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace ZM\Annotation\OneBot;
use Doctrine\Common\Annotations\Annotation\NamedArgumentConstructor;
use Doctrine\Common\Annotations\Annotation\Target;
use ZM\Annotation\AnnotationBase;
/**
* 机器人指令帮助注解
*
* @Annotation
* @NamedArgumentConstructor()
* @Target("METHOD")
*/
#[\Attribute(\Attribute::TARGET_METHOD)]
class CommandHelp extends AnnotationBase
{
public function __construct(
public string $description,
public string $usage,
public string $example,
) {
}
public static function make(
string $description,
string $usage,
string $example,
): CommandHelp {
return new static(...func_get_args());
}
}

View File

@@ -50,13 +50,13 @@ class InitCommand extends Command
$section->write('<fg=gray>更新 composer.json ... </>');
if (!file_exists($this->base_path . '/composer.json')) {
throw new InitException('未找到 composer.json 文件', '请检查当前目录是否为项目根目录', 41);
throw new InitException('未找到 composer.json 文件', 41);
}
try {
$composer = json_decode(file_get_contents($this->base_path . '/composer.json'), true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new InitException('解析 composer.json 文件失败', '请检查 composer.json 文件是否存在语法错误', 42, $e);
throw new InitException('解析 composer.json 文件失败', 42, $e);
}
if (!isset($composer['autoload'])) {
@@ -68,7 +68,7 @@ class InitCommand extends Command
try {
file_put_contents($this->base_path . '/composer.json', json_encode($composer, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
} catch (\JsonException $e) {
throw new InitException('写入 composer.json 文件失败', '', 0, $e);
throw new InitException('写入 composer.json 文件失败', 0, $e);
}
$section->writeln('<info>完成</info>');
@@ -146,7 +146,7 @@ class InitCommand extends Command
if (file_exists($phar_link . '/vendor/autoload.php')) {
$this->base_path = $current_dir;
} else {
throw new InitException('框架启动模式不是 Composer 模式,无法进行初始化', '如果您是从 Github 下载的框架,请参阅文档进行源码模式启动', 42);
throw new InitException('框架启动模式不是 Composer 模式,无法进行初始化', 42);
}
}
}
@@ -166,11 +166,11 @@ class InitCommand extends Command
&& !mkdir($concurrent_dir = $this->base_path . $info['dirname'], 0777, true)
&& !is_dir($concurrent_dir)
) {
throw new InitException("无法创建目录 {$concurrent_dir}", '请检查目录权限');
throw new InitException("无法创建目录 {$concurrent_dir}");
}
if (copy($this->getVendorPath($file), $this->base_path . $file) === false) {
throw new InitException("无法复制文件 {$file}", '请检查目录权限');
throw new InitException("无法复制文件 {$file}");
}
}

View File

@@ -7,22 +7,26 @@ namespace ZM\Container;
use DI;
use DI\Container;
use DI\ContainerBuilder;
use OneBot\Driver\Coroutine\Adaptive;
class ContainerHolder
{
private static ?Container $container = null;
/** @var Container[] */
private static array $container = [];
public static function getEventContainer(): Container
{
if (self::$container === null) {
self::$container = self::buildContainer();
$cid = Adaptive::getCoroutine()?->getCid() ?? -1;
if (!isset(self::$container[$cid])) {
self::$container[$cid] = self::buildContainer();
}
return self::$container;
return self::$container[$cid];
}
public static function clearEventContainer(): void
{
self::$container = null;
$cid = Adaptive::getCoroutine()?->getCid() ?? -1;
unset(self::$container[$cid]);
}
private static function buildContainer(): Container

View File

@@ -26,8 +26,16 @@ class ContainerRegistrant
self::addServices([
OneBotEvent::class => $event,
'bot.event' => DI\get(OneBotEvent::class),
BotContext::class => fn () => bot(),
]);
if (isset($event->self['platform'])) {
self::addServices([
BotContext::class => DI\autowire(BotContext::class)->constructor(
$event->self['user_id'] ?? '',
$event->self['platform'],
),
]);
}
}
/**

View File

@@ -4,22 +4,21 @@ declare(strict_types=1);
namespace ZM\Context;
use Choir\Http\HttpFactory;
use OneBot\Driver\Event\Http\HttpRequestEvent;
use OneBot\Driver\Event\WebSocket\WebSocketMessageEvent;
use OneBot\Util\Utils;
use OneBot\V12\Object\Action;
use OneBot\V12\Object\MessageSegment;
use OneBot\V12\Object\OneBotEvent;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use ZM\Annotation\AnnotationHandler;
use ZM\Annotation\OneBot\BotAction;
use ZM\Context\Trait\BotActionTrait;
use ZM\Exception\OneBot12Exception;
use ZM\Utils\MessageUtil;
class BotContext implements ContextInterface
{
use BotActionTrait;
private static array $echo_id_list = [];
private array $self;
@@ -28,9 +27,10 @@ class BotContext implements ContextInterface
private bool $replied = false;
public function __construct(string $bot_id, string $platform)
public function __construct(string $bot_id, string $platform, null|WebSocketMessageEvent|HttpRequestEvent $event = null)
{
$this->self = ['user_id' => $bot_id, 'platform' => $platform];
$this->base_event = $event;
}
public function getEvent(): OneBotEvent
@@ -82,21 +82,9 @@ class BotContext implements ContextInterface
*/
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);
}
/**
* 设置该消息下解析出来的参数列表
*
@@ -134,39 +122,4 @@ class BotContext implements ContextInterface
{
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);
container()->set(Action::class, $a);
$handler->setRuleCallback(fn (BotAction $act) => $act->action === '' || $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.');
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace ZM\Context\Trait;
use Choir\Http\HttpFactory;
use OneBot\Driver\Event\Http\HttpRequestEvent;
use OneBot\Driver\Event\WebSocket\WebSocketMessageEvent;
use OneBot\Util\Utils;
use OneBot\V12\Object\Action;
use OneBot\V12\Object\ActionResponse;
use OneBot\V12\Object\MessageSegment;
use ZM\Annotation\AnnotationHandler;
use ZM\Annotation\OneBot\BotAction;
use ZM\Exception\OneBot12Exception;
use ZM\Utils\MessageUtil;
trait BotActionTrait
{
private null|WebSocketMessageEvent|HttpRequestEvent $base_event;
/**
* @throws \Throwable
*/
public function sendMessage(\Stringable|array|MessageSegment|string $message, string $detail_type, array $params = []): ActionResponse|bool
{
$message = MessageUtil::convertToArr($message);
$params['message'] = $message;
$params['detail_type'] = $detail_type;
return $this->sendAction(Utils::camelToSeparator(__FUNCTION__), $params, $this->self);
}
/**
* 发送机器人动作
*
* @throws \Throwable
*/
public function sendAction(string $action, array $params = [], ?array $self = null): bool|ActionResponse
{
// 声明 Action 对象
$a = new Action($action, $params, ob_uuidgen(), $self);
self::$echo_id_list[$a->echo] = $a;
// 调用事件在回复之前的回调
$handler = new AnnotationHandler(BotAction::class);
container()->set(Action::class, $a);
$handler->setRuleCallback(fn (BotAction $act) => $act->action === '' || $act->action === $action && !$act->need_response);
$handler->handleAll($a);
// 被阻断时候,就不发送了
if ($handler->getStatus() === AnnotationHandler::STATUS_INTERRUPTED) {
return false;
}
// 调用机器人连接发送 Action
if ($this->base_event instanceof WebSocketMessageEvent) {
$result = $this->base_event->send(json_encode($a->jsonSerialize()));
}
if (!isset($result) && container()->has('ws.message.event')) {
$result = container()->get('ws.message.event')->send(json_encode($a->jsonSerialize()));
}
// 如果是 HTTP WebHook 的形式,那么直接调用 Response
if (!isset($result) && $this->base_event instanceof HttpRequestEvent) {
$response = HttpFactory::createResponse(headers: ['Content-Type' => 'application/json'], body: json_encode([$a->jsonSerialize()]));
$this->base_event->withResponse($response);
$result = true;
}
if (!isset($result) && container()->has('http.request.event')) {
$response = HttpFactory::createResponse(headers: ['Content-Type' => 'application/json'], body: json_encode([$a->jsonSerialize()]));
container()->get('http.request.event')->withResponse($response);
$result = true;
}
if (isset($result)) {
return $result;
}
/* TODO: 协程支持
if (($result ?? false) === true && ($co = Adaptive::getCoroutine()) !== null) {
return $result ?? false;
}*/
throw new OneBot12Exception('No bot connection found.');
}
}

View File

@@ -119,10 +119,10 @@ class SignalListener
}
echo "\r";
logger()->notice('请再按 {count} 次 Ctrl+C 以强制杀死进程', ['count' => 5 - self::$manager_kill_time]);
return;
}
++self::$manager_kill_time;
if (self::$manager_kill_time === 1) {
logger()->notice('Keyboard interrupt, shutting down server...');
Framework::getInstance()->stop();
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ZM\Event\Listener;
use OneBot\Driver\Coroutine\Adaptive;
use OneBot\Driver\Process\ProcessManager;
use OneBot\Util\Singleton;
use ZM\Annotation\AnnotationHandler;
@@ -12,12 +13,14 @@ use ZM\Annotation\AnnotationParser;
use ZM\Annotation\Framework\Init;
use ZM\Exception\ZMKnownException;
use ZM\Framework;
use ZM\Plugin\CommandManualPlugin;
use ZM\Plugin\OneBot12Adapter;
use ZM\Plugin\PluginManager;
use ZM\Process\ProcessStateManager;
use ZM\Store\Database\DBException;
use ZM\Store\Database\DBPool;
use ZM\Store\FileSystem;
use ZM\Store\KV\LightCache;
use ZM\Utils\ZMUtil;
class WorkerEventListener
@@ -34,6 +37,8 @@ class WorkerEventListener
// 自注册一下刷新当前进程的logger进程banner
ob_logger_register(ob_logger());
Adaptive::initWithDriver(Framework::getInstance()->getDriver());
// 如果没有引入参数disable-safe-exit则监听 Ctrl+C
if (!Framework::getInstance()->getArgv()['disable-safe-exit'] && PHP_OS_FAMILY !== 'Windows') {
SignalListener::getInstance()->signalWorker();
@@ -41,12 +46,10 @@ class WorkerEventListener
// Windows 环境下,为了监听 Ctrl+C只能开启终端输入
if (PHP_OS_FAMILY === 'Windows') {
logger()->debug('监听Windows的键盘输入');
sapi_windows_set_ctrl_handler([SignalListener::getInstance(), 'signalWindowsCtrlC']);
Framework::getInstance()->getDriver()->getEventLoop()->addReadEvent(STDIN, function ($x) {});
}
logger()->debug('Worker #' . ProcessManager::getProcessId() . ' started');
// 设置 Worker 进程的状态和 ID 等信息
if (($name = Framework::getInstance()->getDriver()->getName()) === 'swoole') {
/* @phpstan-ignore-next-line */
@@ -68,6 +71,11 @@ class WorkerEventListener
logger()->info('WORKER#' . $i . ":\t" . ProcessStateManager::getProcessState(ZM_PROCESS_WORKER, $i));
}
// 如果使用的是 LightCache注册下自动保存的监听器
if (is_a(config('global.kv.use', \LightCache::class), LightCache::class, true)) {
Framework::getInstance()->getDriver()->getEventLoop()->addTimer(config('global.kv.light_cache_autosave_time', 600) * 1000, [LightCache::class, 'saveAll'], 0);
}
// 注册 Worker 进程遇到退出时的回调,安全退出
register_shutdown_function(function () {
$error = error_get_last();
@@ -87,18 +95,28 @@ class WorkerEventListener
$this->initUserPlugins();
// handle @Init annotation
$this->dispatchInit();
Adaptive::getCoroutine()->create(function () {
$this->dispatchInit();
});
// 回显 debug 日志:进程占用的内存
$memory_total = memory_get_usage() / 1024 / 1024;
logger()->debug('Worker process used ' . round($memory_total, 3) . ' MB');
}
public function onWorkerStart1(): void
{
logger()->debug('Worker #' . ProcessManager::getProcessId() . ' started');
}
/**
* @throws ZMKnownException
* @throws \JsonException
*/
public function onWorkerStop999()
public function onWorkerStop999(): void
{
if (is_a(config('global.kv.use', \LightCache::class), LightCache::class, true)) {
LightCache::saveAll();
}
logger()->debug('Worker #' . ProcessManager::getProcessId() . ' stopping');
if (DIRECTORY_SEPARATOR !== '\\') {
ProcessStateManager::removeProcessState(ZM_PROCESS_WORKER, ProcessManager::getProcessId());
@@ -109,11 +127,16 @@ class WorkerEventListener
}
}
public function onWorkerStop1(): void
{
logger()->debug('Worker #' . ProcessManager::getProcessId() . ' stopped');
}
/**
* 加载用户代码资源包括普通插件、单文件插件、Composer 插件等
* @throws \Throwable
*/
private function initUserPlugins()
private function initUserPlugins(): void
{
logger()->debug('Loading user sources');
@@ -141,6 +164,7 @@ class WorkerEventListener
match ($name) {
'onebot12' => PluginManager::addPlugin(['name' => $name, 'internal' => true, 'object' => new OneBot12Adapter(parser: $parser)]),
'onebot12-ban-other-ws' => PluginManager::addPlugin(['name' => $name, 'internal' => true, 'object' => new OneBot12Adapter(submodule: $name)]),
'command-manual' => PluginManager::addPlugin(['name' => $name, 'internal' => true, 'object' => new CommandManualPlugin($parser)]),
};
}

View File

@@ -12,11 +12,11 @@ class ConfigException extends ZMException
public static function unsupportedFileType(string $file_path): ConfigException
{
return new self("不支持的配置文件类型:{$file_path}", '请检查配置文件的后缀名是否正确', self::UNSUPPORTED_FILE_TYPE);
return new self("不支持的配置文件类型:{$file_path}", self::UNSUPPORTED_FILE_TYPE);
}
public static function loadConfigFailed(string $file_path, string $message): ConfigException
{
return new self("加载配置文件失败:{$file_path}{$message}", '请检查配置文件的格式是否正确,并尝试按照错误信息排查', self::LOAD_CONFIG_FAILED);
return new self("加载配置文件失败:{$file_path}{$message}", self::LOAD_CONFIG_FAILED);
}
}

View File

@@ -5,22 +5,23 @@ declare(strict_types=1);
namespace ZM\Exception;
use OneBot\Exception\ExceptionHandler;
use OneBot\Exception\ExceptionHandlerInterface;
use ZM\Exception\Solution\SolutionRepository;
class Handler extends ExceptionHandler implements ExceptionHandlerInterface
class Handler extends ExceptionHandler
{
public function __construct()
{
parent::__construct();
/** @noinspection ClassConstantCanBeUsedInspection */
$ns = 'NunoMaduro\Collision\Handler';
// TODO: 在 LibOB 发布新版时移除检查
if (class_exists($ns) && method_exists($this, 'tryEnableCollision')) {
$this->tryEnableCollision(new SolutionRepository());
}
}
public function handle(\Throwable $e): void
{
if ($e instanceof ZMKnownException) {
// 如果是已知异常,则可以输出问题说明和解决方案
// TODO
}
$this->handle0($e);
}
}

View File

@@ -8,6 +8,6 @@ class InterruptException extends ZMException
{
public function __construct(public $return_var = null, $message = '', $code = 0, \Throwable $previous = null)
{
parent::__construct($message, '', $code, $previous);
parent::__construct($message, $code, $previous);
}
}

View File

@@ -6,9 +6,4 @@ namespace ZM\Exception;
class InvalidArgumentException extends ZMException
{
public function __construct($message = '', $code = 0, \Throwable $previous = null)
{
// TODO: change this to a better error message
parent::__construct($message, '', $code ?: 74, $previous);
}
}

View File

@@ -10,7 +10,6 @@ class SingletonViolationException extends ZMException
{
parent::__construct(
"{$singleton_class_name} 是单例模式,不允许初始化多个实例。",
"请检查代码,确保只初始化了一个 {$singleton_class_name} 实例。",
69
);
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace ZM\Exception\Solution;
class Solution
{
public function __construct(
private string $title,
private string $description,
private array $links,
) {
}
public function getSolutionTitle(): string
{
return $this->title;
}
public function getSolutionDescription(): string
{
return $this->description;
}
public function getDocumentationLinks(): array
{
return $this->links;
}
}

View File

@@ -0,0 +1,22 @@
<?php
/** @noinspection PhpUndefinedClassInspection */
declare(strict_types=1);
namespace ZM\Exception\Solution;
use NunoMaduro\Collision\Contracts\SolutionsRepository;
class SolutionRepository implements SolutionsRepository
{
/**
* @return Solution[]
*/
public function getFromThrowable(\Throwable $throwable): array
{
return match ($throwable::class) {
default => [],
};
}
}

View File

@@ -10,7 +10,7 @@ class WaitTimeoutException extends ZMException
public function __construct($module, $message = '', $code = 0, \Throwable $previous = null)
{
parent::__construct($message, '', $code, $previous);
parent::__construct($message, $code, $previous);
$this->module = $module;
}
}

View File

@@ -6,8 +6,4 @@ namespace ZM\Exception;
abstract class ZMException extends \Exception
{
public function __construct(string $description, string $solution = '', int $code = 0, ?\Throwable $previous = null)
{
parent::__construct($description . PHP_EOL . $solution, $code, $previous);
}
}

View File

@@ -11,7 +11,7 @@ class ZMKnownException extends ZMException
{
public function __construct($err_code, $message = '', $code = 0, \Throwable $previous = null)
{
parent::__construct(zm_internal_errcode($err_code) . $message, '', $code, $previous);
parent::__construct(zm_internal_errcode($err_code) . $message, $code, $previous);
if ($err_code === 'E99999') {
$code = 0;
// 这也太懒了吧
@@ -19,6 +19,6 @@ class ZMKnownException extends ZMException
// 取最后两数
$code = (int) substr($err_code, -2);
}
parent::__construct($message, '', $code, $previous);
parent::__construct($message, $code, $previous);
}
}

View File

@@ -43,16 +43,16 @@ class Framework
use Singleton;
/** @var int 版本ID */
public const VERSION_ID = 646;
public const VERSION_ID = 654;
/** @var string 版本名称 */
public const VERSION = '3.0.0-beta2';
public const VERSION = '3.0.0-beta3';
/** @var array 传入的参数 */
protected array $argv;
/** @var Driver|SwooleDriver|WorkermanDriver OneBot驱动 */
protected SwooleDriver|Driver|WorkermanDriver $driver;
/** @var null|Driver|SwooleDriver|WorkermanDriver OneBot驱动 */
protected SwooleDriver|Driver|WorkermanDriver|null $driver = null;
/** @var array<array<string, string>> 启动注解列表 */
protected array $setup_annotations = [];
@@ -178,6 +178,9 @@ class Framework
*/
public function getDriver(): Driver
{
if ($this->driver === null) {
$this->driver = new WorkermanDriver();
}
return $this->driver;
}
@@ -228,7 +231,9 @@ class Framework
// 添加框架需要监听的顶层事件监听器
// worker 事件
ob_event_provider()->addEventListener(WorkerStartEvent::getName(), [WorkerEventListener::getInstance(), 'onWorkerStart999'], 999);
ob_event_provider()->addEventListener(WorkerStartEvent::getName(), [WorkerEventListener::getInstance(), 'onWorkerStart1'], 1);
ob_event_provider()->addEventListener(WorkerStopEvent::getName(), [WorkerEventListener::getInstance(), 'onWorkerStop999'], 999);
ob_event_provider()->addEventListener(WorkerStopEvent::getName(), [WorkerEventListener::getInstance(), 'onWorkerStop1'], 1);
// Http 事件
ob_event_provider()->addEventListener(HttpRequestEvent::getName(), [HttpEventListener::getInstance(), 'onRequest999'], 999);
ob_event_provider()->addEventListener(HttpRequestEvent::getName(), [HttpEventListener::getInstance(), 'onRequest1'], 1);

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace ZM\Plugin;
use ZM\Annotation\AnnotationParser;
use ZM\Annotation\OneBot\BotCommand;
use ZM\Annotation\OneBot\CommandArgument;
use ZM\Annotation\OneBot\CommandHelp;
use ZM\Context\BotContext;
/**
* CommandManual 插件
*
* 用以生成、处理指令帮助
*/
class CommandManualPlugin extends ZMPlugin
{
private array $template = [
['type' => 'command', 'header' => false, 'indent' => false],
['type' => 'description', 'header' => false, 'indent' => false],
['type' => 'usage', 'header' => false, 'indent' => false],
['type' => 'arguments', 'header' => '可用参数:', 'indent' => true],
['type' => 'examples', 'header' => '使用示例:', 'indent' => true],
];
/**
* 命令(帮助)列表,键为命令名,值为命令帮助
*
* @var array<string, string>
*/
private array $command_list = [];
public function __construct(AnnotationParser $parser)
{
parent::__construct(__DIR__);
if (config('command_manual.template') !== null) {
$this->template = config('command_manual.template');
}
$parser->addSpecialParser(BotCommand::class, [$this, 'parseBotCommand']);
$parser->addSpecialParser(CommandHelp::class, fn () => false);
$this->addBotCommand(
BotCommand::make('help', 'help')
->withArgument('command', '要查询的指令名', required: true)
->on([$this, 'onHelp'])
);
logger()->info('CommandManualPlugin loaded.');
}
/**
* 解析 BotCommand 的参数和帮助
*
* @param BotCommand $command 命令对象
* @param null|array $same_method_annotations 同一个方法的所有注解
*/
public function parseBotCommand(BotCommand $command, ?array $same_method_annotations = null): ?bool
{
if ($same_method_annotations) {
foreach ($same_method_annotations as $v) {
if ($v instanceof CommandHelp) {
$help = $v;
break;
}
}
}
$help = $help ?? new CommandHelp('', '', '');
$section = '';
foreach ($this->template as $v) {
$content = $this->getSectionContent($command, $v['type'], $help);
$this->addSection($section, $content, $v);
}
$this->command_list[$command->name] = $section;
return true;
}
public function onHelp(BotContext $context): void
{
$command = $context->getParam('command');
if (isset($this->command_list[$command])) {
$context->reply($this->command_list[$command]);
} else {
$context->reply('未找到指令 ' . $command);
}
}
private function addSection(string &$section, string $content, array $options): void
{
if (!$content) {
return;
}
if ($options['header']) {
$section .= $options['header'] . PHP_EOL;
}
if ($options['indent']) {
$content = ' ' . str_replace(PHP_EOL, PHP_EOL . ' ', $content);
$content = rtrim($content);
}
$section .= $content . PHP_EOL;
}
private function getSectionContent(BotCommand $command, string $type, CommandHelp $help): string
{
switch ($type) {
case 'command':
return $command->name;
case 'description':
return $help->description;
case 'usage':
return $help->usage;
case 'arguments':
$ret = '';
foreach ($command->getArguments() as $argument) {
/* @var CommandArgument $argument */
$ret .= $argument->name . ' - ' . $argument->description . PHP_EOL;
}
return $ret;
case 'examples':
return $help->example;
default:
return '';
}
}
}

View File

@@ -7,6 +7,7 @@ namespace ZM\Plugin;
use ZM\Annotation\AnnotationMap;
use ZM\Annotation\AnnotationParser;
use ZM\Annotation\Framework\BindEvent;
use ZM\Annotation\OneBot\BotCommand;
use ZM\Annotation\OneBot\BotEvent;
use ZM\Exception\PluginException;
use ZM\Store\FileSystem;
@@ -191,6 +192,7 @@ class PluginManager
}
// 将 BotCommand 加入事件监听
foreach ($obj->getBotCommands() as $cmd) {
AnnotationMap::$_list[BotCommand::class][] = $cmd;
$parser->parseSpecial($cmd);
}
} elseif (isset($plugin['autoload'], $plugin['dir'])) {

View File

@@ -10,6 +10,6 @@ class DBException extends ZMException
{
public function __construct(string $description, int $code = 0, ?\Throwable $previous = null)
{
parent::__construct($description, '', $code, $previous);
parent::__construct($description, $code, $previous);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace ZM\Store\KV;
interface KVInterface
{
/**
* 打开一个 KV 库
*
* @param string $name KV 的库名称
*/
public static function open(string $name = ''): KVInterface;
/**
* 返回一个 KV 键值对的数据
*
* @param string $key 键名
* @param null|mixed $default 如果不存在时返回的默认值
*/
public function get(string $key, mixed $default = null): mixed;
/**
* 设置一个 KV 键值对的数据
*
* @param string $key 键名
* @param mixed $value 键值
* @param int $ttl 超时秒数(如果等于 0 代表永不超时)
*/
public function set(string $key, mixed $value, int $ttl = 0): bool;
/**
* 强制删除一个 KV 键值对数据
*
* @param string $key 键名
* @return bool 当键存在并被删除时返回 true
*/
public function unset(string $key): bool;
/**
* 键值对数据是否存在
*
* @param string $key 键名
*/
public function isset(string $key): bool;
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace ZM\Store\KV;
use ZM\Exception\InvalidArgumentException;
use ZM\Process\ProcessStateManager;
use ZM\Store\FileSystem;
/**
* 轻量、基于本地 JSON 文件的 KV 键值对缓存
*/
class LightCache implements KVInterface
{
/** @var array 存放库对象的列表 */
private static array $objs = [];
/** @var array 存放缓存数据的列表 */
private static array $caches = [];
/** @var array 存放超时数据的列表 */
private static array $ttys = [];
/** @var string 查找库的目录地址 */
private string $find_dir;
/**
* @throws InvalidArgumentException
*/
public function __construct(private string $name = '', string $find_str = '')
{
if ((ProcessStateManager::$process_mode['worker'] ?? 0) > 1) {
logger()->error('LightCache 不支持多进程模式,如需在多进程下使用,请使用 ZMRedis 作为 KV 引擎!');
return;
}
$this->find_dir = empty($find_str) ? config('global.kv.light_cache_dir', '/tmp/zm_light_cache') : $find_str;
FileSystem::createDir($this->find_dir);
$this->validateKey($name);
if (file_exists($this->find_dir . '/' . $name . '.json')) {
$data = json_decode(file_get_contents($this->find_dir . '/' . $name . '.json'), true);
if (is_array($data)) {
self::$caches[$name] = $data['data'];
self::$ttys[$name] = $data['expire'];
}
}
}
/**
* @throws InvalidArgumentException
*/
public static function open(string $name = ''): KVInterface
{
if (!isset(self::$objs[$name])) {
self::$objs[$name] = new LightCache($name);
}
return self::$objs[$name];
}
/**
* @throws \JsonException
*/
public static function saveAll()
{
/** @var LightCache $obj */
foreach (self::$objs as $obj) {
$obj->save();
}
logger()->debug('Saved all light caches');
}
/**
* 保存 KV 库的数据到文件
*
* @throws \JsonException
*/
public function save(): void
{
file_put_contents(zm_dir($this->find_dir . '/' . $this->name . '.json'), json_encode([
'data' => self::$caches[$this->name] ?? [],
'expire' => self::$ttys[$this->name] ?? [],
], JSON_THROW_ON_ERROR));
}
/**
* 删除该 KV 库的所有数据,并且永远无法恢复
*/
public function removeSelf(): bool
{
if (file_exists($this->find_dir . '/' . $this->name . '.json')) {
unlink($this->find_dir . '/' . $this->name . '.json');
}
unset(self::$caches[$this->name], self::$ttys[$this->name], self::$objs[$this->name]);
return true;
}
public function get(string $key, mixed $default = null): mixed
{
// 首先判断在不在缓存变量里
if (!isset(self::$caches[$this->name][$key])) {
return $default;
}
// 然后判断是否有延迟
if (isset(self::$ttys[$this->name][$key])) {
if (self::$ttys[$this->name][$key] > time()) {
return self::$caches[$this->name][$key];
}
unset(self::$ttys[$this->name][$key], self::$caches[$this->name][$key]);
return $default;
}
return self::$caches[$this->name][$key];
}
/**
* @throws InvalidArgumentException
*/
public function set(string $key, mixed $value, int $ttl = 0): bool
{
$this->validateKey($key);
self::$caches[$this->name][$key] = $value;
if ($ttl > 0) {
self::$ttys[$this->name][$key] = time() + $ttl;
}
return true;
}
public function unset(string $key): bool
{
unset(self::$caches[$this->name][$key], self::$ttys[$this->name][$key]);
return true;
}
public function isset(string $key): bool
{
if (!isset(self::$caches[$this->name][$key])) {
return false;
}
if (isset(self::$ttys[$this->name][$key])) {
if (self::$ttys[$this->name][$key] > time()) {
return true;
}
unset(self::$ttys[$this->name][$key], self::$caches[$this->name][$key]);
return false;
}
return true;
}
private function validateKey(string $key): void
{
if ($key === '') {
return;
}
if (strlen($key) >= 128) {
throw new InvalidArgumentException('LightCache 键名长度不能超过 128 字节!');
}
// 只能包含数字、大小写字母、下划线、短横线、点、中文
if (!preg_match('/^[\w\-.\x{4e00}-\x{9fa5}]+$/u', $key)) {
throw new InvalidArgumentException('LightCache 键名只能包含数字、大小写字母、下划线、短横线、点、中文!');
}
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace ZM\Utils;
use OneBot\Util\Singleton;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
use ZM\Framework;
class ZMRequest
{
use Singleton;
/**
* 快速发起一个 GET 请求
*
* @param string|\Stringable|UriInterface $url 请求地址
* @param array $headers 请求头
* @param array $config 传入参数
* @param bool $only_body 是否只返回 Response 的 body 部分,默认为 True
* @return bool|ResponseInterface|string 返回 False 代表请求失败,返回 string 为仅 Body 的内容,返回 Response 接口对象表明是回包
*/
public static function get(string|UriInterface|\Stringable $url, array $headers = [], array $config = [], bool $only_body = true): bool|ResponseInterface|string
{
$socket = Framework::getInstance()->getDriver()->createHttpClientSocket(array_merge_recursive([
'url' => ($url instanceof UriInterface ? $url->__toString() : $url),
], $config));
$socket->withoutAsync();
$obj = $socket->get($headers, function (ResponseInterface $response) { return $response; }, function () { return false; });
if ($obj instanceof ResponseInterface) {
if ($obj->getStatusCode() !== 200 && $only_body) {
return false;
}
if (!$only_body) {
return $obj;
}
return $obj->getBody()->getContents();
}
return $obj;
}
/**
* 快速发起一个 POST 请求
*
* @param string|\Stringable|UriInterface $url 请求地址
* @param array $header 请求头
* @param mixed $data 请求数据,当传入了一个可以 Json 化的对象时,自动 json_encode其他情况须传入可字符串化的变量
* @param array $config 传入参数
* @param bool $only_body 是否只返回 Response 的 body 部分,默认为 True
* @return bool|ResponseInterface|string 返回 False 代表请求失败,返回 string 为仅 Body 的内容,返回 Response 接口对象表明是回包
*/
public static function post(string|UriInterface|\Stringable $url, array $header, mixed $data, array $config = [], bool $only_body = true): bool|ResponseInterface|string
{
$socket = Framework::getInstance()->getDriver()->createHttpClientSocket(array_merge_recursive([
'url' => ($url instanceof UriInterface ? $url->__toString() : $url),
], $config));
$socket->withoutAsync();
$obj = $socket->post($data, $header, fn (ResponseInterface $response) => $response, fn () => false);
if ($obj instanceof ResponseInterface) {
if ($obj->getStatusCode() !== 200 && $only_body) {
return false;
}
if (!$only_body) {
return $obj;
}
return $obj->getBody()->getContents();
}
return $obj;
}
}

View File

@@ -8,7 +8,9 @@ class ZMUtil
{
/**
* 获取 composer.json 并转为数组进行读取使用
* @param null|string $path 路径
*
* @param null|string $path 路径
* @throws \JsonException
*/
public static function getComposerMetadata(?string $path = null): ?array
{

View File

@@ -12,11 +12,14 @@ use ZM\Plugin\ZMPlugin;
class ZMApplication extends ZMPlugin
{
/** @var null|ZMApplication 存储单例类的变量 */
private static ?ZMApplication $obj;
private static ?ZMApplication $obj = null;
/** @var array 存储要传入的args */
private array $args = [];
/**
* @throws SingletonViolationException
*/
public function __construct(mixed $dir = null)
{
if (self::$obj !== null) {

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Tests\ZM\Store\KV;
use PHPUnit\Framework\TestCase;
use ZM\Store\KV\LightCache;
/**
* @internal
*/
class LightCacheTest extends TestCase
{
public function testRemoveSelf()
{
$a = LightCache::open('asd');
$this->assertInstanceOf(LightCache::class, $a);
/* @phpstan-ignore-next-line */
$this->assertTrue($a->removeSelf());
}
public function testSet()
{
$this->assertTrue(LightCache::open()->set('test123', 'help'));
}
public function testIsset()
{
$this->assertFalse(LightCache::open()->isset('test111'));
}
public function testGet()
{
LightCache::open('ppp')->set('hello', 'world');
$this->assertSame(LightCache::open('ppp')->get('hello', 'ffff'), 'world');
}
public function testUnset()
{
$kv = LightCache::open('sss');
$kv->set('test', 'test');
$this->assertSame($kv->get('test'), 'test');
$kv->unset('test');
$this->assertNull($kv->get('test'));
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Tests\ZM\Utils;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use ZM\Utils\ZMRequest;
/**
* @internal
*/
class ZMRequestTest extends TestCase
{
public function testPost()
{
$r = ZMRequest::post('http://httpbin.org/post', [], 'niubi=123');
$this->assertStringContainsString('123', $r);
$r2 = ZMRequest::post('http://httpbin.org/post', ['User-Agent' => 'test'], 'oijoij=ooo', [], false);
$this->assertInstanceOf(ResponseInterface::class, $r2);
$this->assertStringContainsString('ooo', $r2->getBody()->getContents());
}
public function testGet()
{
$r = ZMRequest::get('http://ip.zhamao.xin');
$this->assertStringContainsString('114', $r);
}
}