├── .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 |
5 |
6 |
7 |
8 |
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 | [](https://github.com/botuniverse/php-libonebot/releases)
2 | [](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 |
--------------------------------------------------------------------------------