├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── 1_Bug_report.yaml │ └── 2_Feature_request.yaml ├── dependabot.yml ├── label-actions.yaml └── workflows │ ├── close-staled.yaml │ ├── coding-style.yml │ ├── label-actions.yaml │ ├── review-actions.yaml │ ├── static-analysis.yml │ └── test.yml ├── .gitignore ├── .php-cs-fixer.php ├── LICENSE ├── README.md ├── cache └── .gitkeep ├── choir-test.php ├── composer.json ├── demo ├── repl.php └── weixin.php ├── docs ├── contributing.md └── update.md ├── phpstan.neon ├── phpunit.xml.dist ├── src └── OneBot │ ├── Config │ ├── Config.php │ ├── Loader │ │ ├── AbstractFileLoader.php │ │ ├── DelegateLoader.php │ │ ├── JsonFileLoader.php │ │ ├── LoadException.php │ │ └── LoaderInterface.php │ ├── Repository.php │ └── RepositoryInterface.php │ ├── Database │ └── SQLite │ │ ├── ConnectionPool.php │ │ └── SQLite.php │ ├── Driver │ ├── Coroutine │ │ ├── Adaptive.php │ │ ├── CoroutineInterface.php │ │ ├── FiberCoroutine.php │ │ └── SwooleCoroutine.php │ ├── Driver.php │ ├── DriverEventLoopBase.php │ ├── Event │ │ ├── DriverEvent.php │ │ ├── DriverInitEvent.php │ │ ├── Event.php │ │ ├── EventDispatcher.php │ │ ├── EventProvider.php │ │ ├── Http │ │ │ └── HttpRequestEvent.php │ │ ├── Process │ │ │ ├── ManagerStartEvent.php │ │ │ ├── ManagerStopEvent.php │ │ │ ├── UserProcessStartEvent.php │ │ │ ├── WorkerExitEvent.php │ │ │ ├── WorkerStartEvent.php │ │ │ └── WorkerStopEvent.php │ │ ├── StopException.php │ │ └── WebSocket │ │ │ ├── WebSocketClientOpenEvent.php │ │ │ ├── WebSocketCloseEvent.php │ │ │ ├── WebSocketMessageEvent.php │ │ │ └── WebSocketOpenEvent.php │ ├── Interfaces │ │ ├── DriverInitPolicy.php │ │ ├── HandledDispatcherInterface.php │ │ ├── PoolInterface.php │ │ ├── ProcessInterface.php │ │ ├── SocketInterface.php │ │ ├── SortedProviderInterface.php │ │ ├── WebSocketClientInterface.php │ │ └── WebSocketInterface.php │ ├── Process │ │ ├── ExecutionResult.php │ │ └── ProcessManager.php │ ├── Socket │ │ ├── HttpClientSocketBase.php │ │ ├── HttpServerSocketBase.php │ │ ├── SocketConfig.php │ │ ├── SocketFlag.php │ │ ├── SocketTrait.php │ │ ├── WSClientSocketBase.php │ │ └── WSServerSocketBase.php │ ├── Swoole │ │ ├── EventLoop.php │ │ ├── ObjectPool.php │ │ ├── Socket │ │ │ ├── HttpClientSocket.php │ │ │ ├── HttpServerSocket.php │ │ │ ├── WSClientSocket.php │ │ │ └── WSServerSocket.php │ │ ├── SwooleDriver.php │ │ ├── TopEventListener.php │ │ ├── UserProcess.php │ │ └── WebSocketClient.php │ └── Workerman │ │ ├── EventLoop.php │ │ ├── ObjectPool.php │ │ ├── Socket │ │ ├── HttpClientSocket.php │ │ ├── HttpServerSocket.php │ │ ├── WSClientSocket.php │ │ └── WSServerSocket.php │ │ ├── TopEventListener.php │ │ ├── UserProcess.php │ │ ├── WebSocketClient.php │ │ ├── Worker.php │ │ └── WorkermanDriver.php │ ├── Exception │ ├── ExceptionHandler.php │ └── ExceptionHandlerInterface.php │ ├── ObjectPool │ └── AbstractObjectPool.php │ ├── Util │ ├── FileUtil.php │ ├── ObjectQueue.php │ ├── Singleton.php │ └── Utils.php │ ├── V12 │ ├── Action │ │ ├── ActionHandlerBase.php │ │ └── DefaultActionHandler.php │ ├── EventBuilder.php │ ├── Exception │ │ ├── OneBotException.php │ │ └── OneBotFailureException.php │ ├── Object │ │ ├── Action.php │ │ ├── ActionResponse.php │ │ ├── MessageSegment.php │ │ └── OneBotEvent.php │ ├── OneBot.php │ ├── OneBotBuilder.php │ ├── OneBotEventListener.php │ ├── RetCode.php │ └── Validator.php │ └── global_defines.php └── tests ├── Fixture ├── config.json └── invalid.json ├── OneBot ├── Config │ ├── ConfigTest.php │ ├── Loader │ │ ├── AbstractFileLoaderTest.php │ │ ├── DelegateLoaderTest.php │ │ └── JsonFileLoaderTest.php │ └── RepositoryTest.php ├── Exception │ └── ExceptionHandlerTest.php ├── GlobalDefinesTest.php ├── Util │ └── FileUtilTest.php └── V12 │ ├── Action │ ├── ActionBaseTest.php │ └── ActionResponseTest.php │ ├── Object │ └── OneBotEventTest.php │ ├── OneBotEventListenerTest.php │ └── RetCodeTest.php └── bootstrap.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1_Bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: 🐛 漏洞(BUG)报告 2 | description: ⚠️ 请不要直接在此提交安全漏洞 3 | labels: bug 4 | 5 | body: 6 | - type: input 7 | id: affected-versions 8 | attributes: 9 | label: 受影响版本 10 | placeholder: x.y.z 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: description 15 | attributes: 16 | label: 描述 17 | description: 请详细地描述您的问题 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: reproduce-steps 22 | attributes: 23 | label: 复现步骤 24 | description: | 25 | 请尽可能地提供可以复现此步骤的漏洞。 26 | 如果步骤过长或难以描述,您可以自行建立一个用于复现漏洞的仓库。 27 | validations: 28 | required: true 29 | - type: textarea 30 | id: possible-solution 31 | attributes: 32 | label: 解决方案 33 | description: 如果您对这个漏洞的成因或修复有任何意见的话,请在此提出 34 | - type: textarea 35 | id: additional-context 36 | attributes: 37 | label: 附加信息 38 | description: 其他可能有帮助的信息,如日志、截图等 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2_Feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: 🚀 功能建议 2 | description: 新功能、改进的意见、草案 3 | labels: enhancement 4 | 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: 描述 10 | description: 请提供简洁清楚的描述 11 | validations: 12 | required: true 13 | - type: textarea 14 | id: example 15 | attributes: 16 | label: 例子 17 | description: | 18 | 一个简单的例子,展示该功能将如何被使用(包括代码、配置文件等) 19 | 如果这是针对已有功能的改进,请展示改进前后使用方式(或效能)的对比 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "composer" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | target-branch: "master" 8 | labels: 9 | - "area/dependency" 10 | -------------------------------------------------------------------------------- /.github/label-actions.yaml: -------------------------------------------------------------------------------- 1 | # Configuration for Label Actions - https://github.com/dessant/label-actions 2 | 3 | resolution/cannot-reproduce: 4 | comment: > 5 | 我们的开发人员无法复现此问题,如有可能,请提供完整的复现用例及截图等资料。 6 | 7 | resolution/duplicate: 8 | comment: > 9 | 这与现有的一个Issue/PR重复了。 10 | close: true 11 | lock: true 12 | 13 | resolution/invalid: 14 | comment: > 15 | 我们的开发人员认为这是一个无效的问题,请确保您查阅了我们的贡献指南及提供了必要的信息。 16 | close: true 17 | 18 | resolution/rejected: 19 | comment: > 20 | 此提案已被我们的开发人员拒绝。 21 | close: true 22 | 23 | resoluton/wontfix: 24 | comment: > 25 | 抱歉,我们暂时不会处理。 26 | close: true 27 | 28 | 'accept PRs': 29 | comment: > 30 | 我们的开发人员认为这是一个不错的提案,您(或其他有意向的人)可以就此提交 PR。 31 | -------------------------------------------------------------------------------- /.github/workflows/close-staled.yaml: -------------------------------------------------------------------------------- 1 | name: Close Stale Issue PR 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | jobs: 13 | action: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Lock outdated issues and prs 17 | uses: dessant/lock-threads@v3 18 | with: 19 | issue-inactive-days: '7' 20 | exclude-any-issue-labels: 'lifecycle/keep-open' 21 | add-issue-labels: 'lifecycle/stale' 22 | issue-comment: > 23 | 由于在关闭后没有更多信息,此Issue已被自动锁定。如有需要请提出一个新Issue。 24 | pr-comment: > 25 | 由于在关闭后没有更多信息,此PR已被自动锁定。如有需要请提出一个新Issue。 26 | -------------------------------------------------------------------------------- /.github/workflows/coding-style.yml: -------------------------------------------------------------------------------- 1 | name: Code Style 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | pre_job: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 13 | steps: 14 | - id: skip_check 15 | uses: fkirc/skip-duplicate-actions@v4 16 | with: 17 | concurrent_skipping: same_content_newer 18 | skip_after_successful_duplicate: true 19 | paths: '["src/**", "tests/**"]' 20 | do_not_skip: '["workflow_dispatch", "schedule"]' 21 | 22 | cs-fix: 23 | needs: pre_job 24 | if: ${{ needs.pre_job.outputs.should_skip != 'true' }} 25 | name: Code Style 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v2 30 | with: 31 | fetch-depth: 0 32 | 33 | - name: Setup PHP 34 | uses: sunxyw/workflows/setup-environment@main 35 | with: 36 | php-version: 8.0 37 | php-extensions: swoole, posix, json 38 | operating-system: ubuntu-latest 39 | use-cache: true 40 | 41 | - name: Code Style 42 | uses: sunxyw/workflows/coding-style@main 43 | -------------------------------------------------------------------------------- /.github/workflows/label-actions.yaml: -------------------------------------------------------------------------------- 1 | name: 'Label Actions' 2 | 3 | on: 4 | issues: 5 | types: [ labeled, unlabeled ] 6 | pull_request: 7 | types: [ labeled, unlabeled ] 8 | discussion: 9 | types: [ labeled, unlabeled ] 10 | 11 | permissions: 12 | contents: read 13 | issues: write 14 | pull-requests: write 15 | discussions: write 16 | 17 | jobs: 18 | action: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: dessant/label-actions@v2 22 | with: 23 | config-path: '.github/label-actions.yaml' 24 | -------------------------------------------------------------------------------- /.github/workflows/review-actions.yaml: -------------------------------------------------------------------------------- 1 | name: "Review Actions" 2 | 3 | on: 4 | pull_request: 5 | types: [ review_requested, review_request_removed ] 6 | pull_request_review: 7 | types: [ submitted ] 8 | 9 | jobs: 10 | prereview: 11 | runs-on: ubuntu-latest 12 | if: ${{ (github.event_name == 'pull_request') }} 13 | steps: 14 | - name: "Mark PR as wait for review" 15 | if: ${{ (github.event.action == 'review_requested') }} 16 | uses: andymckay/labeler@1.0.4 17 | with: 18 | add-labels: "status/wait-for-review" 19 | remove-labels: "status/wip, status/do-not-merge, lifecycle/ready-for-merge" 20 | - name: "Mark PR as work in progress" 21 | if: ${{ (github.event.action == 'review_request_removed') }} 22 | uses: andymckay/labeler@1.0.4 23 | with: 24 | add-labels: "status/wip" 25 | remove-labels: "status/wait-for-review, status/do-not-merge, lifecycle/ready-for-merge" 26 | 27 | postreview: 28 | runs-on: ubuntu-latest 29 | if: ${{ (github.event_name == 'pull_request_review') && (github.event.action == 'submitted') }} 30 | steps: 31 | - name: "Mark PR as ready for merge" 32 | if: ${{ (github.event.review.state == 'approved') }} 33 | uses: andymckay/labeler@1.0.4 34 | with: 35 | add-labels: "lifecycle/ready-for-merge" 36 | remove-labels: "status/wip, status/wait-for-review, status/do-not-merge" 37 | 38 | - name: "Mark PR as do not merge" 39 | if: ${{ (github.event.review.state) == 'request_changes' }} 40 | uses: andymckay/labeler@1.0.4 41 | with: 42 | add-labels: "status/do-not-merge" 43 | remove-labels: "status/wip, status/wait-for-review, lifecycle/ready-for-merge" 44 | 45 | # related labels: status/wip, status/wait-for-review, status/do-not-merge, lifecycle/ready-for-merge 46 | -------------------------------------------------------------------------------- /.github/workflows/static-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Static Analysis 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | pre_job: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 13 | steps: 14 | - id: skip_check 15 | uses: fkirc/skip-duplicate-actions@v4 16 | with: 17 | concurrent_skipping: same_content_newer 18 | skip_after_successful_duplicate: true 19 | paths: '["src/**", "tests/**"]' 20 | do_not_skip: '["workflow_dispatch", "schedule"]' 21 | 22 | analyse: 23 | needs: pre_job 24 | if: ${{ needs.pre_job.outputs.should_skip != 'true' }} 25 | name: Static Analysis 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v2 30 | with: 31 | fetch-depth: 0 32 | 33 | - name: Setup PHP 34 | uses: sunxyw/workflows/setup-environment@main 35 | with: 36 | php-version: 7.4 37 | php-extensions: swoole, posix, json 38 | operating-system: ubuntu-latest 39 | use-cache: true 40 | 41 | - name: Static Analysis 42 | uses: sunxyw/workflows/static-analysis@main 43 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | - develop 9 | - '*-dev*' 10 | pull_request: 11 | types: 12 | - opened 13 | - reopened 14 | - review_requested 15 | workflow_dispatch: 16 | 17 | jobs: 18 | pre_job: 19 | runs-on: ubuntu-latest 20 | outputs: 21 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 22 | steps: 23 | - id: skip_check 24 | uses: fkirc/skip-duplicate-actions@v4 25 | with: 26 | concurrent_skipping: same_content_newer 27 | skip_after_successful_duplicate: true 28 | paths: '["src/**", "tests/**", "bin/phpunit-zm"]' 29 | do_not_skip: '["workflow_dispatch", "schedule"]' 30 | 31 | test: 32 | needs: pre_job 33 | if: ${{ needs.pre_job.outputs.should_skip != 'true' }} 34 | strategy: 35 | matrix: 36 | operating-system: [ ubuntu-latest, windows-latest, macos-latest ] 37 | php-version: [ 7.4, 8.0, 8.1 ] 38 | name: PHP ${{ matrix.php-version }} Test (${{ matrix.operating-system }}) 39 | runs-on: ${{ matrix.operating-system }} 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v2 43 | with: 44 | fetch-depth: 0 45 | 46 | - name: Setup PHP 47 | uses: sunxyw/workflows/setup-environment@main 48 | with: 49 | php-version: ${{ matrix.php-version }} 50 | php-extensions: swoole, posix, json 51 | operating-system: ${{ matrix.operating-system }} 52 | use-cache: true 53 | 54 | - name: Test 55 | uses: sunxyw/workflows/test@main 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Composer ### 2 | composer.phar 3 | /vendor/ 4 | composer.lock 5 | 6 | # CGHooks 7 | cghooks.lock 8 | 9 | # Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control 10 | # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file 11 | # composer.lock 12 | 13 | ### Git ### 14 | # Created by git for backups. To disable backups in Git: 15 | # $ git config --global mergetool.keepBackup false 16 | *.orig 17 | 18 | # Created by git when using merge tools for conflicts 19 | *.BACKUP.* 20 | *.BASE.* 21 | *.LOCAL.* 22 | *.REMOTE.* 23 | *_BACKUP_*.txt 24 | *_BASE_*.txt 25 | *_LOCAL_*.txt 26 | *_REMOTE_*.txt 27 | 28 | ### PhpStorm ### 29 | /.idea 30 | 31 | ### VisualStudioCode ### 32 | /.vscode 33 | *.code-workspace 34 | 35 | # Local History for Visual Studio Code 36 | .history/ 37 | 38 | # Ignore all local history of files 39 | .history 40 | .ionide 41 | 42 | .phpunit.result.cache 43 | 44 | ### ASDF ### 45 | .tool-versions 46 | 47 | ### Phive ### 48 | tools 49 | .phive 50 | 51 | ### pcov coverage report 52 | build/ 53 | 54 | data/ 55 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | setRiskyAllowed(true) 7 | ->setRules([ 8 | '@PSR12' => true, 9 | '@Symfony' => true, 10 | '@PhpCsFixer' => true, 11 | 'array_syntax' => [ 12 | 'syntax' => 'short', 13 | ], 14 | 'list_syntax' => [ 15 | 'syntax' => 'short', 16 | ], 17 | 'concat_space' => [ 18 | 'spacing' => 'one', 19 | ], 20 | 'blank_line_before_statement' => [ 21 | 'statements' => [ 22 | 'declare', 23 | ], 24 | ], 25 | 'general_phpdoc_annotation_remove' => [ 26 | 'annotations' => [ 27 | 'author', 28 | ], 29 | ], 30 | 'ordered_imports' => [ 31 | 'imports_order' => [ 32 | 'class', 33 | 'function', 34 | 'const', 35 | ], 36 | 'sort_algorithm' => 'alpha', 37 | ], 38 | 'single_line_comment_style' => [ 39 | 'comment_types' => [ 40 | ], 41 | ], 42 | 'yoda_style' => [ 43 | 'always_move_variable' => false, 44 | 'equal' => false, 45 | 'identical' => false, 46 | ], 47 | 'phpdoc_align' => true, 48 | 'multiline_whitespace_before_semicolons' => [ 49 | 'strategy' => 'no_multi_line', 50 | ], 51 | 'constant_case' => [ 52 | 'case' => 'lower', 53 | ], 54 | 'class_attributes_separation' => true, 55 | 'combine_consecutive_unsets' => true, 56 | 'declare_strict_types' => true, 57 | 'linebreak_after_opening_tag' => true, 58 | 'lowercase_static_reference' => true, 59 | 'no_useless_else' => true, 60 | 'no_unused_imports' => true, 61 | 'not_operator_with_successor_space' => false, 62 | 'not_operator_with_space' => false, 63 | 'ordered_class_elements' => true, 64 | 'php_unit_strict' => false, 65 | 'phpdoc_separation' => false, 66 | 'single_quote' => true, 67 | 'standardize_not_equals' => true, 68 | 'multiline_comment_opening_closing' => true, 69 | 'phpdoc_summary' => false, 70 | 'types_spaces' => false, 71 | 'braces' => false, 72 | 'blank_line_between_import_groups' => false, 73 | 'phpdoc_order' => ['order' => ['param', 'throws', 'return']], 74 | 'php_unit_test_class_requires_covers' => false, 75 | 'no_null_property_initialization' => false, 76 | ]) 77 | ->setFinder( 78 | PhpCsFixer\Finder::create() 79 | ->exclude('vendor') 80 | ->exclude('docs') 81 | ->in(__DIR__) 82 | ) 83 | ->setUsingCache(false); 84 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Bot Universe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 | Version 5 | 6 | GitHub Workflow Status 7 | License 8 | Packagist PHP Version Support 9 |

10 | 11 | # php-libonebot 12 | 13 | PHP 的 LibOneBot 库。LibOneBot 可以帮助 OneBot 实现者快速在新的聊天机器人平台实现 OneBot v12 接口标准。 14 | 15 | 基于 LibOneBot 实现 OneBot 时,OneBot 实现者只需专注于编写与聊天机器人平台对接的逻辑,包括通过长轮询或 webhook 方式从机器人平台获得事件,并将其转换为 OneBot 事件,以及处理 OneBot 16 | 动作请求,并将其转换为对机器人平台 API 的调用。 17 | 18 | 此外,内部的通信方式有相应的抽象方法,你可以在 libob 的基础上开发或整合其他 Web 框架。 19 | 20 | **当前版本还在开发中,在发布正式版之前此库内的接口可能会发生较大变动。** 21 | 22 | 开发进度见 [更新日志](/docs/update.md)。 23 | 24 | ## 使用 25 | 26 | ```shell 27 | composer require onebot/libonebot 28 | ``` 29 | 30 | ## 尝试 Demo 31 | 32 | 在 require 下载 libob 库后,新建文件 `demo.php` 和 `demo.json`,并在 `demo.php` 中写如下代码: 33 | 34 | ```php 35 | setLogger(new \OneBot\Logger\Console\ConsoleLogger()); 43 | $ob->setDriver( 44 | // 此处也可以在 Linux 系统下安装 swoole 扩展后使用 SwooleDriver() 拥有协程能力 45 | new \OneBot\Driver\Workerman\WorkermanDriver(), 46 | new \OneBot\Config\Repository('demo.json') 47 | ); 48 | $ob->setActionHandlerClass(\OneBot\V12\Action\ReplAction::class); 49 | $ob->run(); 50 | ``` 51 | 52 | 在 `demo.json` 中写如下代码: 53 | 54 | ```json 55 | { 56 | "lib": { 57 | "db": false 58 | }, 59 | "communications": { 60 | "http": { 61 | "enable": true, 62 | "host": "0.0.0.0", 63 | "port": 9600, 64 | "event_enabled": true, 65 | "event_buffer_size": 0 66 | } 67 | } 68 | } 69 | ``` 70 | 71 | 此 Demo 以一个命令行交互的方式使用 LibOneBot 快速完成了一个 OneBot 实现,命令行中输入内容即可发送到 OneBot,使用 HTTP 或 WebSocket 发送给 LibOneBot 后可以将信息显示在终端内。 72 | 73 | ```bash 74 | # 运行 OneBot 实现 75 | php demo.php 76 | ``` 77 | 78 | 启动后可以利用 Postman 或 Curl 等工具发起请求,以 OneVot V12 协议的[发送消息动作](https://12.onebot.dev/interface/action/message/)为例: 79 | 80 | ```shell 81 | curl --location --request POST 'http://localhost:9600/' \ 82 | --header 'Content-Type: application/json' \ 83 | --data-raw '{ 84 | "action": "send_message", 85 | "params": { 86 | "detail_type": "group", 87 | "group_id": "12467", 88 | "message": [ 89 | { 90 | "type": "text", 91 | "data": { 92 | "text": "我是文字巴拉巴拉巴拉" 93 | } 94 | } 95 | ] 96 | } 97 | }' 98 | ``` 99 | 100 | 你应该可以看到 OneBot 命令行中出现以下消息: 101 | 102 | ```shell 103 | [2021-11-18 18:44:39] [INFO] 我是文字巴拉巴拉巴拉 104 | ``` 105 | 106 | 并收到以下响应: 107 | 108 | ```text 109 | {"status":"ok","retcode":0,"data":{"message_id":5007842},"message":""}% 110 | ``` 111 | -------------------------------------------------------------------------------- /cache/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/botuniverse/php-libonebot/db36e24192c13ebe6d3c8af4f32c3ecf58f3647f/cache/.gitkeep -------------------------------------------------------------------------------- /choir-test.php: -------------------------------------------------------------------------------- 1 | 8, 13 | // 'logger-level' => 'debug', 14 | ]); 15 | 16 | $server->on('workerstart', function () { 17 | // xhprof_enable(); 18 | }); 19 | 20 | $server->on('workerstop', function () { 21 | // $data = xhprof_disable(); 22 | // $x = new XHProfRuns_Default(); 23 | // $id = $x->save_run($data, 'xhprof_testing'); 24 | // echo "http://127.0.0.1:8080/index.php?run={$id}&source=xhprof_testing\n"; 25 | }); 26 | 27 | $server->on('request', function (HttpConnection $connection) { 28 | $connection->end('hello world'); 29 | }); 30 | 31 | require_once '/private/tmp/xhprof-2.3.8/xhprof_lib/utils/xhprof_lib.php'; 32 | require_once '/private/tmp/xhprof-2.3.8/xhprof_lib/utils/xhprof_runs.php'; 33 | 34 | $server->start(); 35 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "onebot/libonebot", 3 | "description": "PHP 的 LibOneBot 库,通过此库可快速接入 OneBot 生态", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "php", 8 | "libonebot", 9 | "onebot", 10 | "starter" 11 | ], 12 | "authors": [ 13 | { 14 | "name": "crazywhalecc", 15 | "email": "crazywhalecc@163.com" 16 | }, 17 | { 18 | "name": "sunxyw", 19 | "email": "xy2496419818@gmail.com" 20 | } 21 | ], 22 | "homepage": "https://github.com/botuniverse/php-libonebot", 23 | "support": { 24 | "issues": "https://github.com/botuniverse/php-libonebot/issues", 25 | "wiki": "https://github.com/botuniverse/php-libonebot/wiki" 26 | }, 27 | "require": { 28 | "php": "^7.4 || ^8.0 || ^8.1 || ^8.2 || ^8.3 || ^8.4", 29 | "ext-json": "*", 30 | "psr/cache": "^1.0 || ^3.0", 31 | "psr/event-dispatcher": "^1.0", 32 | "psr/http-client": "^1.0", 33 | "psr/log": "^1.1 || ^3.0", 34 | "rybakit/msgpack": "^0.9.0", 35 | "choir/psr-http": "^1.0", 36 | "workerman/workerman": "^4.0", 37 | "zhamao/logger": "^1.0" 38 | }, 39 | "require-dev": { 40 | "brainmaestro/composer-git-hooks": "^2.8", 41 | "friendsofphp/php-cs-fixer": "^3.2", 42 | "phpstan/phpstan": "^1.1", 43 | "phpunit/phpunit": "^9.0 || ^8.0", 44 | "swoole/ide-helper": "~4.4.0", 45 | "symfony/var-dumper": "^5.3" 46 | }, 47 | "suggest": { 48 | "nunomaduro/collision": "Better display for exception and error message", 49 | "symfony/var-dumper": "Better display for `ob_dump()` global debug function" 50 | }, 51 | "minimum-stability": "dev", 52 | "prefer-stable": true, 53 | "autoload": { 54 | "psr-4": { 55 | "OneBot\\": "src/OneBot" 56 | }, 57 | "files": [ 58 | "src/OneBot/global_defines.php" 59 | ] 60 | }, 61 | "autoload-dev": { 62 | "psr-4": { 63 | "Tests\\": "tests" 64 | } 65 | }, 66 | "config": { 67 | "optimize-autoloader": true, 68 | "sort-packages": true 69 | }, 70 | "extra": { 71 | "hooks": { 72 | "post-merge": "composer install", 73 | "pre-commit": [ 74 | "echo committing as $(git config user.name)", 75 | "composer cs-fix -- --diff" 76 | ], 77 | "pre-push": [ 78 | "composer cs-fix -- --dry-run --diff", 79 | "composer analyse" 80 | ] 81 | } 82 | }, 83 | "scripts": { 84 | "post-install-cmd": [ 85 | "[ $COMPOSER_DEV_MODE -eq 0 ] || vendor/bin/cghooks add" 86 | ], 87 | "analyse": "phpstan analyse --memory-limit 300M", 88 | "cs-fix": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix", 89 | "test": "phpunit --no-coverage" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /demo/repl.php: -------------------------------------------------------------------------------- 1 | 'repl', 25 | 'platform' => 'qq', 26 | 'self_id' => 'REPL-1', 27 | 'db' => true, 28 | 'logger' => [ 29 | 'class' => ConsoleLogger::class, 30 | 'level' => 'info', 31 | ], 32 | 'driver' => [ 33 | 'class' => SwooleDriver::class, 34 | 'config' => [ 35 | 'init_in_user_process_block' => true, 36 | ], 37 | ], 38 | 'communications' => [ 39 | [ 40 | 'type' => 'http', 41 | 'host' => '127.0.0.1', 42 | 'port' => 2345, 43 | 'worker_count' => 8, 44 | 'access_token' => '', 45 | 'event_enabled' => true, 46 | 'event_buffer_size' => 100, 47 | ], 48 | [ 49 | 'type' => 'http_webhook', 50 | 'url' => 'https://example.com/webhook', 51 | 'access_token' => '', 52 | 'timeout' => 5000, 53 | ], 54 | [ 55 | 'type' => 'websocket', 56 | 'host' => '127.0.0.1', 57 | 'port' => 2346, 58 | 'access_token' => '', 59 | ], 60 | [ 61 | 'type' => 'ws_reverse', 62 | 'url' => 'ws://127.0.0.1:9002', 63 | 'access_token' => '', 64 | 'reconnect_interval' => 1000, 65 | ], 66 | ], 67 | ]; 68 | 69 | const ONEBOT_APP_VERSION = '1.0.0-snapshot'; 70 | 71 | $ob = OneBotBuilder::buildFromArray($config); // 传入通信方式 72 | $ob->addActionHandler('send_message', function (Action $obj) { // 写一个动作回调 73 | Validator::validateParamsByAction($obj, ['detail_type' => ['private']]); // 我这里只允许私聊动作,否则 BAD_PARAM 74 | ob_logger()->info(Utils::msgToString($obj->params['message'])); // 把字符串转换为终端输入,因为这是 REPL 的 demo 75 | return ActionResponse::create($obj->echo)->ok(['message_id' => message_id()]); // 返回消息回复 76 | }); 77 | 78 | // 下面是一个简单的 REPL 实现,每次输入一行,就会触发一次 private.message 事件并通过设定的通信方式发送 79 | ob_event_provider()->addEventListener(DriverInitEvent::getName(), function (DriverInitEvent $event) { 80 | ob_logger()->info('Init 进程启动!' . $event->getDriver()->getName()); 81 | $event->getDriver()->getEventLoop()->addReadEvent(STDIN, function ($x) use ($event) { 82 | $s = fgets($x); 83 | if ($s === false) { 84 | $event->getDriver()->getEventLoop()->delReadEvent($x); 85 | return; 86 | } 87 | $event = (new EventBuilder('message', 'private')) 88 | ->feed('message', trim($s)) 89 | ->feed('alt_message', trim($s)) 90 | ->feed('message_id', message_id()) 91 | ->feed('user_id', 'tty') 92 | ->build(); 93 | OneBot::getInstance()->dispatchEvent($event); 94 | }); 95 | }, 0); 96 | 97 | $ob->run(); 98 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | [![Version](https://img.shields.io/github/v/release/botuniverse/php-libonebot?include_prereleases&logo=github&style=flat-square)](https://github.com/botuniverse/php-libonebot/releases) 2 | [![License](https://img.shields.io/github/license/botuniverse/php-libonebot?style=flat-square)](https://github.com/botuniverse/php-libonebot) 3 | 4 | ## 贡献指南 5 | 6 | #### **报告漏洞(BUG)** 7 | 8 | * **如果你发现了一个安全漏洞,请直接联系我们的开发人员,不要将其公开在其他地方。** 9 | 10 | * **请先确认该漏洞并非已知的漏洞**,你可以在 [Issues](https://github.com/botuniverse/php-libonebot/issues) 中检查。 11 | 12 | * 如果你没有发现相关的 Issue,你可以[建立一个](https://github.com/botuniverse/php-libonebot/issues/new)。记得加上一个简洁明确的**标题**,并附以尽可能详细的**描述**。最好加上一段**复现代码**或者**测试用例**。 13 | 14 | * 如果可以,请使用我们提供的模板来建立 Issue。 15 | 16 | 17 | #### **漏洞修复** 18 | 19 | * 你可以新建一个 [Pull Request](https://github.com/botuniverse/php-libonebot/pulls) 来向我们提交修复补丁。 20 | * 请确保你在 PR 中清楚地描述了该漏洞以及解决方案。有需要的话可以附上相应的 Issue 编号。 21 | 22 | #### **功能开发** 23 | 24 | * 你可以新建一个 [Pull Request](https://github.com/botuniverse/php-libonebot/pulls) 来向我们提交新功能。 25 | * 我们建议你先在 [Issues](https://github.com/botuniverse/php-libonebot/issues) 中征求社区意见,看功能是否合适。 26 | 27 | 这是一个社区开源项目,非常感谢您的协助。:heart: 28 | -------------------------------------------------------------------------------- /docs/update.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | 3 | ## v0.4.0 (2022-7-14) 4 | 5 | - 更新内容较多,可能 6 | 7 | ## v0.3.0 (2021-11-29) 8 | 9 | - 实现 EventLoop 事件相关对象 @sunxyw #14 10 | - 添加连接池和 SQLite 连接组件及相关配置项 `lib` @sunxyw #16 #17 11 | - 加入 Dependabot @sunxyw #18 12 | - 修改全局方法 `logger()` -> `ob_logger()` @crazywhalecc #19 13 | - 完善 SwooleDriver 的实现 @crazywhalecc #20 14 | - Driver 类新增 `emitHttpRequest()` 方法,减少驱动实现代码重复 15 | - 更新 README 16 | 17 | ## v0.2.0 (2021-11-18) 18 | 19 | - 添加贡献指南 @sunxyw #2 20 | - 添加日志组件 @sunxyw #3 21 | - 清理、优化代码 22 | - 添加 GitHub Workflows 工作流 23 | - 更新 README 24 | 25 | ## v0.1.0 (2021-11-8) 26 | 27 | - 初始版本 28 | - 新增 Workerman 驱动 29 | - demo 可运行 30 | - 发布 Composer 包 31 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | reportUnmatchedIgnoredErrors: false 3 | treatPhpDocTypesAsCertain: false 4 | level: 4 5 | paths: 6 | - ./src/ 7 | ignoreErrors: 8 | - '#OS_TYPE_(LINUX|WINDOWS) not found#' 9 | - '#class Fiber#' 10 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | ./tests 19 | 20 | 21 | 22 | 23 | ./src/OneBot 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/OneBot/Config/Config.php: -------------------------------------------------------------------------------- 1 | repository = new Repository($context); 27 | break; 28 | case is_string($context): 29 | $this->repository = new Repository(); 30 | $this->load($context, new DelegateLoader()); 31 | break; 32 | case $context instanceof RepositoryInterface: 33 | $this->repository = $context; 34 | break; 35 | default: 36 | $this->repository = new Repository(); 37 | } 38 | } 39 | 40 | /** 41 | * 获取配置仓库 42 | */ 43 | public function getRepository(): RepositoryInterface 44 | { 45 | return $this->repository; 46 | } 47 | 48 | /** 49 | * 设置配置仓库 50 | */ 51 | public function setRepository(RepositoryInterface $repository): void 52 | { 53 | $this->repository = $repository; 54 | } 55 | 56 | /** 57 | * 加载配置 58 | * 59 | * @param mixed $context 传递给加载器的上下文,通常是文件路径 60 | * @param LoaderInterface $loader 指定的加载器 61 | */ 62 | public function load($context, LoaderInterface $loader): void 63 | { 64 | $data = $loader->load($context); 65 | foreach ($data as $key => $value) { 66 | if (is_array($value)) { 67 | $this->merge($key, $value); 68 | } else { 69 | $this->set($key, $value); 70 | } 71 | } 72 | } 73 | 74 | /** 75 | * 合并传入的配置数组至指定的配置项 76 | * 77 | * 请注意内部实现是 array_replace_recursive,而不是 array_merge 78 | * 79 | * @param string $key 目标配置项,必须为数组 80 | * @param array $config 要合并的配置数组 81 | */ 82 | public function merge(string $key, array $config): void 83 | { 84 | $original = $this->get($key, []); 85 | $this->set($key, array_replace_recursive($original, $config)); 86 | } 87 | 88 | /** 89 | * 获取配置项 90 | * 91 | * @param string $key 键名,使用.分割多维数组 92 | * @param mixed $default 默认值 93 | * @return null|array|mixed 94 | * 95 | * @codeCoverageIgnore 已在 RepositoryTest 中测试 96 | */ 97 | public function get(string $key, $default = null) 98 | { 99 | return $this->repository->get($key, $default); 100 | } 101 | 102 | /** 103 | * 设置配置项 104 | * 105 | * @param string $key 键名,使用.分割多维数组 106 | * @param null|mixed $value 值,null表示删除 107 | * 108 | * @codeCoverageIgnore 已在 RepositoryTest 中测试 109 | */ 110 | public function set(string $key, $value): void 111 | { 112 | $this->repository->set($key, $value); 113 | } 114 | 115 | /** 116 | * 判断配置项是否存在 117 | * 118 | * @param string $key 键名,使用.分割多维数组 119 | * @return bool 是否存在 120 | * 121 | * @codeCoverageIgnore 已在 RepositoryTest 中测试 122 | */ 123 | public function has(string $key): bool 124 | { 125 | return $this->repository->has($key); 126 | } 127 | 128 | /** 129 | * 获取所有配置项 130 | * 131 | * @codeCoverageIgnore 已在 RepositoryTest 中测试 132 | */ 133 | public function all(): array 134 | { 135 | return $this->repository->all(); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/OneBot/Config/Loader/AbstractFileLoader.php: -------------------------------------------------------------------------------- 1 | getAbsolutePath($source, getcwd()); 16 | $this->ensureFileExists($file); 17 | 18 | try { 19 | $data = $this->loadFile($file); 20 | } catch (\Throwable $e) { 21 | throw new LoadException("配置文件 '{$file}' 加载失败:{$e->getMessage()}", 0, $e); 22 | } 23 | $this->ensureDataLoaded($data, $file); 24 | 25 | return (array) $data; 26 | } 27 | 28 | /** 29 | * 从文件加载配置 30 | * 31 | * @param string $file 文件路径(绝对路径) 32 | * @return array|mixed|\stdClass 配置数组、对象或者其他类型,但其最终必须可以被转换为数组,可以直接返回null或false代表失败 33 | */ 34 | abstract protected function loadFile(string $file); 35 | 36 | /** 37 | * 获取文件的绝对路径 38 | * 39 | * @param string $file 文件路径(相对路径) 40 | * @param string $base 基础路径 41 | */ 42 | protected function getAbsolutePath(string $file, string $base): string 43 | { 44 | return FileUtil::isRelativePath($file) ? $base . DIRECTORY_SEPARATOR . $file : $file; 45 | } 46 | 47 | protected function ensureFileExists(string $file): void 48 | { 49 | if (!is_file($file) || !is_readable($file)) { 50 | throw new LoadException("配置文件 '{$file}' 不存在或不可读"); 51 | } 52 | } 53 | 54 | protected function ensureDataLoaded($data, string $file): void 55 | { 56 | if ($data === false || $data === null) { 57 | throw new LoadException("配置文件 '{$file}' 加载失败"); 58 | } 59 | 60 | if (!$data instanceof \stdClass && !Utils::isAssocArray((array) $data)) { 61 | throw new LoadException("配置文件 '{$file}' 加载失败:配置必须为关联数组或对象"); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/OneBot/Config/Loader/DelegateLoader.php: -------------------------------------------------------------------------------- 1 | $loader) { 20 | if (!$loader instanceof LoaderInterface) { 21 | throw new \UnexpectedValueException("加载器 {$key} 不是有效的加载器,必须实现 LoaderInterface 接口"); 22 | } 23 | } 24 | 25 | $this->loaders = $loaders ?? self::getDefaultLoaders(); 26 | } 27 | 28 | public function load($source): array 29 | { 30 | return $this->determineLoader($source)->load($source); 31 | } 32 | 33 | public static function getDefaultLoaders(): array 34 | { 35 | return [ 36 | 'json' => new JsonFileLoader(), 37 | ]; 38 | } 39 | 40 | protected function determineLoader($source): LoaderInterface 41 | { 42 | $key = is_dir($source) ? 'dir' : pathinfo($source, PATHINFO_EXTENSION); 43 | 44 | if (!isset($this->loaders[$key])) { 45 | throw new \UnexpectedValueException("无法确定加载器,未知的配置来源:{$source}"); 46 | } 47 | 48 | return $this->loaders[$key]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/OneBot/Config/Loader/JsonFileLoader.php: -------------------------------------------------------------------------------- 1 | getMessage()}"); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/OneBot/Config/Loader/LoadException.php: -------------------------------------------------------------------------------- 1 | config = $config; 22 | } 23 | 24 | public function get(string $key, $default = null) 25 | { 26 | // 在表层直接查找,找到就直接返回 27 | if (array_key_exists($key, $this->config)) { 28 | return $this->config[$key]; 29 | } 30 | 31 | // 判断是否包含.,即是否读取多维数组,否则代表没有对应数据 32 | if (strpos($key, '.') === false) { 33 | return $default; 34 | } 35 | 36 | // 在多维数组中查找 37 | $data = $this->config; 38 | foreach (explode('.', $key) as $segment) { 39 | // $data不是数组表示没有下级元素 40 | // $segment不在数组中表示没有对应数据 41 | if (!is_array($data) || !array_key_exists($segment, $data)) { 42 | return $default; 43 | } 44 | 45 | $data = &$data[$segment]; 46 | } 47 | 48 | return $data; 49 | } 50 | 51 | public function set(string $key, $value): void 52 | { 53 | if ($value === null) { 54 | $this->delete($key); 55 | return; 56 | } 57 | 58 | $data = &$this->config; 59 | 60 | // 找到对应的插入位置,并确保前置数组存在 61 | foreach (explode('.', $key) as $segment) { 62 | if (!isset($data[$segment]) || !is_array($data[$segment])) { 63 | $data[$segment] = []; 64 | } 65 | 66 | $data = &$data[$segment]; 67 | } 68 | 69 | $data = $value; 70 | } 71 | 72 | public function has(string $key): bool 73 | { 74 | return $this->get($key) !== null; 75 | } 76 | 77 | public function all(): array 78 | { 79 | return $this->config; 80 | } 81 | 82 | /** 83 | * 删除指定配置项 84 | * 85 | * @param string $key 键名,使用.分割多维数组 86 | * @internal 87 | */ 88 | private function delete(string $key): void 89 | { 90 | if (array_key_exists($key, $this->config)) { 91 | unset($this->config[$key]); 92 | return; 93 | } 94 | 95 | $data = &$this->config; 96 | $segments = explode('.', $key); 97 | $lastSegment = array_pop($segments); 98 | 99 | foreach ($segments as $segment) { 100 | if (!isset($data[$segment]) || !is_array($data[$segment])) { 101 | return; 102 | } 103 | 104 | $data = &$data[$segment]; 105 | } 106 | 107 | unset($data[$lastSegment]); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/OneBot/Config/RepositoryInterface.php: -------------------------------------------------------------------------------- 1 | getName() === 'swoole') { 30 | self::$coroutine = SwooleCoroutine::getInstance($driver); 31 | } elseif ($driver->getName() === 'workerman' && PHP_VERSION_ID >= 80100) { 32 | // 只有 PHP >= 8.1 才能使用 Fiber 协程接口 33 | self::$coroutine = FiberCoroutine::getInstance($driver); 34 | } 35 | } 36 | 37 | /** 38 | * 挂起多少秒 39 | * 40 | * @param float|int $time 暂停的秒数,支持小数到 0.001 41 | */ 42 | public static function sleep($time) 43 | { 44 | $cid = self::$coroutine instanceof CoroutineInterface ? self::$coroutine->getCid() : -1; 45 | if ($cid === -1) { 46 | goto default_sleep; 47 | } 48 | if (self::$coroutine instanceof SwooleCoroutine) { 49 | Coroutine::sleep($time); 50 | return; 51 | } 52 | if (self::$coroutine instanceof FiberCoroutine) { 53 | WorkermanDriver::getInstance()->getEventLoop()->addTimer(intval($time * 1000), function () use ($cid) { 54 | self::$coroutine->resume($cid); 55 | }); 56 | self::$coroutine->suspend(); 57 | return; 58 | } 59 | default_sleep: 60 | usleep($time * 1000 * 1000); 61 | } 62 | 63 | /** 64 | * 执行命令行 65 | * 66 | * @param string $cmd 命令行 67 | */ 68 | public static function exec(string $cmd): ExecutionResult 69 | { 70 | $cid = self::$coroutine instanceof CoroutineInterface ? self::$coroutine->getCid() : -1; 71 | if ($cid === -1) { 72 | goto default_exec; 73 | } 74 | if (self::$coroutine instanceof SwooleCoroutine) { 75 | $result = Coroutine\System::exec($cmd); 76 | return new ExecutionResult($result['code'], $result['output']); 77 | } 78 | if (self::$coroutine instanceof FiberCoroutine) { 79 | $descriptorspec = [ 80 | 0 => ['pipe', 'r'], // 标准输入,子进程从此管道中读取数据 81 | 1 => ['pipe', 'w'], // 标准输出,子进程向此管道中写入数据 82 | 2 => STDERR, // 标准错误 83 | ]; 84 | $res = proc_open($cmd, $descriptorspec, $pipes, getcwd()); 85 | if (is_resource($res)) { 86 | $cid = self::$coroutine->getCid(); 87 | WorkermanDriver::getInstance()->getEventLoop()->addReadEvent($pipes[1], function ($x) use ($cid, $res, $pipes) { 88 | $stdout = stream_get_contents($x); 89 | $status = proc_get_status($res); 90 | if ($status['exitcode'] !== -1) { 91 | WorkermanDriver::getInstance()->getEventLoop()->delReadEvent($x); 92 | fclose($x); 93 | fclose($pipes[0]); 94 | $out = new ExecutionResult($status['exitcode'], $stdout); 95 | } else { 96 | $out = new ExecutionResult(-1); 97 | } 98 | self::$coroutine->resume($cid, $out); 99 | }); 100 | return self::$coroutine->suspend(); 101 | } 102 | throw new \RuntimeException('Cannot open process with command ' . $cmd); 103 | } 104 | default_exec: 105 | exec($cmd, $output, $code); 106 | return new ExecutionResult($code, $output); 107 | } 108 | 109 | public static function getCoroutine(): ?CoroutineInterface 110 | { 111 | return self::$coroutine; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Coroutine/CoroutineInterface.php: -------------------------------------------------------------------------------- 1 | */ 18 | private static array $suspended_fiber_map = []; 19 | 20 | private Driver $driver; 21 | 22 | public function __construct(Driver $driver) 23 | { 24 | $this->driver = $driver; 25 | } 26 | 27 | public static function isAvailable(): bool 28 | { 29 | return PHP_VERSION_ID >= 80100; 30 | } 31 | 32 | /** 33 | * @throws \Throwable 34 | * @throws \RuntimeException 35 | */ 36 | public function create(callable $callback, ...$args): int 37 | { 38 | if (PHP_VERSION_ID < 80100) { 39 | throw new \RuntimeException('You need PHP >= 8.1 to enable Fiber feature!'); 40 | } 41 | $fiber = new \Fiber($callback); 42 | 43 | if (self::$fiber_stacks === null) { 44 | self::$fiber_stacks = new \SplStack(); 45 | } 46 | 47 | self::$fiber_stacks->push($fiber); 48 | $fiber->start(...$args); 49 | self::$fiber_stacks->pop(); 50 | $id = spl_object_id($fiber); 51 | if (!$fiber->isTerminated()) { 52 | self::$suspended_fiber_map[$id] = $fiber; 53 | } 54 | return $id; 55 | } 56 | 57 | public function exists(int $cid): bool 58 | { 59 | return isset(self::$suspended_fiber_map[$cid]); 60 | } 61 | 62 | /** 63 | * @throws \Throwable 64 | * @throws \RuntimeException 65 | */ 66 | public function suspend() 67 | { 68 | if (PHP_VERSION_ID < 80100) { 69 | throw new \RuntimeException('You need PHP >= 8.1 to enable Fiber feature!'); 70 | } 71 | return \Fiber::suspend(); 72 | } 73 | 74 | /** 75 | * @param null|mixed $value 76 | * @throws \RuntimeException 77 | * @throws \Throwable 78 | * @return false|int 79 | */ 80 | public function resume(int $cid, $value = null) 81 | { 82 | if (PHP_VERSION_ID < 80100) { 83 | throw new \RuntimeException('You need PHP >= 8.1 to enable Fiber feature!'); 84 | } 85 | if (!isset(self::$suspended_fiber_map[$cid])) { 86 | return false; 87 | } 88 | self::$fiber_stacks->push(self::$suspended_fiber_map[$cid]); 89 | self::$suspended_fiber_map[$cid]->resume($value); 90 | self::$fiber_stacks->pop(); 91 | if (self::$suspended_fiber_map[$cid]->isTerminated()) { 92 | unset(self::$suspended_fiber_map[$cid]); 93 | } 94 | return $cid; 95 | } 96 | 97 | public function getCid(): int 98 | { 99 | try { 100 | $v = self::$fiber_stacks->pop(); 101 | self::$fiber_stacks->push($v); 102 | } catch (\RuntimeException $e) { 103 | return -1; 104 | } 105 | return spl_object_id($v); 106 | } 107 | 108 | /** 109 | * @param mixed $time 110 | * @throws \Throwable 111 | * @throws \RuntimeException 112 | */ 113 | public function sleep($time) 114 | { 115 | if (($cid = $this->getCid()) !== -1) { 116 | $this->driver->getEventLoop()->addTimer($time * 1000, function () use ($cid) { 117 | $this->resume($cid); 118 | }); 119 | $this->suspend(); 120 | return; 121 | } 122 | 123 | usleep($time * 1000 * 1000); 124 | } 125 | 126 | /** 127 | * @throws \Throwable 128 | * @throws \RuntimeException 129 | */ 130 | public function exec(string $cmd): ExecutionResult 131 | { 132 | if (($cid = $this->getCid()) !== -1) { 133 | $descriptorspec = [ 134 | 0 => ['pipe', 'r'], // 标准输入,子进程从此管道中读取数据 135 | 1 => ['pipe', 'w'], // 标准输出,子进程向此管道中写入数据 136 | 2 => STDERR, // 标准错误 137 | ]; 138 | $res = proc_open($cmd, $descriptorspec, $pipes, getcwd()); 139 | if (is_resource($res)) { 140 | $this->driver->getEventLoop()->addReadEvent($pipes[1], function ($x) use ($cid, $res, $pipes) { 141 | $stdout = stream_get_contents($x); 142 | $status = proc_get_status($res); 143 | $this->driver->getEventLoop()->delReadEvent($x); 144 | if ($status['exitcode'] !== -1) { 145 | fclose($x); 146 | fclose($pipes[0]); 147 | $out = new ExecutionResult($status['exitcode'], $stdout); 148 | } else { 149 | $out = new ExecutionResult(-1); 150 | } 151 | $this->resume($cid, $out); 152 | }); 153 | return $this->suspend(); 154 | } 155 | throw new \RuntimeException('Cannot open process with command ' . $cmd); 156 | } 157 | 158 | exec($cmd, $output, $code); 159 | return new ExecutionResult($code, $output); 160 | } 161 | 162 | public function getCount(): int 163 | { 164 | return self::$fiber_stacks->count(); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Coroutine/SwooleCoroutine.php: -------------------------------------------------------------------------------- 1 | getCid()])) { 31 | $value = self::$resume_values[$this->getCid()]; 32 | unset(self::$resume_values[$this->getCid()]); 33 | return $value; 34 | } 35 | return null; 36 | } 37 | 38 | public function exists(int $cid): bool 39 | { 40 | return Coroutine::exists($cid); 41 | } 42 | 43 | public function resume(int $cid, $value = null) 44 | { 45 | if (Coroutine::exists($cid)) { 46 | self::$resume_values[$cid] = $value; 47 | Coroutine::resume($cid); 48 | return $cid; 49 | } 50 | return false; 51 | } 52 | 53 | public function getCid(): int 54 | { 55 | return Coroutine::getCid(); 56 | } 57 | 58 | public function sleep($time) 59 | { 60 | Coroutine::sleep($time); 61 | } 62 | 63 | public function exec(string $cmd): ExecutionResult 64 | { 65 | $result = Coroutine\System::exec($cmd); 66 | return new ExecutionResult($result['code'], $result['output']); 67 | } 68 | 69 | public function getCount(): int 70 | { 71 | return Coroutine::stats()['coroutine_num']; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Driver.php: -------------------------------------------------------------------------------- 1 | params = $params; 44 | self::$active_driver_class = static::class; 45 | } 46 | 47 | /** 48 | * 获取当前活动的 Driver 类 49 | */ 50 | public static function getActiveDriverClass(): string 51 | { 52 | return self::$active_driver_class; 53 | } 54 | 55 | /** 56 | * 获取驱动初始化策略 57 | */ 58 | public function getDriverInitPolicy(): int 59 | { 60 | return $this->getParam('driver_init_policy', DriverInitPolicy::MULTI_PROCESS_INIT_IN_FIRST_WORKER); 61 | } 62 | 63 | /** 64 | * 初始化通讯 65 | * 66 | * @param array $comm 启用的通讯方式 67 | */ 68 | public function initDriverProtocols(array $comm) 69 | { 70 | $ws_index = []; 71 | $http_index = []; 72 | $has_http_webhook = []; 73 | $has_ws_reverse = []; 74 | foreach ($comm as $v) { 75 | switch ($v['type']) { 76 | case 'websocket': 77 | case 'ws': 78 | $ws_index[] = $v; 79 | break; 80 | case 'http': 81 | $http_index[] = $v; 82 | break; 83 | case 'http_webhook': 84 | case 'webhook': 85 | $has_http_webhook[] = $v; 86 | break; 87 | case 'ws_reverse': 88 | case 'websocket_reverse': 89 | $has_ws_reverse[] = $v; 90 | break; 91 | } 92 | } 93 | [$http, $webhook, $ws, $ws_reverse] = $this->initInternalDriverClasses($http_index, $has_http_webhook, $ws_index, $has_ws_reverse); 94 | if ($ws) { 95 | ob_logger()->debug('已开启正向 WebSocket'); 96 | } 97 | if ($http) { 98 | ob_logger()->debug('已开启 HTTP'); 99 | } 100 | if ($webhook) { 101 | ob_logger()->debug('已开启 HTTP Webhook'); 102 | } 103 | if ($ws_reverse) { 104 | ob_logger()->debug('已开启反向 WebSocket'); 105 | } 106 | } 107 | 108 | /** 109 | * 获取 Driver 自身传入的配置项(所有) 110 | */ 111 | public function getParams(): array 112 | { 113 | return $this->params; 114 | } 115 | 116 | /** 117 | * 获取 Driver 自身传入的配置项 118 | * 119 | * @param int|string $key 120 | * @param mixed $default 121 | * @return mixed 122 | */ 123 | public function getParam($key, $default) 124 | { 125 | return $this->params[$key] ?? $default; 126 | } 127 | 128 | public function setParams(array $params): void 129 | { 130 | $this->params = $params; 131 | } 132 | 133 | public function getSupportedClients(): array 134 | { 135 | return static::SUPPORTED_CLIENTS; 136 | } 137 | 138 | /** 139 | * 运行驱动 140 | */ 141 | abstract public function run(): void; 142 | 143 | /** 144 | * 获取驱动名称 145 | */ 146 | abstract public function getName(): string; 147 | 148 | /** 149 | * 获取 Driver 相关的底层事件循环接口 150 | */ 151 | abstract public function getEventLoop(): DriverEventLoopBase; 152 | 153 | /** 154 | * 初始化驱动的 WS Reverse Client 连接 155 | * 156 | * @param array $headers 请求头 157 | */ 158 | abstract public function initWSReverseClients(array $headers = []); 159 | 160 | /** 161 | * 根据驱动类型创建一个自动化的 HTTP 请求 Socket 对象 162 | * 163 | * @param array $config 配置 164 | */ 165 | abstract public function createHttpClientSocket(array $config): HttpClientSocketBase; 166 | 167 | /** 168 | * 通过解析的配置,让 Driver 初始化不同的通信方式 169 | * 170 | * 当传入的任一参数不为 null 时,表明此通信方式启用。 171 | */ 172 | abstract protected function initInternalDriverClasses(?array $http, ?array $http_webhook, ?array $ws, ?array $ws_reverse): array; 173 | } 174 | -------------------------------------------------------------------------------- /src/OneBot/Driver/DriverEventLoopBase.php: -------------------------------------------------------------------------------- 1 | propagation_stopped; 30 | } 31 | 32 | /** 33 | * 停止分发 34 | * 通过抛出异常 35 | * 36 | * @throws StopException 37 | */ 38 | public function stopPropagation(): void 39 | { 40 | throw new StopException($this); 41 | } 42 | 43 | /** 44 | * 停止分发 45 | * 46 | * @internal 47 | */ 48 | public function setPropagationStopped(): void 49 | { 50 | $this->propagation_stopped = true; 51 | } 52 | 53 | public function getSocketFlag(): int 54 | { 55 | return $this->socket_config['flag'] ?? 1; 56 | } 57 | 58 | public function getSocketConfig(): array 59 | { 60 | return $this->socket_config; 61 | } 62 | 63 | public function setSocketConfig(array $socket_config): void 64 | { 65 | $this->socket_config = $socket_config; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Event/DriverInitEvent.php: -------------------------------------------------------------------------------- 1 | driver = $driver; 19 | $this->driver_mode = $driver_mode; 20 | } 21 | 22 | public function getDriver(): Driver 23 | { 24 | return $this->driver; 25 | } 26 | 27 | /** 28 | * @return int|mixed 29 | */ 30 | public function getDriverMode() 31 | { 32 | return $this->driver_mode; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Event/Event.php: -------------------------------------------------------------------------------- 1 | getEventListeners($event->getName()) as $listener) { 19 | try { 20 | // TODO: 允许 Listener 修改 $event 21 | // TODO: 在调用 listener 前先判断 isPropagationStopped 22 | $listener[1]($event); 23 | } catch (StopException $exception) { 24 | // ob_logger()->debug('EventLoop ' . $event . ' stopped'); 25 | if ($event instanceof DriverEvent) { 26 | $event->setPropagationStopped(); 27 | } 28 | break; 29 | } 30 | } 31 | return $event; 32 | } 33 | 34 | /** 35 | * 一键分发事件,并handle错误 36 | */ 37 | public function dispatchWithHandler(object $event) 38 | { 39 | try { 40 | (new self())->dispatch($event); 41 | } catch (\Throwable $e) { 42 | ExceptionHandler::getInstance()->handle($e); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Event/EventProvider.php: -------------------------------------------------------------------------------- 1 | >> 已注册的事件监听器 16 | */ 17 | private static $_events = []; 18 | 19 | /** 20 | * 添加事件监听器 21 | * 22 | * @param object|string $event 事件名称 23 | * @param callable $callback 事件回调 24 | * @param int $level 事件等级 25 | */ 26 | public function addEventListener($event, callable $callback, int $level = 20) 27 | { 28 | /* 29 | * TODO: 尝试同时支持类名和自定义名称作为事件名 30 | * NOTE: 这有可能导致事件日志难以追溯? 31 | * NOTE: 使用自定义名称的一个替代方法是在 EventLoop 类中实现 getName 方法 32 | * NOTE: 如果使用自定义名称,则需要在事件处理器中使用 `$event->getName()` 获取事件名 33 | * NOTE: 或者是否由其他可能的方法支持自定义名称,从而避免频繁的 new EventDispatcher 34 | */ 35 | if (is_object($event)) { 36 | $event = get_class($event); 37 | } 38 | self::$_events[$event][] = [$level, $callback]; 39 | $this->sortEvents($event); 40 | } 41 | 42 | /** 43 | * 获取事件监听器 44 | * 45 | * @param string $event_name 事件名称 46 | * @return array 47 | */ 48 | public function getEventListeners(string $event_name): array 49 | { 50 | return self::$_events[$event_name] ?? []; 51 | } 52 | 53 | /** 54 | * 获取事件监听器 55 | * 56 | * @param object $event 事件对象 57 | * @return iterable 58 | */ 59 | public function getListenersForEvent(object $event): iterable 60 | { 61 | return self::getEventListeners($event->getName()); 62 | } 63 | 64 | private function sortEvents($name) 65 | { 66 | usort(self::$_events[$name], function ($a, $b) { 67 | return $a[0] <= $b[0] ? -1 : 1; 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Event/Http/HttpRequestEvent.php: -------------------------------------------------------------------------------- 1 | request = $request; 46 | $this->origin_request = $origin_request; 47 | } 48 | 49 | public function getRequest(): ServerRequestInterface 50 | { 51 | return $this->request; 52 | } 53 | 54 | public function withResponse(ResponseInterface $response): HttpRequestEvent 55 | { 56 | $this->response = $response; 57 | return $this; 58 | } 59 | 60 | public function getResponse(): ?ResponseInterface 61 | { 62 | return $this->response; 63 | } 64 | 65 | public function setErrorHandler(callable $callable) 66 | { 67 | $this->error_handler = $callable; 68 | } 69 | 70 | public function getErrorHandler(): callable 71 | { 72 | return $this->error_handler; 73 | } 74 | 75 | public function withAsyncResponseCallable(callable $callable): HttpRequestEvent 76 | { 77 | $this->async_send_callable = $callable; 78 | return $this; 79 | } 80 | 81 | public function setAsyncSend(bool $async_send = true): void 82 | { 83 | $this->async_send = $async_send; 84 | } 85 | 86 | public function getAsyncSendCallable(): ?callable 87 | { 88 | return $this->async_send_callable; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Event/Process/ManagerStartEvent.php: -------------------------------------------------------------------------------- 1 | process = $process; 20 | } 21 | 22 | public function getProcess(): ProcessInterface 23 | { 24 | return $this->process; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Event/Process/WorkerExitEvent.php: -------------------------------------------------------------------------------- 1 | event = $event; 14 | parent::__construct($message, $code, $previous); 15 | } 16 | 17 | public function getEvent(): DriverEvent 18 | { 19 | return $this->event; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Event/WebSocket/WebSocketClientOpenEvent.php: -------------------------------------------------------------------------------- 1 | fd = $fd; 18 | $this->send_callback = $send_callback; 19 | } 20 | 21 | public function getFd(): int 22 | { 23 | return $this->fd; 24 | } 25 | 26 | public function send($data) 27 | { 28 | return call_user_func($this->send_callback, $this->fd, $data); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Event/WebSocket/WebSocketCloseEvent.php: -------------------------------------------------------------------------------- 1 | fd = $fd; 16 | } 17 | 18 | public function getFd(): int 19 | { 20 | return $this->fd; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Event/WebSocket/WebSocketMessageEvent.php: -------------------------------------------------------------------------------- 1 | fd = $fd; 35 | $this->frame = $frame; 36 | $this->send_callback = $send_callback; 37 | } 38 | 39 | public function getFrame(): FrameInterface 40 | { 41 | return $this->frame; 42 | } 43 | 44 | public function getFd(): int 45 | { 46 | return $this->fd; 47 | } 48 | 49 | public function send($data) 50 | { 51 | return call_user_func($this->send_callback, $this->fd, $data); 52 | } 53 | 54 | public function setOriginFrame($frame): void 55 | { 56 | $this->origin_frame = $frame; 57 | } 58 | 59 | /** 60 | * @return mixed 61 | */ 62 | public function getOriginFrame() 63 | { 64 | return $this->origin_frame; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Event/WebSocket/WebSocketOpenEvent.php: -------------------------------------------------------------------------------- 1 | request = $request; 22 | $this->fd = $fd; 23 | } 24 | 25 | public function getRequest(): ServerRequestInterface 26 | { 27 | return $this->request; 28 | } 29 | 30 | /** 31 | * @return $this 32 | */ 33 | public function withResponse(?ResponseInterface $response): WebSocketOpenEvent 34 | { 35 | $this->response = $response; 36 | return $this; 37 | } 38 | 39 | public function getResponse(): ?ResponseInterface 40 | { 41 | return $this->response; 42 | } 43 | 44 | public function getFd(): int 45 | { 46 | return $this->fd; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Interfaces/DriverInitPolicy.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | public function getEventListeners(string $event_name): array; 18 | 19 | /** 20 | * 添加事件监听器 21 | * 22 | * @param object|string $event 事件名称或事件对象 23 | * @param callable $callback 事件回调 24 | * @param int $level 事件等级 25 | */ 26 | public function addEventListener($event, callable $callback, int $level = 20); 27 | } 28 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Interfaces/WebSocketClientInterface.php: -------------------------------------------------------------------------------- 1 | code = $code; 18 | $this->stdout = $stdout; 19 | $this->stderr = $stderr; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Process/ProcessManager.php: -------------------------------------------------------------------------------- 1 | url = $config['url']; 40 | $this->headers = $config['headers'] ?? []; 41 | $this->access_token = $config['access_token'] ?? ''; 42 | $this->timeout = $config['timeout'] ?? 5; 43 | $this->config = $config; 44 | } 45 | 46 | public function getUrl(): string 47 | { 48 | return $this->url; 49 | } 50 | 51 | public function getHeaders(): array 52 | { 53 | return $this->headers; 54 | } 55 | 56 | public function getAccessToken(): string 57 | { 58 | return $this->access_token; 59 | } 60 | 61 | public function getTimeout(): int 62 | { 63 | return $this->timeout; 64 | } 65 | 66 | public function withoutAsync(bool $no_async = true): HttpClientSocketBase 67 | { 68 | $this->no_async = $no_async; 69 | return $this; 70 | } 71 | 72 | public function get(array $headers, callable $success_callback, callable $error_callback) 73 | { 74 | $request = HttpFactory::createRequest('GET', $this->url, array_merge($this->headers, $headers)); 75 | return $this->sendRequest($request, $success_callback, $error_callback); 76 | } 77 | 78 | /** 79 | * @param array|\JsonSerializable|string $data 数据 80 | * @param array $headers 头 81 | * @param callable $success_callback 成功回调 82 | * @param callable $error_callback 错误回调 83 | * @return bool|mixed 84 | */ 85 | public function post($data, array $headers, callable $success_callback, callable $error_callback) 86 | { 87 | if ($data instanceof \JsonSerializable) { 88 | $data = json_encode($data); 89 | } 90 | $request = HttpFactory::createRequest('POST', $this->url, array_merge($this->headers, $headers), $data); 91 | return $this->sendRequest($request, $success_callback, $error_callback); 92 | } 93 | 94 | /** 95 | * @param RequestInterface $request 请求对象 96 | * @return bool|mixed 97 | */ 98 | public function sendRequest(RequestInterface $request, callable $success_callback, callable $error_callback) 99 | { 100 | if ($this->client_cache === null) { 101 | $class = Driver::getActiveDriverClass(); 102 | foreach (($class::SUPPORTED_CLIENTS ?? []) as $v) { 103 | if (is_a($v, AsyncClientInterface::class, true)) { 104 | $this->client_cache_async = true; 105 | } 106 | try { 107 | /* @throws ClientException */ 108 | $this->client_cache = new $v(); 109 | $this->client_cache->setTimeout($this->timeout * 1000); 110 | } catch (ClientException $e) { 111 | continue; 112 | } 113 | break; 114 | } 115 | } 116 | if ($this->client_cache_async && !$this->no_async) { 117 | $this->client_cache->sendRequestAsync($request, $success_callback, $error_callback); 118 | return true; 119 | } 120 | try { 121 | $response = $this->client_cache->sendRequest($request); 122 | return $success_callback($response); 123 | } catch (\Throwable $e) { 124 | return $error_callback($request, $e); 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Socket/HttpServerSocketBase.php: -------------------------------------------------------------------------------- 1 | config; 14 | } 15 | 16 | public function setConfig(array $config) 17 | { 18 | $this->config = $config; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Socket/SocketFlag.php: -------------------------------------------------------------------------------- 1 | flag = $flag; 15 | return $this; 16 | } 17 | 18 | public function getFlag(): int 19 | { 20 | return $this->flag; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Socket/SocketTrait.php: -------------------------------------------------------------------------------- 1 | ws_socket as $socket) { 26 | if ($socket->getFlag() === $flag) { 27 | return $socket; 28 | } 29 | } 30 | return null; 31 | } 32 | 33 | /** 34 | * @return \Generator|WSServerSocketBase[] 35 | */ 36 | public function getWSServerSocketsByFlag(int $flag = 0): \Generator 37 | { 38 | foreach ($this->ws_socket as $socket) { 39 | if ($socket->getFlag() === $flag) { 40 | yield $socket; 41 | } 42 | } 43 | } 44 | 45 | /** 46 | * @return \Generator|HttpServerSocketBase[] 47 | */ 48 | public function getHttpServerSocketsByFlag(int $flag = 0): \Generator 49 | { 50 | foreach ($this->http_socket as $socket) { 51 | if ($socket->getFlag() === $flag) { 52 | yield $socket; 53 | } 54 | } 55 | } 56 | 57 | /** 58 | * @return \Generator|HttpClientSocketBase[] 59 | */ 60 | public function getHttpWebhookSocketsByFlag(int $flag = 0): \Generator 61 | { 62 | foreach ($this->http_client_socket as $socket) { 63 | if ($socket->getFlag() === $flag) { 64 | yield $socket; 65 | } 66 | } 67 | } 68 | 69 | /** 70 | * @return \Generator|WSClientSocketBase[] 71 | */ 72 | public function getWSReverseSocketsByFlag(int $flag = 0): \Generator 73 | { 74 | foreach ($this->ws_client_socket as $socket) { 75 | if ($socket->getFlag() === $flag) { 76 | yield $socket; 77 | } 78 | } 79 | } 80 | 81 | /* ======================== Getter for all ======================== */ 82 | 83 | /** 84 | * @return WSServerSocketBase[] 85 | */ 86 | public function getWSServerSockets(): array 87 | { 88 | return $this->ws_socket; 89 | } 90 | 91 | /** 92 | * @return HttpServerSocketBase[] 93 | */ 94 | public function getHttpServerSockets(): array 95 | { 96 | return $this->http_socket; 97 | } 98 | 99 | /** 100 | * @return HttpClientSocketBase[] 101 | */ 102 | public function getHttpWebhookSockets(): array 103 | { 104 | return $this->http_client_socket; 105 | } 106 | 107 | /** 108 | * @return WSClientSocketBase[] 109 | */ 110 | public function getWSReverseSockets(): array 111 | { 112 | return $this->ws_client_socket; 113 | } 114 | 115 | /* ======================== Adder ======================== */ 116 | 117 | public function addWSServerSocket(WSServerSocketBase $socket): void 118 | { 119 | $this->ws_socket[] = $socket; 120 | } 121 | 122 | public function addHttpServerSocket(HttpServerSocketBase $socket): void 123 | { 124 | $this->http_socket[] = $socket; 125 | } 126 | 127 | public function addHttpWebhookSocket(HttpClientSocketBase $socket): void 128 | { 129 | $this->http_client_socket[] = $socket; 130 | } 131 | 132 | public function addWSReverseSocket(WSClientSocketBase $socket): void 133 | { 134 | $this->ws_client_socket[] = $socket; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Socket/WSClientSocketBase.php: -------------------------------------------------------------------------------- 1 | url = $config['url']; 29 | $this->headers = $config['headers'] ?? []; 30 | $this->access_token = $config['access_token'] ?? ''; 31 | $this->reconnect_interval = $config['reconnect_interval'] ?? 5; 32 | $this->config = $config; 33 | } 34 | 35 | public function setClient(WebSocketClientInterface $client) 36 | { 37 | $this->client = $client; 38 | } 39 | 40 | public function getUrl(): string 41 | { 42 | return $this->url; 43 | } 44 | 45 | public function getHeaders(): array 46 | { 47 | return $this->headers; 48 | } 49 | 50 | public function getAccessToken(): string 51 | { 52 | return $this->access_token; 53 | } 54 | 55 | public function getReconnectInterval(): int 56 | { 57 | return $this->reconnect_interval; 58 | } 59 | 60 | public function getClient(): WebSocketClientInterface 61 | { 62 | return $this->client; 63 | } 64 | 65 | public function send($data, $fd = null): bool 66 | { 67 | return $this->client->send($data); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Socket/WSServerSocketBase.php: -------------------------------------------------------------------------------- 1 | 0) { 38 | ++$timer_count; 39 | if ($timer_count > $times) { 40 | Timer::clear($timer_id); 41 | return; 42 | } 43 | } 44 | $callable($timer_id, ...$params); 45 | }, ...$arguments); 46 | } 47 | 48 | public function clearTimer(int $timer_id) 49 | { 50 | Timer::clear($timer_id); 51 | } 52 | 53 | public function clearAllTimer() 54 | { 55 | Timer::clearAll(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Swoole/ObjectPool.php: -------------------------------------------------------------------------------- 1 | class = $construct_class; 35 | $this->args = $args; 36 | $this->size = $size; 37 | $this->channel = new Channel($size + 10); 38 | } 39 | 40 | public function __destruct() 41 | { 42 | while (!$this->channel->isEmpty()) { 43 | $this->channel->pop(); 44 | } 45 | $this->channel->close(); 46 | unset($this->channel); 47 | } 48 | 49 | /** 50 | * 获取对象 51 | */ 52 | public function get(): object 53 | { 54 | if ($this->getFreeCount() <= 0) { // 当池子见底了,就自动用 Swoole 的 Channel 消费者模型堵起来 55 | $result = $this->channel->pop(); 56 | } elseif ($this->channel->isEmpty()) { // 如果 Channel 是空的,那么就新建一个对象 57 | $result = $this->makeObject(); 58 | } else { // 否则就直接从 Channel 中取一个出来 59 | $result = $this->channel->pop(); 60 | } 61 | if (!$result) { // 当池子被关闭则抛出异常 62 | throw new \RuntimeException('Channel has been disabled'); 63 | } 64 | // 记录借出去的 Hash 表 65 | $this->out[spl_object_hash($result)] = 1; 66 | return $result; 67 | } 68 | 69 | public function put(object $object): bool 70 | { 71 | if (!isset($this->out[spl_object_hash($object)])) { 72 | // 不能退还不是这里生产出去的对象 73 | throw new \RuntimeException('Cannot put object that not got from here'); 74 | } 75 | unset($this->out[spl_object_hash($object)]); 76 | return $this->channel->push($object); 77 | } 78 | 79 | public function getFreeCount(): int 80 | { 81 | return $this->size - count($this->out); 82 | } 83 | 84 | protected function makeObject(): object 85 | { 86 | $class = $this->class; 87 | $args = $this->args; 88 | return new $class(...$args); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Swoole/Socket/HttpClientSocket.php: -------------------------------------------------------------------------------- 1 | socket_obj = $server_or_port; 18 | $this->config = $config; 19 | } 20 | 21 | public function getPort(): int 22 | { 23 | return $this->socket_obj->port; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Swoole/Socket/WSClientSocket.php: -------------------------------------------------------------------------------- 1 | server = $server; 21 | $this->port = $port; 22 | $this->config = $config; 23 | } 24 | 25 | public function close($fd): bool 26 | { 27 | return false; 28 | } 29 | 30 | public function send($data, $fd): bool 31 | { 32 | if ($data instanceof FrameInterface) { 33 | return $this->server->push($fd, $data->getData(), $data->getOpcode()); 34 | } 35 | return $this->server->push($fd, $data); 36 | } 37 | 38 | public function sendMultiple($data, ?callable $filter = null): array 39 | { 40 | $result = []; 41 | if ($this->port !== null) { 42 | $a = $this->port->connections; 43 | } else { 44 | $a = $this->server->connections; 45 | } 46 | foreach ($a as $fd) { 47 | if ($this->server->exists($fd) && ($filter === null || $filter($fd, $this))) { 48 | $result[$fd] = $this->send($data, $fd); 49 | } 50 | } 51 | return $result; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Swoole/UserProcess.php: -------------------------------------------------------------------------------- 1 | pid; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Swoole/WebSocketClient.php: -------------------------------------------------------------------------------- 1 | true]) 44 | { 45 | $this->set = $set; 46 | } 47 | 48 | /** 49 | * 通过地址来创建一个 WebSocket 连接 50 | * 51 | * 支持 UriInterface 接口的 PSR 对象,也支持直接传入一个带 Scheme 的 52 | * 53 | * @param string|UriInterface $address 54 | * @throws ClientException 55 | */ 56 | public static function createFromAddress($address, array $header = [], array $set = ['websocket_mask' => true]): WebSocketClientInterface 57 | { 58 | return (new self($set))->withRequest(HttpFactory::createRequest('GET', $address, $header)); 59 | } 60 | 61 | /** 62 | * @throws ClientException 63 | */ 64 | public function withRequest(RequestInterface $request): WebSocketClientInterface 65 | { 66 | $this->request = $request; 67 | $this->client = (new SwooleClient($this->set))->buildBaseClient($request); 68 | $this->fd = ++self::$id_counter; 69 | return $this; 70 | } 71 | 72 | /** 73 | * @throws NetworkException 74 | */ 75 | public function connect(): bool 76 | { 77 | if ($this->status !== self::STATUS_INITIAL) { 78 | return false; 79 | } 80 | $uri = $this->request->getUri()->getPath(); 81 | if ($uri === '') { 82 | $uri = '/'; 83 | } 84 | if (($query = $this->request->getUri()->getQuery()) !== '') { 85 | $uri .= '?' . $query; 86 | } 87 | if (($fragment = $this->request->getUri()->getFragment()) !== '') { 88 | $uri .= '?' . $fragment; 89 | } 90 | $r = $this->client->upgrade($uri); 91 | if ($this->client->errCode !== 0) { 92 | throw new NetworkException($this->request, $this->client->errMsg); 93 | } 94 | if ($r) { 95 | $this->status = self::STATUS_ESTABLISHED; 96 | go(function () { 97 | while (true) { 98 | $result = $this->client->recv(60); 99 | if ($result === false) { 100 | if ($this->client->connected === false) { 101 | $this->status = self::STATUS_CLOSED; 102 | go(function () { 103 | $frame = FrameFactory::createCloseFrame($this->client->statusCode, ''); 104 | call_user_func($this->close_func, $frame, $this); 105 | }); 106 | break; 107 | } 108 | } elseif ($result instanceof Frame) { 109 | go(function () use ($result) { 110 | $frame = new \Choir\WebSocket\Frame($result->data, $result->opcode, true, true); 111 | call_user_func($this->message_func, $frame, $this); 112 | }); 113 | } 114 | } 115 | }); 116 | return true; 117 | } 118 | return false; 119 | } 120 | 121 | public function reconnect(): bool 122 | { 123 | $this->status = self::STATUS_INITIAL; 124 | return $this->withRequest($this->request)->connect(); 125 | } 126 | 127 | public function setMessageCallback($callable): WebSocketClientInterface 128 | { 129 | $this->message_func = $callable; 130 | return $this; 131 | } 132 | 133 | public function setCloseCallback($callable): WebSocketClientInterface 134 | { 135 | $this->close_func = $callable; 136 | return $this; 137 | } 138 | 139 | public function send($data): bool 140 | { 141 | if ($data instanceof FrameInterface) { 142 | return $this->client->push($data->getData(), $data->getOpcode()); 143 | } 144 | return $this->client->push($data); 145 | } 146 | 147 | public function push($data): bool 148 | { 149 | return $this->send($data); 150 | } 151 | 152 | public function getFd(): int 153 | { 154 | return $this->fd; 155 | } 156 | 157 | public function isConnected(): bool 158 | { 159 | return $this->status === self::STATUS_ESTABLISHED; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Workerman/EventLoop.php: -------------------------------------------------------------------------------- 1 | 0) { 18 | ++$timer_count; 19 | if ($timer_count > $times) { 20 | Timer::del($timer_id); 21 | return; 22 | } 23 | } 24 | $callable($timer_id, ...$arguments); 25 | }, $arguments); 26 | } 27 | 28 | public function clearTimer(int $timer_id) 29 | { 30 | Timer::del($timer_id); 31 | } 32 | 33 | public function addReadEvent($fd, callable $callable) 34 | { 35 | Worker::getEventLoop()->add($fd, EventInterface::EV_READ, $callable); 36 | } 37 | 38 | public function delReadEvent($fd) 39 | { 40 | Worker::getEventLoop()->del($fd, EventInterface::EV_READ); 41 | } 42 | 43 | public function addWriteEvent($fd, callable $callable) 44 | { 45 | Worker::getEventLoop()->add($fd, EventInterface::EV_WRITE, $callable); 46 | } 47 | 48 | public function delWriteEvent($fd) 49 | { 50 | Worker::getEventLoop()->del($fd, EventInterface::EV_WRITE); 51 | } 52 | 53 | public function clearAllTimer() 54 | { 55 | Timer::delAll(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Workerman/ObjectPool.php: -------------------------------------------------------------------------------- 1 | class = $construct_class; 33 | $this->args = $args; 34 | $this->size = $size; 35 | $this->queue = new \SplQueue(); 36 | } 37 | 38 | public function __destruct() 39 | { 40 | while (!$this->queue->isEmpty()) { 41 | $this->queue->pop(); 42 | } 43 | unset($this->queue); 44 | } 45 | 46 | public function get($recursive = 0): object 47 | { 48 | if ($this->getFreeCount() <= 0) { // 当池子见底了,就自动用 Swoole 的 Channel 消费者模型堵起来 49 | if (($cid = Adaptive::getCoroutine()->getCid()) !== -1) { 50 | self::$coroutine_cid[] = $cid; 51 | $result = Adaptive::getCoroutine()->suspend(); 52 | } elseif ($recursive <= 10) { 53 | Adaptive::sleep(1); 54 | return $this->get(++$recursive); 55 | } else { 56 | throw new \RuntimeException('Non-coroutine mode cannot handle too much busy things'); 57 | } 58 | } elseif ($this->queue->isEmpty()) { // 如果 Channel 是空的,那么就新建一个对象 59 | $result = $this->makeObject(); 60 | } else { // 否则就直接从 Channel 中取一个出来 61 | $result = $this->queue->pop(); 62 | } 63 | // 记录借出去的 Hash 表 64 | $this->out[spl_object_hash($result)] = 1; 65 | return $result; 66 | } 67 | 68 | public function put(object $object): bool 69 | { 70 | if (!isset($this->out[spl_object_hash($object)])) { 71 | // 不能退还不是这里生产出去的对象 72 | throw new \RuntimeException('Cannot put object that not got from here'); 73 | } 74 | unset($this->out[spl_object_hash($object)]); 75 | if (!empty(self::$coroutine_cid)) { 76 | $cid = array_shift(self::$coroutine_cid); 77 | Adaptive::getCoroutine()->resume($cid, $object); 78 | return true; 79 | } 80 | try { 81 | $this->queue->push($object); 82 | return true; 83 | } catch (\RuntimeException $e) { 84 | return false; 85 | } 86 | } 87 | 88 | public function getFreeCount(): int 89 | { 90 | return $this->size - count($this->out); 91 | } 92 | 93 | protected function makeObject(): object 94 | { 95 | $class = $this->class; 96 | $args = $this->args; 97 | return new $class(...$args); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Workerman/Socket/HttpClientSocket.php: -------------------------------------------------------------------------------- 1 | worker = $worker; 20 | $this->config = $config; 21 | } 22 | 23 | public function getPort(): int 24 | { 25 | return $this->config['port']; 26 | } 27 | 28 | public function getWorker(): Worker 29 | { 30 | return $this->worker; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Workerman/Socket/WSClientSocket.php: -------------------------------------------------------------------------------- 1 | worker = $worker; 24 | } 25 | 26 | public function send($data, $fd): bool 27 | { 28 | if (!isset($this->connections[$fd])) { 29 | ob_logger()->warning('链接不存在,可能已被关闭或未连接'); 30 | return false; 31 | } 32 | if ($data instanceof FrameInterface) { 33 | $data = $data->getData(); 34 | } 35 | return $this->connections[$fd]->send($data); 36 | } 37 | 38 | public function sendMultiple($data, ?callable $filter = null): array 39 | { 40 | $result = []; 41 | if ($data instanceof FrameInterface) { 42 | $data = $data->getData(); 43 | } 44 | foreach ($this->connections as $fd => $connection) { 45 | if ($connection->getStatus() === TcpConnection::STATUS_ESTABLISHED && ($filter === null || $filter($fd, $this))) { 46 | $result[$fd] = $connection->send($data); 47 | } 48 | } 49 | return $result; 50 | } 51 | 52 | public function sendAll($data): array 53 | { 54 | $result = []; 55 | if ($data instanceof FrameInterface) { 56 | $data = $data->getData(); 57 | } 58 | foreach ($this->connections as $id => $connection) { 59 | $result[$id] = $connection->send($data); 60 | } 61 | return $result; 62 | } 63 | 64 | public function close($fd): bool 65 | { 66 | if (!isset($this->connections[$fd])) { 67 | ob_logger()->warning('链接不存在,可能已被关闭或未连接'); 68 | return false; 69 | } 70 | $this->connections[$fd]->close(); 71 | unset($this->connections[$fd]); 72 | return true; 73 | } 74 | 75 | public function getWorker(): Worker 76 | { 77 | return $this->worker; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Workerman/TopEventListener.php: -------------------------------------------------------------------------------- 1 | id); 37 | Adaptive::initWithDriver(WorkermanDriver::getInstance()); 38 | if (($co = Adaptive::getCoroutine()) !== null) { 39 | $co->create(fn () => ob_event_dispatcher()->dispatchWithHandler(new WorkerStartEvent())); 40 | } else { 41 | ob_event_dispatcher()->dispatchWithHandler(new WorkerStartEvent()); 42 | } 43 | } 44 | 45 | /** 46 | * Workerman 的顶层 workerStop 事件回调 47 | */ 48 | public function onWorkerStop() 49 | { 50 | ob_event_dispatcher()->dispatchWithHandler(new WorkerStopEvent()); 51 | } 52 | 53 | /** 54 | * Workerman 的顶层 onWebSocketConnect 事件回调 55 | * 56 | * @param TcpConnection $connection 连接本身 57 | */ 58 | public function onWebSocketOpen(array $config, TcpConnection $connection) 59 | { 60 | try { 61 | // 协程套娃 62 | if (($co = Adaptive::getCoroutine()) !== null && $co->getCid() === -1) { 63 | $co->create([$this, 'onWebSocketOpen'], $config, $connection); 64 | return; 65 | } 66 | // WebSocket 隐藏特性: _SERVER 全局变量会在 onWebSocketConnect 中被替换为当前连接的 Header 相关信息 67 | global $_SERVER; 68 | $headers = Utils::convertHeaderFromGlobal($_SERVER); 69 | $server_request = HttpFactory::createServerRequest( 70 | $_SERVER['REQUEST_METHOD'], 71 | 'http://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'], 72 | $headers 73 | ); 74 | $server_request = $server_request->withQueryParams($_GET); 75 | $event = new WebSocketOpenEvent($server_request, $connection->id); 76 | 77 | $event->setSocketConfig($config); 78 | ob_event_dispatcher()->dispatch($event); 79 | // 判断 response 是不是 101 状态,如果是 101 状态,那么就只取 Header 补充,其他内容丢弃 80 | if (is_object($event->getResponse()) && $event->getResponse()->getStatusCode() !== 101) { 81 | $connection->close(method_exists($event->getResponse(), '__toString') ? ((string) $event->getResponse()) : ''); 82 | } elseif (is_object($event->getResponse())) { 83 | /* @phpstan-ignore-next-line */ 84 | $connection->headers = Utils::getRawHeadersFromResponse($event->getResponse()); 85 | } 86 | if (($connection->worker instanceof Worker) && ($socket = WorkermanDriver::getInstance()->getWSServerSocketByWorker($connection->worker)) !== null) { 87 | $socket->connections[$connection->id] = $connection; 88 | } else { 89 | // TODO: 编写不可能的异常情况 90 | ob_logger()->error('WorkermanDriver::getWSServerSocketByWorker() returned null'); 91 | } 92 | } catch (\Throwable $e) { 93 | ExceptionHandler::getInstance()->handle($e); 94 | $connection->close(); 95 | } 96 | } 97 | 98 | /** 99 | * Workerman 的顶层 onWebSocketClose 事件回调 100 | */ 101 | public function onWebSocketClose(array $config, TcpConnection $connection) 102 | { 103 | try { 104 | // 协程套娃 105 | if (($co = Adaptive::getCoroutine()) !== null && $co->getCid() === -1) { 106 | $co->create([$this, 'onWebSocketClose'], $config, $connection); 107 | return; 108 | } 109 | if (($connection->worker instanceof Worker) && ($socket = WorkermanDriver::getInstance()->getWSServerSocketByWorker($connection->worker)) !== null) { 110 | unset($socket->connections[$connection->id]); 111 | } else { 112 | // TODO: 编写不可能的异常情况 113 | ob_logger()->error('WorkermanDriver::getWSServerSocketByWorker() returned null'); 114 | } 115 | $event = new WebSocketCloseEvent($connection->id); 116 | $event->setSocketConfig($config); 117 | ob_event_dispatcher()->dispatch($event); 118 | } catch (\Throwable $e) { 119 | ExceptionHandler::getInstance()->handle($e); 120 | } 121 | } 122 | 123 | /** 124 | * Workerman 的顶层 onWebSocketMessage 事件回调 125 | * 126 | * @param TcpConnection $connection 连接本身 127 | * @param mixed $data 128 | */ 129 | public function onWebSocketMessage(array $config, TcpConnection $connection, $data) 130 | { 131 | try { 132 | // 协程套娃 133 | if (($co = Adaptive::getCoroutine()) !== null && $co->getCid() === -1) { 134 | $co->create([$this, 'onWebSocketMessage'], $config, $connection, $data); 135 | return; 136 | } 137 | ob_logger()->debug('WebSocket message from: ' . $connection->id); 138 | $frame = FrameFactory::createTextFrame($data); 139 | 140 | $event = new WebSocketMessageEvent($connection->id, $frame, function (int $fd, $data) use ($connection) { 141 | if ($data instanceof FrameInterface) { 142 | $data_w = $data->getData(); 143 | $res = $connection->send($data_w); 144 | } else { 145 | $res = $connection->send($data); 146 | } 147 | return !($res === false); 148 | }); 149 | $event->setSocketConfig($config); 150 | ob_event_dispatcher()->dispatch($event); 151 | } catch (\Throwable $e) { 152 | ExceptionHandler::getInstance()->handle($e); 153 | } 154 | } 155 | 156 | public function onHttpRequest(array $config, TcpConnection $connection, Request $request) 157 | { 158 | try { 159 | // 协程套娃 160 | if (($co = Adaptive::getCoroutine()) !== null && $co->getCid() === -1) { 161 | $co->create([$this, 'onHttpRequest'], $config, $connection, $request); 162 | return; 163 | } 164 | $port = $connection->getLocalPort(); 165 | ob_logger()->debug('Http request from ' . $port . ': ' . $request->uri()); 166 | $req = HttpFactory::createServerRequest( 167 | $request->method(), 168 | $request->uri(), 169 | $request->header(), 170 | $request->rawBody() 171 | ); 172 | $req = $req->withQueryParams($request->get() ?? []) 173 | ->withCookieParams($request->cookie() ?? []); 174 | // 解析文件 175 | if (!empty($request->file())) { 176 | $uploaded = []; 177 | foreach ($request->file() as $key => $value) { 178 | $upload = new UploadedFile([ 179 | 'key' => $key, 180 | ...$value, 181 | ]); 182 | $uploaded[] = $upload; 183 | } 184 | if ($uploaded !== []) { 185 | $req = $req->withUploadedFiles($uploaded); 186 | } 187 | } 188 | // 解析 post 189 | if (!empty($request->post())) { 190 | $req = $req->withParsedBody($request->post()); 191 | } 192 | $event = new HttpRequestEvent($req); 193 | $event->setSocketConfig($config); 194 | $send_callable = function (ResponseInterface $psr_response) use ($connection) { 195 | $response = new WorkermanResponse(); 196 | $response->withStatus($psr_response->getStatusCode()); 197 | $response->withHeaders($psr_response->getHeaders()); 198 | $response->withBody($psr_response->getBody()->getContents()); 199 | $connection->send($response); 200 | }; 201 | $event->withAsyncResponseCallable($send_callable); 202 | $response = new WorkermanResponse(); 203 | 204 | ob_event_dispatcher()->dispatch($event); 205 | if (($psr_response = $event->getResponse()) !== null) { 206 | $response->withStatus($psr_response->getStatusCode()); 207 | $response->withHeaders($psr_response->getHeaders()); 208 | $response->withBody($psr_response->getBody()->getContents()); 209 | $connection->send($response); 210 | } 211 | } catch (\Throwable $e) { 212 | ExceptionHandler::getInstance()->handle($e); 213 | if (isset($response)) { 214 | $response->withStatus(500); 215 | $response->withBody('Internal Server Error'); 216 | $connection->send($response); 217 | } 218 | } 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Workerman/UserProcess.php: -------------------------------------------------------------------------------- 1 | callable = $callable; 41 | } 42 | 43 | /** 44 | * @throws \Exception 45 | */ 46 | public function run() 47 | { 48 | if ($this->isRunning()) { 49 | throw new \Exception('The process is already running'); 50 | } 51 | $this->rerun(); 52 | Worker::$user_process_pid = $this->pid; 53 | } 54 | 55 | /** 56 | * @internal 57 | * @throws \Exception 58 | */ 59 | public function rerun() 60 | { 61 | $this->pid = pcntl_fork(); 62 | if ($this->pid == -1) { 63 | throw new \Exception('Could not fork'); 64 | } 65 | if ($this->pid !== 0) { 66 | $this->is_running = true; 67 | } else { 68 | $this->pid = posix_getpid(); 69 | try { 70 | $exit_code = call_user_func($this->callable); 71 | } catch (\Throwable $e) { 72 | $exit_code = 255; 73 | } 74 | exit((int) $exit_code); 75 | } 76 | } 77 | 78 | public function getPid(): int 79 | { 80 | return $this->pid; 81 | } 82 | 83 | /** 84 | * @throws \Exception 85 | */ 86 | public function wait() 87 | { 88 | if ($this->isRunning()) { 89 | $this->updateStatus(true); 90 | } 91 | } 92 | 93 | public function getStatus(): int 94 | { 95 | return $this->status; 96 | } 97 | 98 | /** 99 | * @throws \Exception 100 | */ 101 | public function isRunning(): bool 102 | { 103 | if (!$this->is_running) { 104 | return false; 105 | } 106 | $this->updateStatus(); 107 | return $this->is_running; 108 | } 109 | 110 | /** 111 | * @throws \Exception 112 | */ 113 | private function updateStatus(bool $blocking = false) 114 | { 115 | if (!$this->is_running) { 116 | return; 117 | } 118 | $options = $blocking ? 0 : WNOHANG | WUNTRACED; 119 | $result = pcntl_waitpid($this->getPid(), $status, $options); 120 | if ($result === -1) { 121 | throw new \Exception('Error waits on or returns the status of the process'); 122 | } 123 | if ($result) { 124 | $this->is_running = false; 125 | $this->status = $status; 126 | } else { 127 | $this->is_running = true; 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/OneBot/Driver/Workerman/WebSocketClient.php: -------------------------------------------------------------------------------- 1 | withRequest(HttpFactory::createRequest('GET', $address, $header)); 54 | } 55 | 56 | /** 57 | * @throws \Exception 58 | */ 59 | public function withRequest(RequestInterface $request): WebSocketClientInterface 60 | { 61 | // 通过 AsyncTcpConnection 建立连接 62 | $this->connection = new AsyncTcpConnection('ws://' . $request->getUri()->getHost() . ':' . $request->getUri()->getPort()); 63 | // 通过 walkor 的隐藏魔法(无语了),设置请求的 Header。因为 PSR 的 Request 对象返回 Headers 是数组形式的,我们不需要重复的 Header 只取一个就行 64 | /* @phpstan-ignore-next-line */ 65 | $this->connection->headers = array_map(function ($x) { 66 | return $x[0]; 67 | }, $request->getHeaders()); 68 | // 如果连接建立后,可以通,则把 Request 请求中的请求包体以 WebSocket Message 发送给目标 Server。 69 | $this->connection->onConnect = function () use ($request) { 70 | $this->connection->send($request->getBody()->getContents()); 71 | $this->status = self::STATUS_ESTABLISHED; 72 | }; 73 | $this->request = $request; 74 | 75 | return $this; 76 | } 77 | 78 | public function connect(): bool 79 | { 80 | $this->connection->connect(); 81 | $this->status = $this->connection->getStatus(); 82 | return $this->status <= 2; 83 | } 84 | 85 | /** 86 | * @throws \Exception 87 | */ 88 | public function reconnect(): bool 89 | { 90 | return $this->withRequest($this->request)->setMessageCallback($this->on_message)->setCloseCallback($this->on_close)->connect(); 91 | } 92 | 93 | public function setMessageCallback($callable): WebSocketClientInterface 94 | { 95 | $this->status = $this->connection->getStatus(); 96 | $this->on_message = $callable; 97 | $this->connection->onMessage = function (AsyncTcpConnection $con, $data) use ($callable) { 98 | $frame = FrameFactory::createTextFrame($data); 99 | $callable($frame, $this); 100 | }; 101 | return $this; 102 | } 103 | 104 | public function setCloseCallback($callable): WebSocketClientInterface 105 | { 106 | $this->status = $this->connection->getStatus(); 107 | $this->on_close = $callable; 108 | $this->connection->onClose = function (AsyncTcpConnection $con) use ($callable) { 109 | $frame = FrameFactory::createCloseFrame(1000, ''); 110 | $con->close(); 111 | $callable($frame, $this, $con->getStatus(false)); 112 | }; 113 | return $this; 114 | } 115 | 116 | public function send($data): bool 117 | { 118 | if ($data instanceof FrameInterface) { 119 | $data = $data->getData(); 120 | } elseif (!is_string($data)) { 121 | return false; 122 | } 123 | $this->connection->send($data); 124 | return true; 125 | } 126 | 127 | public function push($data): bool 128 | { 129 | return $this->send($data); 130 | } 131 | 132 | public function getFd(): int 133 | { 134 | return $this->connection->id; 135 | } 136 | 137 | public function isConnected(): bool 138 | { 139 | return $this->status === self::STATUS_ESTABLISHED; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/OneBot/Exception/ExceptionHandler.php: -------------------------------------------------------------------------------- 1 | tryEnableCollision(); 23 | } 24 | 25 | public function getWhoops() 26 | { 27 | return $this->whoops; 28 | } 29 | 30 | /** 31 | * 处理异常 32 | */ 33 | public function handle(\Throwable $e): void 34 | { 35 | if ($this->overridden_by !== null) { 36 | $this->overridden_by->handle($e); 37 | return; 38 | } 39 | 40 | $this->handle0($e); 41 | } 42 | 43 | public function overrideWith(ExceptionHandlerInterface $handler): void 44 | { 45 | $this->overridden_by = $handler; 46 | } 47 | 48 | protected function handle0(\Throwable $e): void 49 | { 50 | if (is_null($this->whoops)) { 51 | ob_logger()->error('Uncaught ' . get_class($e) . ': ' . $e->getMessage() . ' at ' . $e->getFile() . '(' . $e->getLine() . ')'); 52 | ob_logger()->error($e->getTraceAsString()); 53 | return; 54 | } 55 | 56 | $this->whoops->handleException($e); 57 | } 58 | 59 | protected function tryEnableCollision($solution_repo = null): void 60 | { 61 | $whoops_class = 'Whoops\Run'; 62 | $collision_namespace = 'NunoMaduro\Collision'; 63 | $collision_handler = "{$collision_namespace}\\Handler"; 64 | $collision_writer = "{$collision_namespace}\\Writer"; 65 | $collision_repo = "{$collision_namespace}\\Contracts\\SolutionsRepository"; 66 | if (class_exists($collision_handler) && class_exists($whoops_class)) { 67 | if ($solution_repo instanceof $collision_repo) { 68 | // @phpstan-ignore-next-line 69 | $writer = new $collision_writer($solution_repo); 70 | } else { 71 | // @phpstan-ignore-next-line 72 | $writer = new $collision_writer(); 73 | } 74 | 75 | $this->whoops = new $whoops_class(); 76 | $this->whoops->allowQuit(false); 77 | $this->whoops->writeToOutput(false); 78 | $this->whoops->pushHandler(new $collision_handler($writer)); 79 | $this->whoops->register(); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/OneBot/Exception/ExceptionHandlerInterface.php: -------------------------------------------------------------------------------- 1 | queue = new Channel(swoole_cpu_num()); 28 | } else { 29 | $this->queue = new \SplQueue(); 30 | } 31 | // TODO: 添加更多可配置项 32 | } 33 | 34 | /** 35 | * 取出对象 36 | */ 37 | public function take(): object 38 | { 39 | if ($this->getFreeCount() > 0) { 40 | // 如有可用对象则取用 41 | try { 42 | $object = $this->queue->pop(); 43 | } catch (\RuntimeException $e) { 44 | // 此处用以捕获 SplQueue 在对象池空时抛出的异常 45 | throw new \RuntimeException('对象池已空,无法取出'); 46 | } 47 | if (!$object) { 48 | // Swoole Channel 在通道关闭时会返回 false 49 | throw new \RuntimeException('对象池通道被关闭,无法去除'); 50 | } 51 | } else { 52 | // 没有就整个新的 53 | $object = $this->makeObject(); 54 | } 55 | $hash = spl_object_hash($object); 56 | // 为方便在归还时删除,使用数组key存储 57 | $this->actives[$hash] = ''; 58 | 59 | return $object; 60 | } 61 | 62 | /** 63 | * 归还对象 64 | */ 65 | public function return(object $object): bool 66 | { 67 | $hash = spl_object_hash($object); 68 | unset($this->actives[$hash]); 69 | 70 | // 放回队列里 71 | return $this->queue->push($object); 72 | } 73 | 74 | abstract protected function makeObject(): object; 75 | 76 | /** 77 | * 获取可用的对象数量 78 | */ 79 | protected function getFreeCount(): int 80 | { 81 | $count = 0; 82 | if (ob_driver_is(SwooleDriver::class)) { 83 | $count = $this->queue->stats()['queue_num']; 84 | } elseif (ob_driver_is(WorkermanDriver::class)) { 85 | $count = $this->queue->count(); 86 | } 87 | return max($count, 0); 88 | } 89 | 90 | /** 91 | * 获取活跃(已被取用)的对象数量 92 | */ 93 | protected function getActiveCount(): int 94 | { 95 | return count($this->actives); 96 | } 97 | 98 | /** 99 | * 获取所有的对象数量 100 | */ 101 | protected function getTotalCount(): int 102 | { 103 | return $this->getFreeCount() + $this->getActiveCount(); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/OneBot/Util/FileUtil.php: -------------------------------------------------------------------------------- 1 | 2 && ctype_alpha($path[0]) && $path[1] === ':'); 21 | } 22 | return strlen($path) > 0 && $path[0] !== '/'; 23 | } 24 | 25 | /** 26 | * 根据路径和操作系统选择合适的分隔符,用于适配 Windows 和 Linux 27 | * 28 | * @param string $path 路径 29 | */ 30 | public static function getRealPath(string $path): string 31 | { 32 | if (strpos($path, 'phar://') === 0) { 33 | return $path; 34 | } 35 | return str_replace('/', DIRECTORY_SEPARATOR, $path); 36 | } 37 | 38 | /** 39 | * 递归或非递归扫描目录,可返回相对目录的文件列表或绝对目录的文件列表 40 | * 41 | * @param string $dir 目录 42 | * @param bool $recursive 是否递归扫描子目录 43 | * @param bool|string $relative 是否返回相对目录,如果为true则返回相对目录,如果为false则返回绝对目录 44 | * @param bool $include_dir 非递归模式下,是否包含目录 45 | * @return array|false 46 | * @since 2.5 47 | */ 48 | public static function scanDirFiles(string $dir, bool $recursive = true, $relative = false, bool $include_dir = false) 49 | { 50 | $dir = self::getRealPath($dir); 51 | // 不是目录不扫,直接 false 处理 52 | if (!is_dir($dir)) { 53 | ob_logger_registered() && ob_logger()->warning('扫描目录失败,目录不存在'); 54 | return false; 55 | } 56 | ob_logger_registered() && ob_logger()->debug('扫描' . $dir); 57 | // 套上 zm_dir 58 | $scan_list = scandir($dir); 59 | if ($scan_list === false) { 60 | ob_logger_registered() && ob_logger()->warning('扫描目录失败,目录无法读取: ' . $dir); 61 | return false; 62 | } 63 | $list = []; 64 | // 将 relative 置为相对目录的前缀 65 | if ($relative === true) { 66 | $relative = $dir; 67 | } 68 | // 遍历目录 69 | foreach ($scan_list as $v) { 70 | // Unix 系统排除这俩目录 71 | if ($v == '.' || $v == '..') { 72 | continue; 73 | } 74 | $sub_file = self::getRealPath($dir . '/' . $v); 75 | if (is_dir($sub_file) && $recursive) { 76 | # 如果是 目录 且 递推 , 则递推添加下级文件 77 | $list = array_merge($list, self::scanDirFiles($sub_file, $recursive, $relative)); 78 | } elseif (is_file($sub_file) || is_dir($sub_file) && !$recursive && $include_dir) { 79 | # 如果是 文件 或 (是 目录 且 不递推 且 包含目录) 80 | if (is_string($relative) && mb_strpos($sub_file, $relative) === 0) { 81 | $list[] = ltrim(mb_substr($sub_file, mb_strlen($relative)), '/\\'); 82 | } elseif ($relative === false) { 83 | $list[] = $sub_file; 84 | } 85 | } 86 | } 87 | return $list; 88 | } 89 | 90 | public static function removeDirRecursive(string $dir): bool 91 | { 92 | $dir = self::getRealPath($dir); 93 | // 不是目录不扫,直接 false 处理 94 | if (!is_dir($dir)) { 95 | return false; 96 | } 97 | // 套上 zm_dir 98 | $scan_list = scandir($dir); 99 | if ($scan_list === false) { 100 | return false; 101 | } 102 | // 遍历目录 103 | $has_file = false; 104 | foreach ($scan_list as $v) { 105 | // Unix 系统排除这俩目录 106 | if ($v == '.' || $v == '..') { 107 | continue; 108 | } 109 | $has_file = true; 110 | $sub_file = self::getRealPath($dir . '/' . $v); 111 | if (is_dir($sub_file)) { 112 | if (!self::removeDirRecursive($sub_file)) { 113 | return false; 114 | } 115 | } else { 116 | if (!unlink($sub_file)) { 117 | return false; 118 | } 119 | } 120 | } 121 | rmdir($dir); 122 | return true; 123 | } 124 | 125 | public static function mkdir(string $dir, $perm = 0755, bool $recursive = false): bool 126 | { 127 | if (!is_dir($dir)) { 128 | return \mkdir($dir, $perm, $recursive); 129 | } 130 | return true; 131 | } 132 | 133 | public static function saveMetaFile(string $path, string $file_id, $data, array $config): bool 134 | { 135 | if (!self::mkdir($path, 0755, true)) { 136 | ob_logger_registered() && ob_logger()->error('无法保存文件,因为无法创建目录: ' . $path); 137 | return false; 138 | } 139 | $file_path = self::getRealPath($path . '/' . $file_id); 140 | if ($data !== null && file_put_contents($file_path, $data) === false) { 141 | ob_logger_registered() && ob_logger()->error('无法保存文件,因为无法写入文件: ' . $file_path); 142 | return false; 143 | } 144 | if (!isset($config['name'])) { 145 | ob_logger_registered() && ob_logger()->error('无法保存文件,因为元数据缺少文件名: ' . $file_path); 146 | return false; 147 | } 148 | if ($data === null && !file_exists($file_path)) { 149 | $config['nodata'] = true; 150 | } 151 | if (!isset($config['nodata']) && ($config['sha256'] ?? null) !== null) { 152 | $data = is_null($data) ? file_get_contents($file_path) : (is_object($data) ? strval($data) : $data); 153 | if (hash('sha256', $data) !== $config['sha256']) { 154 | ob_logger_registered() && ob_logger()->error('无法保存文件,sha256值不匹配!'); 155 | return false; 156 | } 157 | } 158 | $conf = json_encode($config); 159 | if (file_put_contents($file_path . '.json', $conf) === false) { 160 | ob_logger_registered() && ob_logger()->error('无法保存文件,因为无法写入文件: ' . $file_path . '.json'); 161 | return false; 162 | } 163 | return true; 164 | } 165 | 166 | public static function getMetaFile(string $path, string $file_id): array 167 | { 168 | $file_path = self::getRealPath($path . '/' . $file_id); 169 | if (!file_exists($file_path . '.json')) { 170 | ob_logger_registered() && ob_logger()->error('无法读取文件,因为元数据或文件不存在: ' . $file_path); 171 | return [null, null]; 172 | } 173 | $data = json_decode(file_get_contents($file_path . '.json'), true); 174 | if (!isset($data['name'])) { 175 | ob_logger_registered() && ob_logger()->error('无法读取文件,因为元数据缺少文件名: ' . $file_path); 176 | return [null, null]; 177 | } 178 | if (!file_exists($file_path)) { 179 | $content = null; 180 | } else { 181 | $content = file_get_contents($file_path); 182 | if ($content === false) { 183 | $content = null; 184 | } 185 | } 186 | return [$data, $content]; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/OneBot/Util/ObjectQueue.php: -------------------------------------------------------------------------------- 1 | count() >= (self::$limit[$queue_name] ?? 999999)) { 24 | self::$queues[$queue_name]->dequeue(); 25 | } 26 | self::$queues[$queue_name]->enqueue($value); 27 | } 28 | 29 | public static function dequeue(string $queue_name, int $count = 1): array 30 | { 31 | $arr = []; 32 | if (!isset(self::$queues[$queue_name])) { 33 | self::$queues[$queue_name] = new \SplQueue(); 34 | } 35 | if ($count <= 0) { 36 | $count = 999999999; 37 | } 38 | try { 39 | for ($i = 0; $i < $count; ++$i) { 40 | $arr[] = self::$queues[$queue_name]->dequeue(); 41 | } 42 | } catch (\RuntimeException $e) { 43 | } 44 | return $arr; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/OneBot/Util/Singleton.php: -------------------------------------------------------------------------------- 1 | getHeaders() as $k => $v) { 24 | foreach ($v as $vs) { 25 | $line .= $k . ': ' . $vs . "\r\n"; 26 | } 27 | } 28 | return trim($line, "\r\n"); 29 | } 30 | 31 | /** 32 | * 判断是否为关联数组 33 | * 34 | * @param array $arr 待判断数组 35 | */ 36 | public static function isAssocArray(array $arr): bool 37 | { 38 | return array_values($arr) !== $arr; 39 | } 40 | 41 | /** 42 | * 将蛇形字符串转换为驼峰命名 43 | * 44 | * @param string $string 需要进行转换的字符串 45 | * @param string $separator 分隔符 46 | */ 47 | public static function separatorToCamel(string $string, string $separator = '_'): string 48 | { 49 | $string = $separator . str_replace($separator, ' ', strtolower($string)); 50 | return ltrim(str_replace(' ', '', ucwords($string)), $separator); 51 | } 52 | 53 | /** 54 | * 将驼峰字符串转换为蛇形命名 55 | * 56 | * @param string $string 需要进行转换的字符串 57 | * @param string $separator 分隔符 58 | */ 59 | public static function camelToSeparator(string $string, string $separator = '_'): string 60 | { 61 | return strtolower(ltrim(preg_replace('/[A-Z]([A-Z](?![a-z]))*/', $separator . '$0', $string), '_')); 62 | } 63 | 64 | /** 65 | * 将消息数组转换为字符串 66 | * 传入字符串时原样返回 67 | * 68 | * @param array|string $message 消息 69 | */ 70 | public static function msgToString($message): string 71 | { 72 | $result = ''; 73 | if (is_array($message)) { 74 | foreach ($message as $v) { 75 | if ($v['type'] === 'text') { 76 | $result .= $v['data']['text']; 77 | } 78 | } 79 | } else { 80 | $result = $message; 81 | } 82 | return $result; 83 | } 84 | 85 | /** 86 | * 获取动作方法名 87 | * 88 | * @throws OneBotFailureException 89 | */ 90 | public static function getActionFuncName(ActionHandlerBase $handler, string $action): string 91 | { 92 | if (isset(ActionHandlerBase::$core_cache[$action])) { 93 | return ActionHandlerBase::$core_cache[$action]; 94 | } 95 | 96 | if (isset(ActionHandlerBase::$ext_cache[$action])) { 97 | return ActionHandlerBase::$ext_cache[$action]; 98 | } 99 | if (strpos($action, OneBot::getInstance()->getPlatform() . '.') === 0) { 100 | $func = self::separatorToCamel('ext_' . substr($action, strlen(OneBot::getInstance()->getPlatform()) + 1)); 101 | if (method_exists($handler, $func)) { 102 | return ActionHandlerBase::$ext_cache[$action] = $func; 103 | } 104 | } else { 105 | $func = self::separatorToCamel('on_' . $action); 106 | if (method_exists($handler, $func)) { 107 | return ActionHandlerBase::$core_cache[$action] = $func; 108 | } 109 | } 110 | throw new OneBotFailureException(RetCode::UNSUPPORTED_ACTION); 111 | } 112 | 113 | /** 114 | * 将 $_SERVER 变量中的 Header 提取出来转换为数组 K-V 形式 115 | */ 116 | public static function convertHeaderFromGlobal(array $server): array 117 | { 118 | $headers = []; 119 | foreach ($server as $header => $value) { 120 | $header = strtolower($header); 121 | if (strpos($header, 'http_') === 0) { 122 | $string = '_' . str_replace('_', ' ', strtolower($header)); 123 | $header = ltrim(str_replace(' ', '-', ucwords($string)), '_'); 124 | $header = substr($header, 5); 125 | $headers[$header] = $value; 126 | } 127 | } 128 | return $headers; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/OneBot/V12/Action/DefaultActionHandler.php: -------------------------------------------------------------------------------- 1 | data['type'] = $type; 22 | $this->data['id'] = $id ?? ob_uuidgen(); 23 | $this->data['time'] = $time ?? time(); 24 | $this->data['detail_type'] = $detail_type; 25 | $this->data['sub_type'] = $sub_type; 26 | $this->data['self'] = [ 27 | 'platform' => OneBot::getInstance()->getPlatform(), 28 | 'user_id' => OneBot::getInstance()->getSelfId(), 29 | ]; 30 | } 31 | 32 | public function feed(string $key, $value): EventBuilder 33 | { 34 | $this->data[$key] = $value; 35 | return $this; 36 | } 37 | 38 | public function valid(): bool 39 | { 40 | try { 41 | $this->event = new OneBotEvent($this->data); 42 | return true; 43 | } catch (OneBotException $e) { 44 | return false; 45 | } 46 | } 47 | 48 | /** 49 | * @throws OneBotException 50 | */ 51 | public function build(): OneBotEvent 52 | { 53 | return $this->event ?? new OneBotEvent($this->data); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/OneBot/V12/Exception/OneBotException.php: -------------------------------------------------------------------------------- 1 | retcode = $retcode; 26 | $this->action_object = $action_object; 27 | $message = $message ?? RetCode::getMessage($retcode); 28 | parent::__construct($message, 0, $previous); 29 | } 30 | 31 | public function getRetCode() 32 | { 33 | return $this->retcode; 34 | } 35 | 36 | public function getActionObject(): ?Action 37 | { 38 | return $this->action_object; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/OneBot/V12/Object/Action.php: -------------------------------------------------------------------------------- 1 | action = $action; 33 | $this->params = $params; 34 | $this->echo = $echo; 35 | $this->self = $self; 36 | } 37 | 38 | public function __toString() 39 | { 40 | return json_encode($this->jsonSerialize(), JSON_UNESCAPED_SLASHES); 41 | } 42 | 43 | /** 44 | * 从数组创建动作实例 45 | */ 46 | public static function fromArray(array $arr): Action 47 | { 48 | return new self($arr['action'], $arr['params'] ?? [], $arr['echo'] ?? null, $arr['self'] ?? null); 49 | } 50 | 51 | public function jsonSerialize(): array 52 | { 53 | $d = [ 54 | 'action' => $this->action, 55 | 'params' => $this->params, 56 | ]; 57 | if ($this->echo !== null) { 58 | $d['echo'] = $this->echo; 59 | } 60 | if ($this->self !== null) { 61 | $d['self'] = $this->self; 62 | } 63 | return $d; 64 | } 65 | 66 | /** 67 | * @noinspection PhpLanguageLevelInspection 68 | */ 69 | #[\ReturnTypeWillChange] 70 | public function getIterator(): \ArrayIterator 71 | { 72 | return new \ArrayIterator($this->jsonSerialize()); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/OneBot/V12/Object/ActionResponse.php: -------------------------------------------------------------------------------- 1 | echo !== null) { 34 | $a->echo = $echo->echo; 35 | } elseif (is_string($echo)) { 36 | $a->echo = $echo; 37 | } 38 | return $a; 39 | } 40 | 41 | public function ok($data = []): ActionResponse 42 | { 43 | $this->status = 'ok'; 44 | $this->retcode = 0; 45 | $this->data = $data; 46 | $this->message = ''; 47 | return $this; 48 | } 49 | 50 | public function fail($retcode, $message = ''): ActionResponse 51 | { 52 | $this->status = 'failed'; 53 | $this->retcode = $retcode; 54 | $this->data = []; 55 | $this->message = $message === '' ? RetCode::getMessage($retcode) : $message; 56 | return $this; 57 | } 58 | 59 | /** 60 | * @noinspection PhpLanguageLevelInspection 61 | */ 62 | #[\ReturnTypeWillChange] 63 | public function getIterator(): \ArrayIterator 64 | { 65 | return new \ArrayIterator([ 66 | 'status' => $this->status, 67 | 'retcode' => $this->retcode, 68 | 'message' => $this->message, 69 | 'data' => $this->data, 70 | 'echo' => $this->echo, 71 | ]); 72 | } 73 | 74 | public function jsonSerialize(): array 75 | { 76 | return [ 77 | 'status' => $this->status, 78 | 'retcode' => $this->retcode, 79 | 'message' => $this->message, 80 | 'data' => $this->data, 81 | 'echo' => $this->echo, 82 | ]; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/OneBot/V12/Object/MessageSegment.php: -------------------------------------------------------------------------------- 1 | type = $type; 24 | $this->data = $data; 25 | } 26 | 27 | /** 28 | * 根据字符串创建文本消息段 29 | * 30 | * @param string $message 消息 31 | */ 32 | public static function text(string $message): MessageSegment 33 | { 34 | return new self('text', ['text' => $message]); 35 | } 36 | 37 | public static function mention(string $user_id): MessageSegment 38 | { 39 | return new self('mention', ['user_id' => $user_id]); 40 | } 41 | 42 | public static function mentionAll(): MessageSegment 43 | { 44 | return new self('mention_all', []); 45 | } 46 | 47 | public static function image(string $file_id): MessageSegment 48 | { 49 | return new self('image', ['file_id' => $file_id]); 50 | } 51 | 52 | public static function voice(string $file_id): MessageSegment 53 | { 54 | return new self('voice', ['file_id' => $file_id]); 55 | } 56 | 57 | public static function file(string $file_id): MessageSegment 58 | { 59 | return new self('file', ['file_id' => $file_id]); 60 | } 61 | 62 | public static function location($latitude, $longitude, string $title, string $content): MessageSegment 63 | { 64 | return new self('location', [ 65 | 'latitude' => $latitude, 66 | 'longitude' => $longitude, 67 | 'title' => $title, 68 | 'content' => $content, 69 | ]); 70 | } 71 | 72 | public static function reply(string $message_id, ?string $user_id = null): MessageSegment 73 | { 74 | $data = ['message_id' => $message_id]; 75 | if ($user_id !== null) { 76 | $data['user_id'] = $user_id; 77 | } 78 | return new self('reply', $data); 79 | } 80 | 81 | public function jsonSerialize(): array 82 | { 83 | return [ 84 | 'type' => $this->type, 85 | 'data' => $this->data, 86 | ]; 87 | } 88 | 89 | /** 90 | * @noinspection PhpLanguageLevelInspection 91 | */ 92 | #[\ReturnTypeWillChange] 93 | public function getIterator(): \ArrayIterator 94 | { 95 | return new \ArrayIterator($this); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/OneBot/V12/Object/OneBotEvent.php: -------------------------------------------------------------------------------- 1 | data = $data; 59 | } 60 | 61 | public function __call(string $name, array $args = []) 62 | { 63 | if (str_starts_with($name, 'get')) { 64 | $key = Utils::camelToSeparator(substr($name, 3)); 65 | if (isset($this->data[$key])) { 66 | return $this->data[$key]; 67 | } 68 | return null; 69 | } 70 | if (str_starts_with($name, 'set')) { 71 | if ($name === 'setMessage') { 72 | $this->message_segment_cache = null; 73 | } 74 | $key = Utils::camelToSeparator(substr($name, 3)); 75 | if (isset($this->data[$key])) { 76 | $this->data[$key] = $args[0]; 77 | return true; 78 | } 79 | return false; 80 | } 81 | throw new \BadMethodCallException('Call to undefined method ' . __CLASS__ . '::' . $name . '()'); 82 | } 83 | 84 | public function __get(string $name) 85 | { 86 | return $this->data[$name] ?? null; 87 | } 88 | 89 | public function __toString(): string 90 | { 91 | return json_encode($this->data, JSON_UNESCAPED_SLASHES); 92 | } 93 | 94 | /** 95 | * 获取事件的扩展字段 96 | * 97 | * @param string $key 键名 98 | * @return null|mixed 99 | */ 100 | public function get(string $key) 101 | { 102 | return $this->data[$key] ?? null; 103 | } 104 | 105 | /** 106 | * 获取 OneBot 事件的原数据数组 107 | */ 108 | public function getRawData(): array 109 | { 110 | return $this->data; 111 | } 112 | 113 | /** 114 | * 获取消息段数组 115 | * 当事件不是消息时,返回 null 116 | * 117 | * @param bool $return_assoc_array 是否返回数组形式的消息段,默认为false,返回对象形式的消息段 118 | * @return null|array|MessageSegment[] 119 | */ 120 | public function getMessage(bool $return_assoc_array = false): ?array 121 | { 122 | if (!isset($this->data['message'])) { 123 | return null; 124 | } 125 | if ($return_assoc_array) { 126 | return $this->data['message']; 127 | } 128 | if ($this->message_segment_cache !== null) { 129 | return $this->message_segment_cache; 130 | } 131 | $this->message_segment_cache = []; 132 | foreach ($this->data['message'] as $segment) { 133 | $this->message_segment_cache[] = $segment instanceof MessageSegment ? $segment : new MessageSegment($segment['type'], $segment['data']); 134 | } 135 | return $this->message_segment_cache; 136 | } 137 | 138 | /** 139 | * 获取纯文本消息 140 | */ 141 | public function getMessageString(): string 142 | { 143 | $message = $this->getMessage(); 144 | if ($message === null) { 145 | return ''; 146 | } 147 | $message_string = ''; 148 | foreach ($message as $segment) { 149 | if ($segment->type === 'text') { 150 | $message_string .= $segment->data['text']; 151 | } else { 152 | $message_string .= '[富文本:' . $segment->type . ']'; 153 | } 154 | } 155 | return $message_string; 156 | } 157 | 158 | public function jsonSerialize(): array 159 | { 160 | return $this->data; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/OneBot/V12/OneBotBuilder.php: -------------------------------------------------------------------------------- 1 | components['name'] = $name; 40 | return $this; 41 | } 42 | 43 | /** 44 | * 设置 OneBot 平台名称 45 | * 46 | * 例如 qq,kaiheila,discord 等。 47 | * 48 | * @return $this 49 | */ 50 | public function setPlatform(string $platform): self 51 | { 52 | $this->components['platform'] = $platform; 53 | return $this; 54 | } 55 | 56 | /** 57 | * 设置 OneBot 机器人自身的 ID 58 | * 59 | * 此处可能无法先调用知道,可能需要保留在后面 Driver 初始化,机器人端 API 实现连接完毕后设置。 60 | * 61 | * @param string $self_id 机器人自身 ID 62 | * @return $this 63 | */ 64 | public function setSelfId(string $self_id): self 65 | { 66 | $this->components['self_id'] = $self_id; 67 | return $this; 68 | } 69 | 70 | /** 71 | * 设置自定义的 Logger 组件 72 | * 73 | * @param array|object|string $logger Logger 实例、类名或类传参数组 74 | * @return $this 75 | */ 76 | public function useLogger($logger): self 77 | { 78 | $this->components['logger'] = self::resolveClassInstance($logger, LoggerInterface::class); 79 | return $this; 80 | } 81 | 82 | /** 83 | * 设置自定义的 Driver 底层协议驱动器 84 | * 85 | * @param array|object|string $driver Driver 实例、类名或类传参数组 86 | * @return $this 87 | */ 88 | public function useDriver($driver): self 89 | { 90 | $this->components['driver'] = self::resolveClassInstance($driver, Driver::class); 91 | return $this; 92 | } 93 | 94 | /** 95 | * 设置要启动的通信协议 96 | * 97 | * @param array $protocols 通信协议启动的数组 98 | * @return $this 99 | */ 100 | public function setCommunicationsProtocol(array $protocols): self 101 | { 102 | array_map([$this, 'addCommunicationProtocol'], $protocols); 103 | return $this; 104 | } 105 | 106 | /** 107 | * 从工厂模式开始初始化 OneBot 对象,并进一步启动 OneBot 实现 108 | */ 109 | public function build(): OneBot 110 | { 111 | $required_config = ['name', 'platform', 'self_id', 'logger', 'driver', 'communications']; 112 | 113 | if (array_keys($this->components) !== $required_config) { 114 | $missing = implode(', ', array_diff($required_config, array_keys($this->components))); 115 | throw new \InvalidArgumentException('Builder must be configured before building, missing: ' . $missing); 116 | } 117 | 118 | $config = new Config([ 119 | 'name' => $this->components['name'], 120 | 'platform' => $this->components['platform'], 121 | 'self_id' => $this->components['self_id'], 122 | 'logger' => $this->components['logger'], 123 | 'driver' => $this->components['driver'], 124 | 'communications' => $this->components['communications'], 125 | ]); 126 | 127 | return new OneBot($config); 128 | } 129 | 130 | /** 131 | * 从数组格式的配置文件实例化 OneBot 对象 132 | * 133 | * 内部将自动转换为 Repository 对象再依次调用 buildFromConfig()。 134 | * 135 | * @param array $array config 数组 136 | * @return OneBot OneBot 对象 137 | */ 138 | public static function buildFromArray(array $array): OneBot 139 | { 140 | $config = new Repository($array); 141 | return self::buildFromConfig($config); 142 | } 143 | 144 | /** 145 | * 从 Repository 对象实例化 OneBot 对象 146 | * 147 | * 首先会对 config 中的 'logger' 类实例化,然后对 Driver 类进行实例化。 148 | * 实例化后可以通过 $config 进行获取相应对象。 149 | * 150 | * @param RepositoryInterface $config Repository 对象 151 | * @return OneBot OneBot 对象 152 | */ 153 | public static function buildFromConfig(RepositoryInterface $config): OneBot 154 | { 155 | $config->set('logger', self::resolveClassInstance($config->get('logger'), LoggerInterface::class)); 156 | $config->set('driver', self::resolveClassInstance($config->get('driver'), Driver::class)); 157 | 158 | return new OneBot($config); 159 | } 160 | 161 | /** 162 | * 通过给出的 Class Name 返回该 Class 的实例,同时第二个参数用于做验证类型,是否是对应类型 163 | * 164 | * $class 参数可以传入对象,传入对象时直接验证后返回本身。 165 | * 传入类名称时直接new返回。 166 | * 传入array时,数组中第一个值代表类名称,第二个值代表构造参数列表,会在new class时被当作参数传入。 167 | * 传入其他类型会抛出异常。 168 | * 169 | * @param array|object|string $class 参数类 170 | * @param string $expected 期望类型,用于验证 171 | * @return mixed 返回实例对象 172 | */ 173 | protected static function resolveClassInstance($class, string $expected) 174 | { 175 | if ($class instanceof $expected) { 176 | return $class; 177 | } 178 | // TODO:这里是不是缺一个对string和array传入类型的验证,要不然expected就搁那晒太阳 179 | if (is_string($class)) { 180 | return new $class(); 181 | } 182 | 183 | if (is_array($class)) { 184 | $classname = array_shift($class); 185 | $parameters = array_shift($class); 186 | if ($parameters) { 187 | return new $classname($parameters); 188 | } 189 | return new $classname(); 190 | } 191 | 192 | throw new \InvalidArgumentException("Cannot resolve {$expected}, it must be an instance, a class name or an array containing a class name and an array of parameters"); 193 | } 194 | 195 | /** 196 | * 添加配置文件到对象里 197 | * 198 | * @param array $config 配置数组 199 | * @return $this 200 | */ 201 | private function addCommunicationProtocol(array $config): self 202 | { 203 | $this->components['communications'][] = $config; 204 | return $this; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/OneBot/V12/RetCode.php: -------------------------------------------------------------------------------- 1 | 'OK', 49 | self::BAD_REQUEST => 'Bad Request', 50 | self::UNSUPPORTED_ACTION => 'Unsupported Action', 51 | self::BAD_PARAM => 'Invalid parameter', 52 | self::UNSUPPORTED_PARAM => 'Unsupported parameter', 53 | self::UNSUPPORTED_SEGMENT => 'Unsupported segment', 54 | self::BAD_SEGMENT_DATA => 'Bad segment data', 55 | self::UNSUPPORTED_SEGMENT_DATA => 'Unsupported segment data', 56 | self::WHO_AM_I => 'Who am I', 57 | self::BAD_HANDLER => 'Bad handler', 58 | self::INTERNAL_HANDLER_ERROR => 'Internal handler error', 59 | self::DATABASE_ERROR => 'Database error', 60 | self::FILESYSTEM_ERROR => 'Filesystem error', 61 | self::NETWORK_ERROR => 'Network error', 62 | self::PLATFORM_ERROR => 'Platform error', 63 | self::LOGIC_ERROR => 'Logic error', 64 | self::I_AM_TIRED => 'I am tired', 65 | self::UNKNOWN_ERROR => 'Unknown error', 66 | ]; 67 | return $msg[$retcode] ?? 'Unknown error'; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/OneBot/global_defines.php: -------------------------------------------------------------------------------- 1 | = 8.0) { 65 | var_dump($var, ...$moreVars); 66 | } else { 67 | var_dump($var); 68 | foreach ($moreVars as $v) { 69 | var_dump($v); 70 | } 71 | } 72 | if (1 < func_num_args()) { 73 | return func_get_args(); 74 | } 75 | return $var; 76 | } 77 | 78 | /** 79 | * 获取 OneBot 日志实例 80 | */ 81 | function ob_logger(): LoggerInterface 82 | { 83 | global $ob_logger; 84 | return $ob_logger; 85 | } 86 | 87 | /** 88 | * 检查是否已经初始化了 Logger 对象,如果没有的话,返回 False 89 | */ 90 | function ob_logger_registered(): bool 91 | { 92 | global $ob_logger; 93 | return isset($ob_logger); 94 | } 95 | 96 | /** 97 | * 注册一个 Logger 对象到 OneBot 中,如果已经注册了将会覆盖 98 | */ 99 | function ob_logger_register(LoggerInterface $logger): void 100 | { 101 | global $ob_logger; 102 | if ($logger instanceof ConsoleLogger) { 103 | $type = ProcessManager::getProcessType(); 104 | $type_map = [ 105 | ONEBOT_PROCESS_MASTER => 'MST', 106 | ONEBOT_PROCESS_MANAGER => 'MAN', 107 | ONEBOT_PROCESS_WORKER => '#' . ProcessManager::getProcessId(), 108 | ONEBOT_PROCESS_USER => 'USR', 109 | (ONEBOT_PROCESS_WORKER | ONEBOT_PROCESS_TASKWORKER) => '%' . ProcessManager::getProcessId(), 110 | (ONEBOT_PROCESS_WORKER | ONEBOT_PROCESS_MASTER) => 'MST#' . ProcessManager::getProcessId(), 111 | ]; 112 | $ss_type = $type_map[$type] ?? ('TYPE*' . $type); 113 | $logger::$format = '[%date%] [%level%] [' . $ss_type . '] %body%'; 114 | $logger::$date_format = 'Y-m-d H:i:s'; 115 | } 116 | $ob_logger = $logger; 117 | } 118 | 119 | /** 120 | * 获取 OneBot 配置实例 121 | * 122 | * @param null|mixed $default 123 | * @return mixed 124 | */ 125 | function ob_config(?string $key = null, $default = null) 126 | { 127 | $config = OneBot::getInstance()->getConfig(); 128 | if (!is_null($key)) { 129 | $config = $config->get($key, $default); 130 | } 131 | return $config; 132 | } 133 | 134 | /** 135 | * 生成 UUID 136 | * 137 | * @param bool $uppercase 是否大写 138 | */ 139 | function ob_uuidgen(bool $uppercase = false): string 140 | { 141 | try { 142 | $data = random_bytes(16); 143 | } catch (Exception $e) { 144 | throw new RuntimeException('Failed to generate UUID: ' . $e->getMessage(), $e->getCode(), $e); 145 | } 146 | $data[6] = chr(ord($data[6]) & 0x0F | 0x40); 147 | $data[8] = chr(ord($data[8]) & 0x3F | 0x80); 148 | return $uppercase ? strtoupper(vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4))) 149 | : vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); 150 | } 151 | 152 | function ob_event_dispatcher(): HandledDispatcherInterface 153 | { 154 | global $ob_event_dispatcher; 155 | if ($ob_event_dispatcher === null) { 156 | $ob_event_dispatcher = new EventDispatcher(); 157 | } 158 | return $ob_event_dispatcher; 159 | } 160 | 161 | function ob_event_provider(): SortedProviderInterface 162 | { 163 | global $ob_event_provider; 164 | if ($ob_event_provider === null) { 165 | $ob_event_provider = EventProvider::getInstance(); 166 | } 167 | return $ob_event_provider; 168 | } 169 | 170 | /** 171 | * 判断当前驱动是否为指定驱动 172 | * 173 | * @param string $driver 驱动名称 174 | */ 175 | function ob_driver_is(string $driver): bool 176 | { 177 | return get_class(OneBot::getInstance()->getDriver()) === $driver; 178 | } 179 | 180 | /** 181 | * 构建消息段的助手函数 182 | * 183 | * @param string $type 类型 184 | * @param array $data 字段 185 | */ 186 | function ob_segment(string $type, array $data = []): MessageSegment 187 | { 188 | return new MessageSegment($type, $data); 189 | } 190 | -------------------------------------------------------------------------------- /tests/Fixture/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar", 3 | "array": [ 4 | "aaa", 5 | "zzz" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tests/Fixture/invalid.json: -------------------------------------------------------------------------------- 1 | I am a fake json file^^ 2 | -------------------------------------------------------------------------------- /tests/OneBot/Config/ConfigTest.php: -------------------------------------------------------------------------------- 1 | load('tests/Fixture/config.json', new JsonFileLoader()); 21 | $this->assertSame('bar', $config->getRepository()->get('foo')); 22 | $this->assertSame(['aaa', 'zzz'], $config->getRepository()->get('array')); 23 | } 24 | 25 | public function testCanReplaceRepository(): void 26 | { 27 | $config = new Config(); 28 | $this->assertNull($config->get('foo')); 29 | $config->setRepository(new Repository(['foo' => 'bar'])); 30 | $this->assertSame('bar', $config->get('foo')); 31 | } 32 | 33 | /** 34 | * @dataProvider providerTestConstruct 35 | * @param mixed $context 36 | */ 37 | public function testConstruct($context): void 38 | { 39 | $config = new Config($context); 40 | $this->assertSame('bar', $config->get('foo')); 41 | } 42 | 43 | public function providerTestConstruct(): array 44 | { 45 | return [ 46 | 'array' => [ 47 | ['foo' => 'bar'], 48 | ], 49 | 'path' => [ 50 | 'tests/Fixture/config.json', 51 | ], 52 | 'repository' => [ 53 | new Repository(['foo' => 'bar']), 54 | ], 55 | ]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/OneBot/Config/Loader/AbstractFileLoaderTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('This test is only for ' . $run_on); 23 | } 24 | $stub = $this->getMockForAbstractClass(AbstractFileLoader::class); 25 | $class = new \ReflectionClass($stub); 26 | $method = $class->getMethod('getAbsolutePath'); 27 | $method->setAccessible(true); 28 | $path = $method->invoke($stub, $file, $base); 29 | $this->assertSame($expected, $path); 30 | } 31 | 32 | public function providerTestGetAbsolutePath(): array 33 | { 34 | return [ 35 | 'linux absolute path' => [ 36 | '/etc/hosts', 37 | '/var/www', 38 | '/etc/hosts', 39 | 'Linux', 40 | ], 41 | 'linux relative path' => [ 42 | 'hosts', 43 | '/var/www', 44 | '/var/www/hosts', 45 | 'Linux', 46 | ], 47 | 'windows absolute path' => [ 48 | 'C:\Windows\System32\drivers\etc\hosts', 49 | 'C:\Windows\System32', 50 | 'C:\Windows\System32\drivers\etc\hosts', 51 | 'Windows', 52 | ], 53 | 'windows relative path' => [ 54 | 'drivers\etc\hosts', 55 | 'C:\Windows\System32', 56 | 'C:\Windows\System32\drivers\etc\hosts', 57 | 'Windows', 58 | ], 59 | ]; 60 | } 61 | 62 | public function testLoad(): void 63 | { 64 | $stub = $this->getMockForAbstractClass(AbstractFileLoader::class); 65 | $stub->method('loadFile') 66 | ->willReturn(['foo' => 'bar']); 67 | $this->assertSame(['foo' => 'bar'], $stub->load('composer.json')); 68 | } 69 | 70 | public function testLoadWithException(): void 71 | { 72 | $exception = new \Exception('test'); 73 | $this->expectExceptionObject($exception); 74 | $stub = $this->getMockForAbstractClass(AbstractFileLoader::class); 75 | $stub->method('loadFile') 76 | ->willThrowException($exception); 77 | $stub->load('composer.json'); 78 | } 79 | 80 | public function testLoadNotExistsFile(): void 81 | { 82 | $this->expectException(LoadException::class); 83 | $this->expectExceptionMessageMatches('/^配置文件 \'[^\']+\' 不存在或不可读$/'); 84 | $stub = $this->getMockForAbstractClass(AbstractFileLoader::class); 85 | $stub->load('not_exists_file'); 86 | } 87 | 88 | public function testLoadWithInvalidData(): void 89 | { 90 | $this->expectException(LoadException::class); 91 | $this->expectExceptionMessageMatches('/^配置文件 \'[^\']+\' 加载失败$/'); 92 | $stub = $this->getMockForAbstractClass(AbstractFileLoader::class); 93 | $stub->method('loadFile') 94 | ->willReturn(false); 95 | $stub->load('composer.json'); 96 | } 97 | 98 | public function testLoadWithInvalidDataAgain(): void 99 | { 100 | $this->expectException(LoadException::class); 101 | $this->expectExceptionMessageMatches('/^配置文件 \'[^\']+\' 加载失败:配置必须为关联数组或对象$/'); 102 | $stub = $this->getMockForAbstractClass(AbstractFileLoader::class); 103 | $stub->method('loadFile') 104 | ->willReturn([1, 2, 3, 4]); 105 | $stub->load('composer.json'); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/OneBot/Config/Loader/DelegateLoaderTest.php: -------------------------------------------------------------------------------- 1 | expectException(\UnexpectedValueException::class); 18 | new DelegateLoader([new \stdClass()]); 19 | } 20 | 21 | public function testDetermineUnknownLoader(): void 22 | { 23 | $this->expectException(\UnexpectedValueException::class); 24 | $this->expectExceptionMessage('无法确定加载器,未知的配置来源:foo'); 25 | $class = new \ReflectionClass(DelegateLoader::class); 26 | $method = $class->getMethod('determineLoader'); 27 | $method->setAccessible(true); 28 | $method->invoke(new DelegateLoader([]), 'foo'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/OneBot/Config/Loader/JsonFileLoaderTest.php: -------------------------------------------------------------------------------- 1 | load('tests/Fixture/config.json'); 20 | $this->assertSame('bar', $config['foo']); 21 | } 22 | 23 | public function testLoadInvalidJsonFile(): void 24 | { 25 | $this->expectException(LoadException::class); 26 | $loader = new JsonFileLoader(); 27 | $loader->load('tests/Fixture/invalid.json'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/OneBot/Config/RepositoryTest.php: -------------------------------------------------------------------------------- 1 | repository = new Repository( 23 | $this->config = [ 24 | 'foo' => 'bar', 25 | 'bar' => 'baz', 26 | 'baz' => 'bat', 27 | 'null' => null, 28 | 'boolean' => true, 29 | 'associate' => [ 30 | 'x' => 'xxx', 31 | 'y' => 'yyy', 32 | ], 33 | 'array' => [ 34 | 'aaa', 35 | 'zzz', 36 | ], 37 | 'x' => [ 38 | 'z' => 'zoo', 39 | ], 40 | 'a.b' => 'c', 41 | 'a' => [ 42 | 'b.c' => 'd', 43 | ], 44 | 'default' => 'yes', 45 | 'another array' => [ 46 | 'foo', 'bar', 47 | ], 48 | ], 49 | ); 50 | } 51 | 52 | // 尚未确定是否应该支持 53 | // public function testGetValueWhenKeyContainsDot(): void 54 | // { 55 | // $this->assertEquals('c', $this->repository->get('a.b')); 56 | // $this->assertEquals('d', $this->repository->get('a.b.c')); 57 | // } 58 | 59 | public function testGetBooleanValue(): void 60 | { 61 | $this->assertTrue($this->repository->get('boolean')); 62 | } 63 | 64 | /** 65 | * @dataProvider providerTestGetValue 66 | * @param mixed $expected 67 | */ 68 | public function testGetValue(string $key, $expected): void 69 | { 70 | $this->assertSame($expected, $this->repository->get($key)); 71 | } 72 | 73 | public function providerTestGetValue(): array 74 | { 75 | return [ 76 | 'null' => ['null', null], 77 | 'boolean' => ['boolean', true], 78 | 'associate' => ['associate', ['x' => 'xxx', 'y' => 'yyy']], 79 | 'array' => ['array', ['aaa', 'zzz']], 80 | 'dot access' => ['x.z', 'zoo'], 81 | ]; 82 | } 83 | 84 | public function testGetWithDefault(): void 85 | { 86 | $this->assertSame('default', $this->repository->get('not_exist', 'default')); 87 | $this->assertSame('default', $this->repository->get('deep.not_exists', 'default')); 88 | } 89 | 90 | public function testSetValue(): void 91 | { 92 | $this->repository->set('key', 'value'); 93 | $this->assertSame('value', $this->repository->get('key')); 94 | } 95 | 96 | public function testSetArrayValue(): void 97 | { 98 | $this->repository->set('array', ['a', 'b']); 99 | $this->assertSame(['a', 'b'], $this->repository->get('array')); 100 | $this->assertSame('a', $this->repository->get('array.0')); 101 | } 102 | 103 | /** 104 | * @dataProvider providerTestDeleteValue 105 | */ 106 | public function testDeleteValue(string $key): void 107 | { 108 | $this->repository->set($key, null); 109 | $this->assertNull($this->repository->get($key)); 110 | } 111 | 112 | public function providerTestDeleteValue(): array 113 | { 114 | return [ 115 | 'shallow' => ['foo'], 116 | 'deep' => ['associate.x'], 117 | 'not exists' => ['not_exists'], 118 | 'not exists deep' => ['deep.not_exists'], 119 | ]; 120 | } 121 | 122 | public function testHas(): void 123 | { 124 | $this->assertTrue($this->repository->has('foo')); 125 | $this->assertFalse($this->repository->has('not_exist')); 126 | } 127 | 128 | public function testAll(): void 129 | { 130 | $this->assertSame($this->config, $this->repository->all()); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /tests/OneBot/Exception/ExceptionHandlerTest.php: -------------------------------------------------------------------------------- 1 | newInstanceWithoutConstructor(); 26 | // expect handle() to not throw any exception 27 | $this->expectNotToPerformAssertions(); 28 | $instance->handle(new \Exception('test')); 29 | 30 | // restore logger 31 | ob_logger_register($logger); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/OneBot/GlobalDefinesTest.php: -------------------------------------------------------------------------------- 1 | assertIsString(ob_uuidgen()); 17 | $this->assertEquals(36, strlen(ob_uuidgen())); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/OneBot/Util/FileUtilTest.php: -------------------------------------------------------------------------------- 1 | assertTrue(FileUtil::removeDirRecursive(getcwd() . '/data/help1')); 22 | $this->assertFalse(FileUtil::removeDirRecursive(getcwd() . '/data/help1')); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/OneBot/V12/Action/ActionBaseTest.php: -------------------------------------------------------------------------------- 1 | getConfig()->get('file_upload.path')); 30 | } 31 | 32 | public function testOnDeleteMessage() 33 | { 34 | $this->assertEquals(ActionResponse::create()->fail(RetCode::UNSUPPORTED_ACTION), self::$handler->onDeleteMessage(new Action('delete_message'))); 35 | } 36 | 37 | public function testOnGetGroupMemberList() 38 | { 39 | $this->assertEquals(ActionResponse::create()->fail(RetCode::UNSUPPORTED_ACTION), self::$handler->onGetGroupMemberList(new Action('get_group_member_list'))); 40 | } 41 | 42 | public function testOnGetSupportedActions() 43 | { 44 | $response = self::$handler->onGetSupportedActions(new Action('get_supported_actions')); 45 | $this->assertEquals('ok', $response->status); 46 | $this->assertEquals(0, $response->retcode); 47 | $this->assertNotEmpty($response->data); 48 | } 49 | 50 | public function testOnGetSelfInfo() 51 | { 52 | $this->assertEquals(ActionResponse::create()->fail(RetCode::UNSUPPORTED_ACTION), self::$handler->onGetSelfInfo(new Action('get_self_info'))); 53 | } 54 | 55 | public function testOnGetLatestEvents() 56 | { 57 | $this->assertEquals(ActionResponse::create()->fail(RetCode::UNSUPPORTED_ACTION), self::$handler->onGetLatestEvents(new Action('get_latest_events'))); 58 | } 59 | 60 | public function testOnGetVersion() 61 | { 62 | $this->assertEquals(0, self::$handler->onGetVersion(new Action('get_version'))->retcode); 63 | } 64 | 65 | public function testOnGetGroupList() 66 | { 67 | $this->assertEquals(ActionResponse::create()->fail(RetCode::UNSUPPORTED_ACTION), self::$handler->onGetGroupList(new Action('get_group_list'))); 68 | } 69 | 70 | public function testOnGetGroupMemberInfo() 71 | { 72 | $this->assertEquals(ActionResponse::create()->fail(RetCode::UNSUPPORTED_ACTION), self::$handler->onGetGroupMemberInfo(new Action('get_group_member_info'))); 73 | } 74 | 75 | public function testOnSetGroupName() 76 | { 77 | $this->assertEquals(ActionResponse::create()->fail(RetCode::UNSUPPORTED_ACTION), self::$handler->onSetGroupName(new Action('set_group_name'))); 78 | } 79 | 80 | public function testOnLeaveGroup() 81 | { 82 | $this->assertEquals(ActionResponse::create()->fail(RetCode::UNSUPPORTED_ACTION), self::$handler->onLeaveGroup(new Action('leave_group'))); 83 | } 84 | 85 | public function testOnGetStatus() 86 | { 87 | $this->assertEquals(0, self::$handler->onGetStatus(new Action('get_status'))->retcode); 88 | } 89 | 90 | public function testOnGetFriendList() 91 | { 92 | $this->assertEquals(ActionResponse::create()->fail(RetCode::UNSUPPORTED_ACTION), self::$handler->onGetFriendList(new Action('get_friend_list'))); 93 | } 94 | 95 | public function testOnGetGroupInfo() 96 | { 97 | $this->assertEquals(ActionResponse::create()->fail(RetCode::UNSUPPORTED_ACTION), self::$handler->onGetGroupInfo(new Action('get_group_info'))); 98 | } 99 | 100 | public function testOnGetUserInfo() 101 | { 102 | $this->assertEquals(ActionResponse::create()->fail(RetCode::UNSUPPORTED_ACTION), self::$handler->onGetUserInfo(new Action('get_user_info'))); 103 | } 104 | 105 | public function testOnSendMessage() 106 | { 107 | $this->assertEquals(ActionResponse::create()->fail(RetCode::UNSUPPORTED_ACTION), self::$handler->onSendMessage(new Action('send_message'))); 108 | } 109 | 110 | public function testOnUploadFileUrl() 111 | { 112 | $resp = self::$handler->onUploadFile(new Action('upload_file', [ 113 | 'type' => 'url', 114 | 'name' => 'testfile.jpg', 115 | 'url' => 'https://zhamao.xin/file/hello.jpg', 116 | ]), ONEBOT_JSON); 117 | $this->assertEquals(RetCode::OK, $resp->retcode); 118 | $this->assertArrayHasKey('file_id', $resp->data); 119 | $path = ob_config('file_upload.path', getcwd() . '/data/files'); 120 | [$meta, $content] = FileUtil::getMetaFile($path, $resp->data['file_id']); 121 | $this->assertEquals('testfile.jpg', $meta['name']); 122 | $this->assertEquals('https://zhamao.xin/file/hello.jpg', $meta['url']); 123 | $this->assertNotNull($content); 124 | } 125 | 126 | public function testOnUploadFilePath() 127 | { 128 | $resp = self::$handler->onUploadFile(new Action('upload_file', [ 129 | 'type' => 'path', 130 | 'name' => 'a.txt', 131 | 'path' => __FILE__, 132 | ]), ONEBOT_JSON); 133 | $this->assertEquals(RetCode::OK, $resp->retcode); 134 | $this->assertArrayHasKey('file_id', $resp->data); 135 | $path = ob_config('file_upload.path', getcwd() . '/data/files'); 136 | [$meta, $content] = FileUtil::getMetaFile($path, $resp->data['file_id']); 137 | $this->assertEquals('a.txt', $meta['name']); 138 | $this->assertEquals(file_get_contents(__FILE__), $content); 139 | } 140 | 141 | public function testOnUploadFileData() 142 | { 143 | $resp = self::$handler->onUploadFile(new Action('upload_file', [ 144 | 'type' => 'data', 145 | 'name' => 'b.txt', 146 | 'data' => base64_encode('hello world'), 147 | 'sha256' => hash('sha256', 'hello world'), 148 | ])); 149 | $this->assertEquals(RetCode::OK, $resp->retcode); 150 | $this->assertArrayHasKey('file_id', $resp->data); 151 | $path = ob_config('file_upload.path', getcwd() . '/data/files'); 152 | [$meta, $content] = FileUtil::getMetaFile($path, $resp->data['file_id']); 153 | $this->assertEquals('b.txt', $meta['name']); 154 | $this->assertEquals('hello world', $content); 155 | } 156 | 157 | public function testOnUploadFileFragmented() 158 | { 159 | $file = file_get_contents(__FILE__); 160 | $total = strlen($file); 161 | // 多种分片形式 162 | foreach ([10000, 75, 999] as $n) { 163 | // 先准备 164 | $prepare = self::$handler->onUploadFileFragmented(new Action('upload_file_fragmented', [ 165 | 'stage' => 'prepare', 166 | 'name' => 'a.php', 167 | 'total_size' => $total, 168 | ])); 169 | $this->assertEquals(0, $prepare->retcode); 170 | $this->assertIsString($prepare->data['file_id']); 171 | // 第二阶段:最糟糕的倒序传输,JSON模式使用base64,每n个字节为一组 172 | $file_id = $prepare->data['file_id']; 173 | /* @phpstan-ignore-next-line */ 174 | $chunk_cnt = intval($total / $n) + ($total % $n > 0 ? 1 : 0); 175 | for ($i = $chunk_cnt - 1; $i >= 0; --$i) { 176 | $offset = $i * $n; 177 | $chunk_data = substr($file, $offset, $n); 178 | $transfer = self::$handler->onUploadFileFragmented(new Action('upload_file_fragmented', [ 179 | 'stage' => 'transfer', 180 | 'file_id' => $file_id, 181 | 'offset' => $offset, 182 | 'size' => strlen($chunk_data), 183 | 'data' => base64_encode($chunk_data), 184 | ]), ONEBOT_JSON); 185 | $this->assertEquals(0, $transfer->retcode); 186 | } 187 | $finish = self::$handler->onUploadFileFragmented(new Action('upload_file_fragmented', [ 188 | 'stage' => 'finish', 189 | 'file_id' => $file_id, 190 | 'sha256' => hash('sha256', $file), 191 | ])); 192 | $this->assertEquals(0, $finish->retcode); 193 | } 194 | } 195 | 196 | public function testOnGetFile() 197 | { 198 | $resp = self::$handler->onUploadFile(new Action('upload_file', [ 199 | 'type' => 'url', 200 | 'name' => 'testfile.jpg', 201 | 'url' => 'https://zhamao.xin/file/hello.jpg', 202 | ]), ONEBOT_JSON); 203 | $file_hash = '390e5287fe9b552eb534222aa1c5f166f70d4b0c0c1309571dda9a25545edc18'; 204 | $this->assertEquals(RetCode::OK, $resp->retcode); 205 | $get = self::$handler->onGetFile(new Action('get_file', [ 206 | 'file_id' => $resp->data['file_id'], 207 | 'type' => 'url', 208 | ])); 209 | $this->assertEquals('https://zhamao.xin/file/hello.jpg', $get->data['url']); 210 | $get = self::$handler->onGetFile(new Action('get_file', [ 211 | 'file_id' => $resp->data['file_id'], 212 | 'type' => 'data', 213 | ]), ONEBOT_JSON); 214 | $path = ob_config('file_upload.path', getcwd() . '/data/files'); 215 | $this->assertEquals($file_hash, hash_file('sha256', FileUtil::getRealPath($path . '/' . $resp->data['file_id']))); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /tests/OneBot/V12/Action/ActionResponseTest.php: -------------------------------------------------------------------------------- 1 | data['a'] = 'b'; 20 | $response->echo = 'ppp'; 21 | $this->assertEquals($response, ActionResponse::create('ppp')->ok(['a' => 'b'])); 22 | } 23 | 24 | public function testGetIterator() 25 | { 26 | $response = new ActionResponse(); 27 | $response->data['a'] = 'b'; 28 | $response->echo = 'ppp'; 29 | $this->assertEquals((array) $response, (array) ActionResponse::create('ppp')->ok(['a' => 'b'])->getIterator()); 30 | } 31 | 32 | public function testJsonSerialize() 33 | { 34 | $response = new ActionResponse(); 35 | $response->data['a'] = 'b'; 36 | $response->echo = 'ppp'; 37 | $this->assertEquals(json_encode($response), json_encode(ActionResponse::create('ppp')->ok(['a' => 'b']))); 38 | } 39 | 40 | public function testFail() 41 | { 42 | $response = new ActionResponse(); 43 | $response->retcode = RetCode::UNSUPPORTED_ACTION; 44 | $response->status = 'failed'; 45 | $response->message = RetCode::getMessage(RetCode::UNSUPPORTED_ACTION); 46 | $this->assertEquals($response, ActionResponse::create()->fail(RetCode::UNSUPPORTED_ACTION)); 47 | } 48 | 49 | public function testCreate() 50 | { 51 | $response = new ActionResponse(); 52 | $response->echo = 'ppp'; 53 | $this->assertEquals($response, ActionResponse::create('ppp')); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/OneBot/V12/Object/OneBotEventTest.php: -------------------------------------------------------------------------------- 1 | '123', 20 | 'type' => 'message', 21 | 'self' => [ 22 | 'user_id' => '123', 23 | 'platform' => 'test', 24 | ], 25 | 'detail_type' => 'group', 26 | 'sub_type' => 'normal', 27 | 'time' => 123, 28 | 'alt_message' => '123', 29 | 'group_id' => '123', 30 | 'user_id' => '123', 31 | 'guild_id' => '123', 32 | 'channel_id' => '123', 33 | 'operator_id' => '123', 34 | 'message_id' => '123', 35 | 'message' => [ 36 | [ 37 | 'type' => 'text', 38 | 'data' => [ 39 | 'text' => '123', 40 | ], 41 | ], 42 | ], 43 | ]); 44 | $event->setMessage([ob_segment('mention', ['user_id' => '123456'])]); 45 | $this->assertInstanceOf(MessageSegment::class, $event->getMessage()[0]); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/OneBot/V12/OneBotEventListenerTest.php: -------------------------------------------------------------------------------- 1 | addActionHandler('test', function (Action $obj) { 25 | return ActionResponse::create($obj->echo)->ok(['hello' => 'world']); 26 | }); 27 | } 28 | 29 | /** 30 | * @dataProvider providerOnHttpRequest 31 | */ 32 | public function testOnHttpRequest(array $request_params, array $expected) 33 | { 34 | $req = HttpFactory::createServerRequest(...$request_params); 35 | $event = new HttpRequestEvent($req); 36 | $event->setSocketConfig(['type' => 'http', 'host' => '127.1', 'port' => 8083]); 37 | OneBotEventListener::getInstance()->onHttpRequest($event); 38 | if ($event->getResponse()->getHeaderLine('content-type') === 'application/msgpack') { 39 | $obj = MessagePack::unpack($event->getResponse()->getBody()->getContents()); 40 | } else { 41 | $obj = json_decode($event->getResponse()->getBody()->getContents(), true); 42 | } 43 | foreach ($expected as $k => $v) { 44 | switch ($k) { 45 | case 'status_code': 46 | $this->assertEquals($v, $event->getResponse()->getStatusCode()); 47 | break; 48 | case 'retcode': 49 | $this->assertArrayHasKey('retcode', $obj); 50 | $this->assertEquals($v, $obj['retcode']); 51 | break; 52 | case 'echo': 53 | $this->assertEquals($v, $obj['echo']); 54 | break; 55 | case 'data_contains_key': 56 | $this->assertArrayHasKey($v, $obj['data']); 57 | break; 58 | } 59 | } 60 | } 61 | 62 | public function providerOnHttpRequest(): array 63 | { 64 | return [ 65 | 'favicon 405' => [['GET', '/favicon.ico', [], null, '1.1', []], ['status_code' => 405]], 66 | 'other header 404' => [['GET', '/waefawef', ['Content-Type' => 'text/html'], null, '1.1', []], ['status_code' => 405]], 67 | 'default ok action' => [['POST', '/test', ['Content-Type' => 'application/json'], '{"action":"get_supported_actions"}'], ['status_code' => 200, 'retcode' => RetCode::OK]], 68 | 'dynamic input action' => [['POST', '/test', ['Content-Type' => 'application/json'], '{"action":"test","echo":"hello world"}'], ['status_code' => 200, 'retcode' => RetCode::OK, 'echo' => 'hello world', 'data_contains_key' => 'hello']], 69 | 'msgpack' => [['POST', '/test', ['Content-Type' => 'application/msgpack'], MessagePack::pack(['action' => 'get_supported_actions'])], ['status_code' => 200, 'retcode' => RetCode::OK]], 70 | 'json no action' => [['POST', '/test', ['Content-Type' => 'application/json'], '[]'], ['status_code' => 200, 'retcode' => RetCode::BAD_REQUEST]], 71 | ]; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/OneBot/V12/RetCodeTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('OK', RetCode::getMessage(0)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | setName('test') 14 | ->setPlatform('testarea') 15 | ->setSelfId('t001') 16 | ->useLogger(ConsoleLogger::class) 17 | ->useDriver(WorkermanDriver::class) 18 | ->setCommunicationsProtocol([['http' => ['host' => '0.0.0.0', 'port' => 20001]]]) 19 | ->build(); 20 | 21 | $ob->getConfig()->set('file_upload.path', FileUtil::getRealPath(__DIR__ . '/../data/files')); 22 | --------------------------------------------------------------------------------