├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── LICENSE ├── Lib ├── __init__.py ├── common.py ├── constants.py ├── core │ ├── ConfigManager.py │ ├── EventManager.py │ ├── ListenerServer.py │ ├── OnebotAPI.py │ ├── PluginManager.py │ ├── ThreadPool.py │ └── __init__.py └── utils │ ├── Actions.py │ ├── AutoRestartOnebot.py │ ├── EventClassifier.py │ ├── EventHandlers.py │ ├── Logger.py │ ├── PluginConfig.py │ ├── QQDataCacher.py │ ├── QQDataCacher.pyi │ ├── QQRichText.py │ ├── StateManager.py │ └── __init__.py ├── README.md ├── config.yml ├── main.py ├── plugins ├── Helper.py └── LagrangeExtension │ ├── Actions.py │ ├── Events.py │ ├── Segments.py │ └── __init__.py ├── requirements.txt └── testing.py /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Docs to Server 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | 16 | - name: Trigger server update API 17 | id: trigger-api # 给步骤一个唯一 ID,以便后续步骤引用其输出 18 | run: | 19 | set -e # 在任何命令失败时终止执行 20 | 21 | # 发送初始 POST 请求 22 | response=$(curl -s -X POST https://xiaosu.icu/api/update \ 23 | -H "api-key: ${{ secrets.SERVER_API_KEY }}" \ 24 | -H "server: mrb2apidoc") 25 | 26 | echo "Initial Response: $response" 27 | 28 | # 提取 success 和 id 29 | success=$(echo "$response" | jq -r '.success') 30 | if [ "$success" != "true" ]; then 31 | echo "Error: API call did not succeed." 32 | exit 1 33 | fi 34 | 35 | id=$(echo "$response" | jq -r '.id') 36 | if [ -z "$id" ]; then 37 | echo "Error: No ID returned from API." 38 | exit 1 39 | fi 40 | 41 | echo "Update ID: $id" 42 | 43 | # 将 id 写入到 GITHUB_OUTPUT 文件 44 | echo "id=$id" >> $GITHUB_OUTPUT 45 | 46 | - name: Poll update status 47 | run: | 48 | set -e # 在任何命令失败时终止执行 49 | 50 | # 获取上一步的输出 ID 51 | id=${{ steps.trigger-api.outputs.id }} 52 | echo "Polling for Update ID: $id" 53 | 54 | # 开始轮询 /api/update/,最多15分钟 55 | start_time=$(date +%s) # 获取当前时间戳 56 | timeout=900 # 最大轮询时间为15分钟(900秒) 57 | 58 | while true; do 59 | update_response=$(curl -s -X GET https://xiaosu.icu/api/update/$id \ 60 | -H "api-key: ${{ secrets.SERVER_API_KEY }}") 61 | 62 | echo "Polling Response: $update_response" 63 | 64 | # 检查 status 65 | status=$(echo "$update_response" | jq -r '.status') 66 | if [ "$status" != "waiting" ]; then 67 | # 检查 success 字段 68 | success=$(echo "$update_response" | jq -r '.success') 69 | if [ "$success" != "true" ]; then 70 | echo "Error: Update failed. Response indicates success=false." 71 | exit 1 72 | fi 73 | 74 | echo "Update complete with status: $status" 75 | break 76 | fi 77 | 78 | # 检查是否超时 79 | current_time=$(date +%s) 80 | elapsed_time=$((current_time - start_time)) 81 | if [ "$elapsed_time" -ge "$timeout" ]; then 82 | echo "Error: Polling timed out after 15 minutes." 83 | exit 1 84 | fi 85 | 86 | sleep 1 87 | done 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 默认忽略的文件 2 | /shelf/ 3 | /workspace.xml 4 | # 基于编辑器的 HTTP 客户端请求 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | 10 | *.log 11 | /.idea 12 | *.pyc 13 | *.dump 14 | test.py 15 | *.zip 16 | /MURainBot2 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 2.1, February 1999 3 | 4 | Copyright (C) 1991, 1999 Free Software Foundation, Inc. 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | [This is the first released version of the Lesser GPL. It also counts 10 | as the successor of the GNU Library Public License, version 2, hence 11 | the version number 2.1.] 12 | 13 | Preamble 14 | 15 | The licenses for most software are designed to take away your 16 | freedom to share and change it. By contrast, the GNU General Public 17 | Licenses are intended to guarantee your freedom to share and change 18 | free software--to make sure the software is free for all its users. 19 | 20 | This license, the Lesser General Public License, applies to some 21 | specially designated software packages--typically libraries--of the 22 | Free Software Foundation and other authors who decide to use it. You 23 | can use it too, but we suggest you first think carefully about whether 24 | this license or the ordinary General Public License is the better 25 | strategy to use in any particular case, based on the explanations below. 26 | 27 | When we speak of free software, we are referring to freedom of use, 28 | not price. Our General Public Licenses are designed to make sure that 29 | you have the freedom to distribute copies of free software (and charge 30 | for this service if you wish); that you receive source code or can get 31 | it if you want it; that you can change the software and use pieces of 32 | it in new free programs; and that you are informed that you can do 33 | these things. 34 | 35 | To protect your rights, we need to make restrictions that forbid 36 | distributors to deny you these rights or to ask you to surrender these 37 | rights. These restrictions translate to certain responsibilities for 38 | you if you distribute copies of the library or if you modify it. 39 | 40 | For example, if you distribute copies of the library, whether gratis 41 | or for a fee, you must give the recipients all the rights that we gave 42 | you. You must make sure that they, too, receive or can get the source 43 | code. If you link other code with the library, you must provide 44 | complete object files to the recipients, so that they can relink them 45 | with the library after making changes to the library and recompiling 46 | it. And you must show them these terms so they know their rights. 47 | 48 | We protect your rights with a two-step method: (1) we copyright the 49 | library, and (2) we offer you this license, which gives you legal 50 | permission to copy, distribute and/or modify the library. 51 | 52 | To protect each distributor, we want to make it very clear that 53 | there is no warranty for the free library. Also, if the library is 54 | modified by someone else and passed on, the recipients should know 55 | that what they have is not the original version, so that the original 56 | author's reputation will not be affected by problems that might be 57 | introduced by others. 58 | 59 | Finally, software patents pose a constant threat to the existence of 60 | any free program. We wish to make sure that a company cannot 61 | effectively restrict the users of a free program by obtaining a 62 | restrictive license from a patent holder. Therefore, we insist that 63 | any patent license obtained for a version of the library must be 64 | consistent with the full freedom of use specified in this license. 65 | 66 | Most GNU software, including some libraries, is covered by the 67 | ordinary GNU General Public License. This license, the GNU Lesser 68 | General Public License, applies to certain designated libraries, and 69 | is quite different from the ordinary General Public License. We use 70 | this license for certain libraries in order to permit linking those 71 | libraries into non-free programs. 72 | 73 | When a program is linked with a library, whether statically or using 74 | a shared library, the combination of the two is legally speaking a 75 | combined work, a derivative of the original library. The ordinary 76 | General Public License therefore permits such linking only if the 77 | entire combination fits its criteria of freedom. The Lesser General 78 | Public License permits more lax criteria for linking other code with 79 | the library. 80 | 81 | We call this license the "Lesser" General Public License because it 82 | does Less to protect the user's freedom than the ordinary General 83 | Public License. It also provides other free software developers Less 84 | of an advantage over competing non-free programs. These disadvantages 85 | are the reason we use the ordinary General Public License for many 86 | libraries. However, the Lesser license provides advantages in certain 87 | special circumstances. 88 | 89 | For example, on rare occasions, there may be a special need to 90 | encourage the widest possible use of a certain library, so that it becomes 91 | a de-facto standard. To achieve this, non-free programs must be 92 | allowed to use the library. A more frequent case is that a free 93 | library does the same job as widely used non-free libraries. In this 94 | case, there is little to gain by limiting the free library to free 95 | software only, so we use the Lesser General Public License. 96 | 97 | In other cases, permission to use a particular library in non-free 98 | programs enables a greater number of people to use a large body of 99 | free software. For example, permission to use the GNU C Library in 100 | non-free programs enables many more people to use the whole GNU 101 | operating system, as well as its variant, the GNU/Linux operating 102 | system. 103 | 104 | Although the Lesser General Public License is Less protective of the 105 | users' freedom, it does ensure that the user of a program that is 106 | linked with the Library has the freedom and the wherewithal to run 107 | that program using a modified version of the Library. 108 | 109 | The precise terms and conditions for copying, distribution and 110 | modification follow. Pay close attention to the difference between a 111 | "work based on the library" and a "work that uses the library". The 112 | former contains code derived from the library, whereas the latter must 113 | be combined with the library in order to run. 114 | 115 | GNU LESSER GENERAL PUBLIC LICENSE 116 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 117 | 118 | 0. This License Agreement applies to any software library or other 119 | program which contains a notice placed by the copyright holder or 120 | other authorized party saying it may be distributed under the terms of 121 | this Lesser General Public License (also called "this License"). 122 | Each licensee is addressed as "you". 123 | 124 | A "library" means a collection of software functions and/or data 125 | prepared so as to be conveniently linked with application programs 126 | (which use some of those functions and data) to form executables. 127 | 128 | The "Library", below, refers to any such software library or work 129 | which has been distributed under these terms. A "work based on the 130 | Library" means either the Library or any derivative work under 131 | copyright law: that is to say, a work containing the Library or a 132 | portion of it, either verbatim or with modifications and/or translated 133 | straightforwardly into another language. (Hereinafter, translation is 134 | included without limitation in the term "modification".) 135 | 136 | "Source code" for a work means the preferred form of the work for 137 | making modifications to it. For a library, complete source code means 138 | all the source code for all modules it contains, plus any associated 139 | interface definition files, plus the scripts used to control compilation 140 | and installation of the library. 141 | 142 | Activities other than copying, distribution and modification are not 143 | covered by this License; they are outside its scope. The act of 144 | running a program using the Library is not restricted, and output from 145 | such a program is covered only if its contents constitute a work based 146 | on the Library (independent of the use of the Library in a tool for 147 | writing it). Whether that is true depends on what the Library does 148 | and what the program that uses the Library does. 149 | 150 | 1. You may copy and distribute verbatim copies of the Library's 151 | complete source code as you receive it, in any medium, provided that 152 | you conspicuously and appropriately publish on each copy an 153 | appropriate copyright notice and disclaimer of warranty; keep intact 154 | all the notices that refer to this License and to the absence of any 155 | warranty; and distribute a copy of this License along with the 156 | Library. 157 | 158 | You may charge a fee for the physical act of transferring a copy, 159 | and you may at your option offer warranty protection in exchange for a 160 | fee. 161 | 162 | 2. You may modify your copy or copies of the Library or any portion 163 | of it, thus forming a work based on the Library, and copy and 164 | distribute such modifications or work under the terms of Section 1 165 | above, provided that you also meet all of these conditions: 166 | 167 | a) The modified work must itself be a software library. 168 | 169 | b) You must cause the files modified to carry prominent notices 170 | stating that you changed the files and the date of any change. 171 | 172 | c) You must cause the whole of the work to be licensed at no 173 | charge to all third parties under the terms of this License. 174 | 175 | d) If a facility in the modified Library refers to a function or a 176 | table of data to be supplied by an application program that uses 177 | the facility, other than as an argument passed when the facility 178 | is invoked, then you must make a good faith effort to ensure that, 179 | in the event an application does not supply such function or 180 | table, the facility still operates, and performs whatever part of 181 | its purpose remains meaningful. 182 | 183 | (For example, a function in a library to compute square roots has 184 | a purpose that is entirely well-defined independent of the 185 | application. Therefore, Subsection 2d requires that any 186 | application-supplied function or table used by this function must 187 | be optional: if the application does not supply it, the square 188 | root function must still compute square roots.) 189 | 190 | These requirements apply to the modified work as a whole. If 191 | identifiable sections of that work are not derived from the Library, 192 | and can be reasonably considered independent and separate works in 193 | themselves, then this License, and its terms, do not apply to those 194 | sections when you distribute them as separate works. But when you 195 | distribute the same sections as part of a whole which is a work based 196 | on the Library, the distribution of the whole must be on the terms of 197 | this License, whose permissions for other licensees extend to the 198 | entire whole, and thus to each and every part regardless of who wrote 199 | it. 200 | 201 | Thus, it is not the intent of this section to claim rights or contest 202 | your rights to work written entirely by you; rather, the intent is to 203 | exercise the right to control the distribution of derivative or 204 | collective works based on the Library. 205 | 206 | In addition, mere aggregation of another work not based on the Library 207 | with the Library (or with a work based on the Library) on a volume of 208 | a storage or distribution medium does not bring the other work under 209 | the scope of this License. 210 | 211 | 3. You may opt to apply the terms of the ordinary GNU General Public 212 | License instead of this License to a given copy of the Library. To do 213 | this, you must alter all the notices that refer to this License, so 214 | that they refer to the ordinary GNU General Public License, version 2, 215 | instead of to this License. (If a newer version than version 2 of the 216 | ordinary GNU General Public License has appeared, then you can specify 217 | that version instead if you wish.) Do not make any other change in 218 | these notices. 219 | 220 | Once this change is made in a given copy, it is irreversible for 221 | that copy, so the ordinary GNU General Public License applies to all 222 | subsequent copies and derivative works made from that copy. 223 | 224 | This option is useful when you wish to copy part of the code of 225 | the Library into a program that is not a library. 226 | 227 | 4. You may copy and distribute the Library (or a portion or 228 | derivative of it, under Section 2) in object code or executable form 229 | under the terms of Sections 1 and 2 above provided that you accompany 230 | it with the complete corresponding machine-readable source code, which 231 | must be distributed under the terms of Sections 1 and 2 above on a 232 | medium customarily used for software interchange. 233 | 234 | If distribution of object code is made by offering access to copy 235 | from a designated place, then offering equivalent access to copy the 236 | source code from the same place satisfies the requirement to 237 | distribute the source code, even though third parties are not 238 | compelled to copy the source along with the object code. 239 | 240 | 5. A program that contains no derivative of any portion of the 241 | Library, but is designed to work with the Library by being compiled or 242 | linked with it, is called a "work that uses the Library". Such a 243 | work, in isolation, is not a derivative work of the Library, and 244 | therefore falls outside the scope of this License. 245 | 246 | However, linking a "work that uses the Library" with the Library 247 | creates an executable that is a derivative of the Library (because it 248 | contains portions of the Library), rather than a "work that uses the 249 | library". The executable is therefore covered by this License. 250 | Section 6 states terms for distribution of such executables. 251 | 252 | When a "work that uses the Library" uses material from a header file 253 | that is part of the Library, the object code for the work may be a 254 | derivative work of the Library even though the source code is not. 255 | Whether this is true is especially significant if the work can be 256 | linked without the Library, or if the work is itself a library. The 257 | threshold for this to be true is not precisely defined by law. 258 | 259 | If such an object file uses only numerical parameters, data 260 | structure layouts and accessors, and small macros and small inline 261 | functions (ten lines or less in length), then the use of the object 262 | file is unrestricted, regardless of whether it is legally a derivative 263 | work. (Executables containing this object code plus portions of the 264 | Library will still fall under Section 6.) 265 | 266 | Otherwise, if the work is a derivative of the Library, you may 267 | distribute the object code for the work under the terms of Section 6. 268 | Any executables containing that work also fall under Section 6, 269 | whether or not they are linked directly with the Library itself. 270 | 271 | 6. As an exception to the Sections above, you may also combine or 272 | link a "work that uses the Library" with the Library to produce a 273 | work containing portions of the Library, and distribute that work 274 | under terms of your choice, provided that the terms permit 275 | modification of the work for the customer's own use and reverse 276 | engineering for debugging such modifications. 277 | 278 | You must give prominent notice with each copy of the work that the 279 | Library is used in it and that the Library and its use are covered by 280 | this License. You must supply a copy of this License. If the work 281 | during execution displays copyright notices, you must include the 282 | copyright notice for the Library among them, as well as a reference 283 | directing the user to the copy of this License. Also, you must do one 284 | of these things: 285 | 286 | a) Accompany the work with the complete corresponding 287 | machine-readable source code for the Library including whatever 288 | changes were used in the work (which must be distributed under 289 | Sections 1 and 2 above); and, if the work is an executable linked 290 | with the Library, with the complete machine-readable "work that 291 | uses the Library", as object code and/or source code, so that the 292 | user can modify the Library and then relink to produce a modified 293 | executable containing the modified Library. (It is understood 294 | that the user who changes the contents of definitions files in the 295 | Library will not necessarily be able to recompile the application 296 | to use the modified definitions.) 297 | 298 | b) Use a suitable shared library mechanism for linking with the 299 | Library. A suitable mechanism is one that (1) uses at run time a 300 | copy of the library already present on the user's computer system, 301 | rather than copying library functions into the executable, and (2) 302 | will operate properly with a modified version of the library, if 303 | the user installs one, as long as the modified version is 304 | interface-compatible with the version that the work was made with. 305 | 306 | c) Accompany the work with a written offer, valid for at 307 | least three years, to give the same user the materials 308 | specified in Subsection 6a, above, for a charge no more 309 | than the cost of performing this distribution. 310 | 311 | d) If distribution of the work is made by offering access to copy 312 | from a designated place, offer equivalent access to copy the above 313 | specified materials from the same place. 314 | 315 | e) Verify that the user has already received a copy of these 316 | materials or that you have already sent this user a copy. 317 | 318 | For an executable, the required form of the "work that uses the 319 | Library" must include any data and utility programs needed for 320 | reproducing the executable from it. However, as a special exception, 321 | the materials to be distributed need not include anything that is 322 | normally distributed (in either source or binary form) with the major 323 | components (compiler, kernel, and so on) of the operating system on 324 | which the executable runs, unless that component itself accompanies 325 | the executable. 326 | 327 | It may happen that this requirement contradicts the license 328 | restrictions of other proprietary libraries that do not normally 329 | accompany the operating system. Such a contradiction means you cannot 330 | use both them and the Library together in an executable that you 331 | distribute. 332 | 333 | 7. You may place library facilities that are a work based on the 334 | Library side-by-side in a single library together with other library 335 | facilities not covered by this License, and distribute such a combined 336 | library, provided that the separate distribution of the work based on 337 | the Library and of the other library facilities is otherwise 338 | permitted, and provided that you do these two things: 339 | 340 | a) Accompany the combined library with a copy of the same work 341 | based on the Library, uncombined with any other library 342 | facilities. This must be distributed under the terms of the 343 | Sections above. 344 | 345 | b) Give prominent notice with the combined library of the fact 346 | that part of it is a work based on the Library, and explaining 347 | where to find the accompanying uncombined form of the same work. 348 | 349 | 8. You may not copy, modify, sublicense, link with, or distribute 350 | the Library except as expressly provided under this License. Any 351 | attempt otherwise to copy, modify, sublicense, link with, or 352 | distribute the Library is void, and will automatically terminate your 353 | rights under this License. However, parties who have received copies, 354 | or rights, from you under this License will not have their licenses 355 | terminated so long as such parties remain in full compliance. 356 | 357 | 9. You are not required to accept this License, since you have not 358 | signed it. However, nothing else grants you permission to modify or 359 | distribute the Library or its derivative works. These actions are 360 | prohibited by law if you do not accept this License. Therefore, by 361 | modifying or distributing the Library (or any work based on the 362 | Library), you indicate your acceptance of this License to do so, and 363 | all its terms and conditions for copying, distributing or modifying 364 | the Library or works based on it. 365 | 366 | 10. Each time you redistribute the Library (or any work based on the 367 | Library), the recipient automatically receives a license from the 368 | original licensor to copy, distribute, link with or modify the Library 369 | subject to these terms and conditions. You may not impose any further 370 | restrictions on the recipients' exercise of the rights granted herein. 371 | You are not responsible for enforcing compliance by third parties with 372 | this License. 373 | 374 | 11. If, as a consequence of a court judgment or allegation of patent 375 | infringement or for any other reason (not limited to patent issues), 376 | conditions are imposed on you (whether by court order, agreement or 377 | otherwise) that contradict the conditions of this License, they do not 378 | excuse you from the conditions of this License. If you cannot 379 | distribute so as to satisfy simultaneously your obligations under this 380 | License and any other pertinent obligations, then as a consequence you 381 | may not distribute the Library at all. For example, if a patent 382 | license would not permit royalty-free redistribution of the Library by 383 | all those who receive copies directly or indirectly through you, then 384 | the only way you could satisfy both it and this License would be to 385 | refrain entirely from distribution of the Library. 386 | 387 | If any portion of this section is held invalid or unenforceable under any 388 | particular circumstance, the balance of the section is intended to apply, 389 | and the section as a whole is intended to apply in other circumstances. 390 | 391 | It is not the purpose of this section to induce you to infringe any 392 | patents or other property right claims or to contest validity of any 393 | such claims; this section has the sole purpose of protecting the 394 | integrity of the free software distribution system which is 395 | implemented by public license practices. Many people have made 396 | generous contributions to the wide range of software distributed 397 | through that system in reliance on consistent application of that 398 | system; it is up to the author/donor to decide if he or she is willing 399 | to distribute software through any other system and a licensee cannot 400 | impose that choice. 401 | 402 | This section is intended to make thoroughly clear what is believed to 403 | be a consequence of the rest of this License. 404 | 405 | 12. If the distribution and/or use of the Library is restricted in 406 | certain countries either by patents or by copyrighted interfaces, the 407 | original copyright holder who places the Library under this License may add 408 | an explicit geographical distribution limitation excluding those countries, 409 | so that distribution is permitted only in or among countries not thus 410 | excluded. In such case, this License incorporates the limitation as if 411 | written in the body of this License. 412 | 413 | 13. The Free Software Foundation may publish revised and/or new 414 | versions of the Lesser General Public License from time to time. 415 | Such new versions will be similar in spirit to the present version, 416 | but may differ in detail to address new problems or concerns. 417 | 418 | Each version is given a distinguishing version number. If the Library 419 | specifies a version number of this License which applies to it and 420 | "any later version", you have the option of following the terms and 421 | conditions either of that version or of any later version published by 422 | the Free Software Foundation. If the Library does not specify a 423 | license version number, you may choose any version ever published by 424 | the Free Software Foundation. 425 | 426 | 14. If you wish to incorporate parts of the Library into other free 427 | programs whose distribution conditions are incompatible with these, 428 | write to the author to ask for permission. For software which is 429 | copyrighted by the Free Software Foundation, write to the Free 430 | Software Foundation; we sometimes make exceptions for this. Our 431 | decision will be guided by the two goals of preserving the free status 432 | of all derivatives of our free software and of promoting the sharing 433 | and reuse of software generally. 434 | 435 | NO WARRANTY 436 | 437 | 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO 438 | WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. 439 | EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR 440 | OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY 441 | KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE 442 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 443 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE 444 | LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME 445 | THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 446 | 447 | 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN 448 | WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY 449 | AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU 450 | FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR 451 | CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE 452 | LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING 453 | RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A 454 | FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF 455 | SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH 456 | DAMAGES. 457 | 458 | END OF TERMS AND CONDITIONS 459 | 460 | How to Apply These Terms to Your New Libraries 461 | 462 | If you develop a new library, and you want it to be of the greatest 463 | possible use to the public, we recommend making it free software that 464 | everyone can redistribute and change. You can do so by permitting 465 | redistribution under these terms (or, alternatively, under the terms of the 466 | ordinary General Public License). 467 | 468 | To apply these terms, attach the following notices to the library. It is 469 | safest to attach them to the start of each source file to most effectively 470 | convey the exclusion of warranty; and each file should have at least the 471 | "copyright" line and a pointer to where the full notice is found. 472 | 473 | 474 | Copyright (C) 475 | 476 | This library is free software; you can redistribute it and/or 477 | modify it under the terms of the GNU Lesser General Public 478 | License as published by the Free Software Foundation; either 479 | version 2.1 of the License, or (at your option) any later version. 480 | 481 | This library is distributed in the hope that it will be useful, 482 | but WITHOUT ANY WARRANTY; without even the implied warranty of 483 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 484 | Lesser General Public License for more details. 485 | 486 | You should have received a copy of the GNU Lesser General Public 487 | License along with this library; if not, write to the Free Software 488 | Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 489 | USA 490 | 491 | Also add information on how to contact you by electronic and paper mail. 492 | 493 | You should also get your employer (if you work as a programmer) or your 494 | school, if any, to sign a "copyright disclaimer" for the library, if 495 | necessary. Here is a sample; alter the names: 496 | 497 | Yoyodyne, Inc., hereby disclaims all copyright interest in the 498 | library `Frob' (a library for tweaking knobs) written by James Random 499 | Hacker. 500 | 501 | , 1 April 1990 502 | Ty Coon, President of Vice 503 | 504 | That's all there is to it! 505 | -------------------------------------------------------------------------------- /Lib/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | MRB2 Lib 3 | """ 4 | # __ __ ____ _ ____ _ _____ 5 | # | \/ |_ _| _ \ __ _(_)_ __ | __ ) ___ | |_|___ \ 6 | # | |\/| | | | | |_) / _` | | '_ \ | _ \ / _ \| __| __) | 7 | # | | | | |_| | _ < (_| | | | | | | |_) | (_) | |_ / __/ 8 | # |_| |_|\__,_|_| \_\__,_|_|_| |_| |____/ \___/ \__|_____| 9 | 10 | from .utils import * 11 | from . import common 12 | -------------------------------------------------------------------------------- /Lib/common.py: -------------------------------------------------------------------------------- 1 | """ 2 | 工具 3 | """ 4 | import os.path 5 | import shutil 6 | import sys 7 | import threading 8 | import time 9 | import uuid 10 | from collections import OrderedDict 11 | from io import BytesIO 12 | 13 | import requests 14 | 15 | from .constants import * 16 | from .utils import Logger 17 | 18 | logger = Logger.get_logger() 19 | 20 | 21 | class LimitedSizeDict(OrderedDict): 22 | """ 23 | 带有限制大小的字典 24 | """ 25 | 26 | def __init__(self, max_size): 27 | self._max_size = max_size 28 | super().__init__() 29 | 30 | def __setitem__(self, key, value): 31 | if key in self: 32 | del self[key] 33 | elif len(self) >= self._max_size: 34 | oldest_key = next(iter(self)) 35 | del self[oldest_key] 36 | super().__setitem__(key, value) 37 | 38 | 39 | def restart() -> None: 40 | """ 41 | MRB2重启 42 | Returns: 43 | None 44 | """ 45 | # 获取当前解释器路径 46 | p = sys.executable 47 | try: 48 | # 启动新程序(解释器路径, 当前程序) 49 | os.execl(p, p, *sys.argv) 50 | except OSError: 51 | # 关闭当前程序 52 | sys.exit() 53 | 54 | 55 | def download_file_to_cache(url: str, headers=None, file_name: str = "", 56 | download_path: str = None, stream=False, fake_headers: bool = True) -> str | None: 57 | """ 58 | 下载文件到缓存 59 | Args: 60 | url: 下载的url 61 | headers: 下载请求的请求头 62 | file_name: 文件名 63 | download_path: 下载路径 64 | stream: 是否使用流式传输 65 | fake_headers: 是否使用自动生成的假请求头 66 | Returns: 67 | 文件路径 68 | """ 69 | if headers is None: 70 | headers = {} 71 | 72 | if fake_headers: 73 | headers["User-Agent"] = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " 74 | "Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.42") 75 | headers["Accept-Language"] = "zh-CN,zh;q=0.9,en;q=0.8,da;q=0.7,ko;q=0.6" 76 | headers["Accept-Encoding"] = "gzip, deflate, br" 77 | headers["Accept"] = ("text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8," 78 | "application/signed-exchange;v=b3;q=0.7") 79 | headers["Connection"] = "keep-alive" 80 | headers["Upgrade-Insecure-Requests"] = "1" 81 | headers["Cache-Control"] = "max-age=0" 82 | headers["Sec-Fetch-Dest"] = "document" 83 | headers["Sec-Fetch-Mode"] = "navigate" 84 | headers["Sec-Fetch-Site"] = "none" 85 | headers["Sec-Fetch-User"] = "?1" 86 | headers["Sec-Ch-Ua"] = "\"Chromium\";v=\"113\", \"Not-A.Brand\";v=\"24\", \"Microsoft Edge\";v=\"113\"" 87 | headers["Sec-Ch-Ua-Mobile"] = "?0" 88 | headers["Sec-Ch-Ua-Platform"] = "\"Windows\"" 89 | headers["Host"] = url.split("/")[2] 90 | 91 | # 路径拼接 92 | if file_name == "": 93 | file_name = uuid.uuid4().hex + ".cache" 94 | 95 | if download_path is None: 96 | file_path = os.path.join(CACHE_PATH, file_name) 97 | else: 98 | file_path = os.path.join(download_path, file_name) 99 | 100 | # 路径不存在特判 101 | if not os.path.exists(CACHE_PATH): 102 | os.makedirs(CACHE_PATH) 103 | 104 | try: 105 | # 下载 106 | if stream: 107 | with open(file_path, "wb") as f, requests.get(url, stream=True, headers=headers) as res: 108 | for chunk in res.iter_content(chunk_size=64 * 1024): 109 | if not chunk: 110 | break 111 | f.write(chunk) 112 | else: 113 | # 不使用流式传输 114 | res = requests.get(url, headers=headers) 115 | 116 | with open(file_path, "wb") as f: 117 | f.write(res.content) 118 | except requests.exceptions.RequestException as e: 119 | logger.warning(f"下载文件失败: {e}") 120 | if os.path.exists(file_path): 121 | os.remove(file_path) 122 | return None 123 | 124 | return file_path 125 | 126 | 127 | # 删除缓存文件 128 | def clean_cache() -> None: 129 | """ 130 | 清理缓存 131 | Returns: 132 | None 133 | """ 134 | if os.path.exists(CACHE_PATH): 135 | try: 136 | shutil.rmtree(CACHE_PATH, ignore_errors=True) 137 | except Exception as e: 138 | logger.warning("删除缓存时报错,报错信息: %s" % repr(e)) 139 | 140 | 141 | # 函数缓存 142 | def function_cache(max_size: int, expiration_time: int = -1): 143 | """ 144 | 函数缓存 145 | Args: 146 | max_size: 最大大小 147 | expiration_time: 过期时间 148 | Returns: 149 | None 150 | """ 151 | cache = LimitedSizeDict(max_size) 152 | 153 | def cache_decorator(func): 154 | """ 155 | 缓存装饰器 156 | Args: 157 | @param func: 158 | Returns: 159 | None 160 | """ 161 | 162 | def wrapper(*args, **kwargs): 163 | key = str(func.__name__) + str(args) + str(kwargs) 164 | if key in cache and (expiration_time == -1 or time.time() - cache[key][1] < expiration_time): 165 | return cache[key][0] 166 | result = func(*args, **kwargs) 167 | cache[key] = (result, time.time()) 168 | return result 169 | 170 | def clear_cache(): 171 | """清理缓存""" 172 | cache.clear() 173 | 174 | def get_cache(): 175 | """获取缓存""" 176 | return dict(cache) 177 | 178 | def original_func(*args, **kwargs): 179 | """调用原函数""" 180 | return func(*args, **kwargs) 181 | 182 | wrapper.clear_cache = clear_cache 183 | wrapper.get_cache = get_cache 184 | wrapper.original_func = original_func 185 | return wrapper 186 | 187 | return cache_decorator 188 | 189 | 190 | def thread_lock(func): 191 | """ 192 | 线程锁装饰器 193 | """ 194 | thread_lock = threading.Lock() 195 | 196 | def wrapper(*args, **kwargs): 197 | with thread_lock: 198 | return func(*args, **kwargs) 199 | 200 | return wrapper 201 | 202 | 203 | def finalize_and_cleanup(): 204 | """ 205 | 结束运行 206 | @return: 207 | """ 208 | logger.info("MuRainBot即将关闭,正在删除缓存") 209 | 210 | clean_cache() 211 | 212 | logger.warning("MuRainBot结束运行!") 213 | logger.info("再见!\n") 214 | 215 | 216 | @thread_lock 217 | def save_exc_dump(description: str = None, path: str = None): 218 | """ 219 | 保存异常堆栈 220 | Args: 221 | description: 保存的dump描述,为空则默认 222 | path: 保存的路径,为空则自动根据错误生成 223 | """ 224 | # 扫描是否存在非当前日期且为归档的exc_dump 225 | exc_dump_files = [ 226 | file for file in os.listdir(DUMPS_PATH) if file.startswith("coredumpy_") and file.endswith(".dump") 227 | ] 228 | 229 | today_date = time.strftime("%Y%m%d") 230 | date_flags = [] 231 | 232 | for file in exc_dump_files: 233 | file_date = file.split("coredumpy_", 1)[1].split("_", 1)[0][:len("YYYYMMDD")] 234 | if file_date != today_date: 235 | os.makedirs(os.path.join(DUMPS_PATH, f"coredumpy_archive_{file_date}"), exist_ok=True) 236 | os.rename(os.path.join(DUMPS_PATH, file), os.path.join(DUMPS_PATH, f"coredumpy_archive_{file_date}", file)) 237 | if file_date not in date_flags: 238 | logger.info(f"已自动归档 {file_date} 的异常堆栈到 coredumpy_archive_{file_date}") 239 | date_flags.append(file_date) 240 | 241 | # 保存dump文件 242 | try: 243 | import coredumpy 244 | except ImportError: 245 | logger.warning("coredumpy未安装,无法保存异常堆栈") 246 | return None 247 | 248 | try: 249 | _, _, exc_traceback = sys.exc_info() 250 | if not exc_traceback: 251 | raise Exception("No traceback found") 252 | 253 | # 遍历 traceback 链表,找到最后一个 frame (异常最初发生的位置) 254 | current_tb = exc_traceback 255 | frame = current_tb.tb_frame 256 | while current_tb: 257 | frame = current_tb.tb_frame 258 | current_tb = current_tb.tb_next 259 | 260 | i = 0 261 | while True: 262 | if i > 0: 263 | path_ = os.path.join(DUMPS_PATH, 264 | f"coredumpy_" 265 | f"{time.strftime('%Y%m%d%-H%M%S')}_" 266 | f"{frame.f_code.co_name}_{i}.dump") 267 | else: 268 | path_ = os.path.join(DUMPS_PATH, 269 | f"coredumpy_" 270 | f"{time.strftime('%Y%m%d-%H%M%S')}_" 271 | f"{frame.f_code.co_name}.dump") 272 | if not os.path.exists(path_): 273 | break 274 | i += 1 275 | 276 | for _ in ['?', '*', '"', '<', '>']: 277 | path_ = path_.replace(_, "") 278 | 279 | kwargs = { 280 | "frame": frame, 281 | "path": os.path.normpath(path_) 282 | } 283 | if description: 284 | kwargs["description"] = description 285 | if path: 286 | kwargs["path"] = path 287 | 288 | coredumpy.dump(**kwargs) 289 | except Exception as e: 290 | logger.error(f"保存异常堆栈时发生错误: {repr(e)}", exc_info=True) 291 | return None 292 | 293 | return kwargs["path"] 294 | 295 | 296 | def bytes_io_to_file( 297 | io_bytes: BytesIO, 298 | file_name: str | None = None, 299 | file_type: str | None = None, 300 | save_dir: str = CACHE_PATH 301 | ): 302 | """ 303 | 将BytesIO对象保存成文件,并返回路径 304 | Args: 305 | io_bytes: BytesIO对象 306 | file_name: 要保存的文件名,与file_type选一个填即可 307 | file_type: 文件类型(扩展名),与file_name选一个填即可 308 | save_dir: 保存的文件夹 309 | 310 | Returns: 311 | 保存的文件路径 312 | """ 313 | if not isinstance(io_bytes, BytesIO): 314 | raise TypeError("bytes_io_to_file: 输入类型错误") 315 | if file_name is None: 316 | if file_type is None: 317 | file_type = "cache" 318 | file_name = uuid.uuid4().hex + "." + file_type 319 | if not os.path.exists(save_dir): 320 | os.makedirs(save_dir) 321 | 322 | with open(os.path.join(save_dir, file_name), "wb") as f: 323 | f.write(io_bytes.getvalue()) 324 | return os.path.join(save_dir, file_name) 325 | -------------------------------------------------------------------------------- /Lib/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | 常量 3 | """ 4 | import os 5 | 6 | WORK_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 7 | DATA_PATH = os.path.join(WORK_PATH, "data") 8 | LOGS_PATH = os.path.join(WORK_PATH, "logs") 9 | DUMPS_PATH = os.path.join(WORK_PATH, "exc_dumps") 10 | PLUGINS_PATH = os.path.join(WORK_PATH, "plugins") 11 | CONFIG_PATH = os.path.join(WORK_PATH, "config.yml") 12 | PLUGIN_CONFIGS_PATH = os.path.join(WORK_PATH, "plugin_configs") 13 | CACHE_PATH = os.path.join(DATA_PATH, "cache") 14 | 15 | if not os.path.exists(DATA_PATH): 16 | os.makedirs(DATA_PATH) 17 | 18 | if not os.path.exists(PLUGIN_CONFIGS_PATH): 19 | os.makedirs(PLUGIN_CONFIGS_PATH) 20 | 21 | if not os.path.exists(CACHE_PATH): 22 | os.makedirs(CACHE_PATH) 23 | 24 | if not os.path.exists(LOGS_PATH): 25 | os.makedirs(LOGS_PATH) 26 | 27 | if not os.path.exists(DUMPS_PATH): 28 | os.makedirs(DUMPS_PATH) 29 | -------------------------------------------------------------------------------- /Lib/core/ConfigManager.py: -------------------------------------------------------------------------------- 1 | """ 2 | 配置管理器 3 | """ 4 | 5 | import dataclasses 6 | 7 | import yaml 8 | from ..constants import * 9 | from ..utils import Logger 10 | 11 | logger = Logger.get_logger() 12 | 13 | 14 | class ConfigManager: 15 | """ 16 | 配置管理器 17 | """ 18 | def __init__(self, config_path, default_config: str | dict = None): 19 | self.config_path = config_path 20 | self.default_config = default_config 21 | self.config = {} 22 | self.load_config() 23 | 24 | def load_config(self): 25 | """ 26 | 加载配置文件 27 | Returns: 28 | None 29 | """ 30 | if os.path.exists(self.config_path): 31 | try: 32 | with open(self.config_path, encoding="utf-8") as f: 33 | self.config = yaml.safe_load(f) 34 | except Exception as e: 35 | logger.error(f"配置文件 {self.config_path} 加载失败,请检查配置文件内容是否正确。" 36 | f"如果无法修复,请删除配置文件重新配置,以创建默认配置文件。" 37 | f"错误信息:{repr(e)}") 38 | else: 39 | try: 40 | if isinstance(self.default_config, str): 41 | with open(self.config_path, "w", encoding="utf-8") as f: 42 | f.write(self.default_config) 43 | logger.info(f"配置文件 {self.config_path} 不存在,已创建默认配置文件") 44 | self.load_config() 45 | elif isinstance(self.default_config, dict): 46 | with open(self.config_path, "w", encoding="utf-8") as f: 47 | yaml.safe_dump(self.default_config, f) 48 | logger.info(f"配置文件 {self.config_path} 不存在,已创建默认配置文件") 49 | self.load_config() 50 | else: 51 | logger.error(f"配置文件 {self.config_path} 不存在,且未提供默认配置,无法创建默认配置文件") 52 | self.config = {} 53 | except Exception as e: 54 | logger.error(f"配置文件 {self.config_path} 创建失败,请检查配置文件路径是否正确。错误信息:{repr(e)}") 55 | self.config = {} 56 | self.init() 57 | 58 | def init(self): 59 | """ 60 | 用于初始化配置文件,可自行编写初始化逻辑,例如默认值等 61 | """ 62 | pass 63 | 64 | def save_config(self): 65 | """ 66 | 保存配置文件 67 | Returns: 68 | None 69 | """ 70 | with open(self.config_path, "w", encoding="utf-8") as f: 71 | yaml.safe_dump(self.config, f) 72 | 73 | def get(self, key, default=None): 74 | """ 75 | 获取配置项 76 | Args: 77 | key: 配置项键 78 | default: 默认值 79 | Returns: 80 | 配置项值 81 | """ 82 | return self.config.get(key, default) 83 | 84 | def set(self, key, value): 85 | """ 86 | 设置配置项 87 | Args: 88 | key: 配置项键 89 | value: 配置项值 90 | Returns: 91 | None 92 | """ 93 | self.config[key] = value 94 | self.init() 95 | 96 | 97 | class GlobalConfig(ConfigManager): 98 | """ 99 | MRB2配置管理器 100 | """ 101 | _instance = None 102 | _init_flag = False 103 | 104 | @dataclasses.dataclass 105 | class Account: 106 | """ 107 | 账号相关 108 | """ 109 | user_id: int 110 | nick_name: str 111 | bot_admin: list 112 | 113 | @dataclasses.dataclass 114 | class Api: 115 | """ 116 | Api设置 117 | """ 118 | host: str 119 | port: int 120 | access_token: str 121 | 122 | @dataclasses.dataclass 123 | class Server: 124 | """ 125 | 监听服务器设置 126 | """ 127 | host: str 128 | port: int 129 | server: str 130 | max_works: int 131 | secret: str 132 | 133 | @dataclasses.dataclass 134 | class ThreadPool: 135 | """ 136 | 线程池相关 137 | """ 138 | max_workers: int 139 | 140 | @dataclasses.dataclass 141 | class QQDataCache: 142 | """ 143 | QQ数据缓存设置 144 | """ 145 | enable: bool 146 | expire_time: int 147 | max_cache_size: int 148 | 149 | @dataclasses.dataclass 150 | class Debug: 151 | """ 152 | 调试模式,若启用框架的日志等级将被设置为debug,不建议在生产环境开启 153 | """ 154 | enable: bool 155 | save_dump: bool 156 | 157 | @dataclasses.dataclass 158 | class AutoRestartOnebot: 159 | """ 160 | 在Onebot实现端状态异常时自动重启Onebot实现端(需开启心跳包) 161 | """ 162 | enable: bool 163 | 164 | @dataclasses.dataclass 165 | class Command: 166 | """ 167 | 命令相关 168 | """ 169 | command_start: list[str] 170 | 171 | DEFAULT_CONFIG = """# MuRainBot2配置文件 172 | account: # 账号相关 173 | user_id: 0 # QQ账号(留空则自动获取) 174 | nick_name: "" # 昵称(留空则自动获取) 175 | bot_admin: [] 176 | 177 | api: # Api设置(Onebot HTTP通信) 178 | host: '127.0.0.1' 179 | port: 5700 180 | access_token: "" # HTTP的Access Token,为空则不使用(详见https://github.com/botuniverse/onebot-11/blob/master/communication/authorization.md#http-%E5%92%8C%E6%AD%A3%E5%90%91-websocket) 181 | 182 | server: # 监听服务器设置(Onebot HTTP POST通信) 183 | host: '127.0.0.1' 184 | port: 5701 185 | server: 'werkzeug' # 使用的服务器(werkzeug或waitress,使用waitress需先pip install waitress) 186 | max_works: 4 # 最大工作线程数 187 | secret: "" # 上报数据签名密钥(详见https://github.com/botuniverse/onebot-11/blob/master/communication/http-post.md#%E7%AD%BE%E5%90%8D) 188 | 189 | thread_pool: # 线程池相关 190 | max_workers: 10 # 线程池最大线程数 191 | 192 | qq_data_cache: # QQ数据缓存设置 193 | enable: true # 是否启用缓存(非常不推荐关闭缓存,对于对于需要无缓存的场景,推荐在插件内自行调用api来获取而非关闭此配置项) 194 | expire_time: 300 # 缓存过期时间(秒) 195 | max_cache_size: 500 # 最大缓存数量(设置过大可能会导致报错) 196 | 197 | debug: # 调试模式,若启用框架的日志等级将被设置为debug,不建议在生产环境开启 198 | enable: false # 是否启用调试模式 199 | save_dump: true # 是否在发生异常的同时保存一个dump错误文件(不受debug.enable约束,独立开关,若要使用请先安装coredumpy库,不使用可不安装) 200 | 201 | auto_restart_onebot: # 在Onebot实现端状态异常时自动重启Onebot实现端(需开启心跳包) 202 | enable: true # 是否启用自动重启 203 | 204 | command: # 命令相关 205 | command_start: ["/"] # 命令起始符 206 | """ 207 | 208 | def __new__(cls): 209 | if not cls._instance: 210 | cls._instance = super().__new__(cls) 211 | return cls._instance 212 | 213 | def __init__(self): 214 | self.account: GlobalConfig.Account = None 215 | self.api: GlobalConfig.Api = None 216 | self.server: GlobalConfig.Server = None 217 | self.thread_pool: GlobalConfig.ThreadPool = None 218 | self.qq_data_cache: GlobalConfig.QQDataCache = None 219 | self.debug: GlobalConfig.Debug = None 220 | self.auto_restart_onebot: GlobalConfig.AutoRestartOnebot = None 221 | self.command: GlobalConfig.Command = None 222 | if not self._init_flag: 223 | self._init_flag = True 224 | super().__init__(CONFIG_PATH, self.DEFAULT_CONFIG) 225 | else: 226 | self.init() 227 | 228 | def init(self): 229 | super().init() 230 | self.account = self.Account( 231 | user_id=self.get("account", {}).get("user_id", 0), 232 | nick_name=self.get("account", {}).get("nick_name", ""), 233 | bot_admin=self.get("account", {}).get("bot_admin", []) 234 | ) 235 | self.api = self.Api( 236 | host=self.get("api", {}).get("host", ""), 237 | port=self.get("api", {}).get("port", 5700), 238 | access_token=self.get("api", {}).get("access_token", "") 239 | ) 240 | self.server = self.Server( 241 | host=self.get("server", {}).get("host", ""), 242 | port=self.get("server", {}).get("port", 5701), 243 | server=self.get("server", {}).get("server", "werkzeug").lower(), 244 | max_works=self.get("server", {}).get("max_works", 4), 245 | secret=self.get("server", {}).get("secret", "") 246 | ) 247 | self.thread_pool = self.ThreadPool( 248 | max_workers=self.get("thread_pool", {}).get("max_workers", 10) 249 | ) 250 | self.qq_data_cache = self.QQDataCache( 251 | enable=self.get("qq_data_cache", {}).get("enable", True), 252 | expire_time=self.get("qq_data_cache", {}).get("expire_time", 300), 253 | max_cache_size=self.get("qq_data_cache", {}).get("max_cache_size", 500) 254 | ) 255 | self.debug = self.Debug( 256 | enable=self.get("debug", {}).get("enable", False), 257 | save_dump=self.get("debug", {}).get("save_dump", True) 258 | ) 259 | self.auto_restart_onebot = self.AutoRestartOnebot( 260 | enable=self.get("auto_restart_onebot", {}).get("enable", True) 261 | ) 262 | self.command = self.Command( 263 | command_start=self.get("command", {}).get("command_start", ["/"]) 264 | ) 265 | 266 | 267 | if __name__ == "__main__": 268 | test_config = GlobalConfig() 269 | print(test_config.api) 270 | test_config.set("api", {"host": "127.0.0.1", "port": 5700}) 271 | print(test_config.api) 272 | -------------------------------------------------------------------------------- /Lib/core/EventManager.py: -------------------------------------------------------------------------------- 1 | """ 2 | 事件管理器,用于管理事件与事件监听器 3 | """ 4 | import inspect 5 | from collections.abc import Callable 6 | from dataclasses import dataclass, field 7 | from typing import Any, TypeVar 8 | 9 | from Lib.core.ThreadPool import async_task 10 | from Lib.core import ConfigManager 11 | from Lib.utils import Logger 12 | from Lib.common import save_exc_dump 13 | 14 | logger = Logger.get_logger() 15 | 16 | 17 | class _Event: 18 | """ 19 | 请勿使用此事件类,使用Event继承以创建自定义事件 20 | """ 21 | pass 22 | 23 | 24 | class Hook(_Event): 25 | """ 26 | 钩子事件,用于在事件处理过程中跳过某些监听器 27 | """ 28 | 29 | def __init__(self, event, listener): 30 | self.event = event 31 | self.listener = listener 32 | 33 | def call(self): 34 | """ 35 | 按优先级顺序同步触发所有监听器 36 | """ 37 | if self.__class__ in event_listeners: 38 | for listener in sorted(event_listeners[self.__class__], key=lambda i: i.priority, reverse=True): 39 | try: 40 | res = listener.func(self, **listener.kwargs) 41 | except Exception as e: 42 | if ConfigManager.GlobalConfig().debug.save_dump: 43 | dump_path = save_exc_dump(f"监听器中发生错误") 44 | else: 45 | dump_path = None 46 | logger.error(f"监听器中发生错误: {repr(e)}" 47 | f"{f"\n已保存异常到 {dump_path}" if dump_path else ""}", 48 | exc_info=True) 49 | continue 50 | if res is True: 51 | return True 52 | return False 53 | return None 54 | 55 | 56 | T = TypeVar('T', bound='_Event') 57 | 58 | 59 | # 定义事件监听器的数据类 60 | @dataclass(order=True) 61 | class EventListener: 62 | """ 63 | 事件监听器数据类 64 | """ 65 | priority: int # 优先级,默认为排序的依据 66 | func: Callable[[T, ...], Any] # 监听器函数 67 | kwargs: dict[str, Any] = field(default_factory=dict) # 附加参数 68 | 69 | def __post_init__(self): 70 | # 确保监听器函数至少有一个参数 71 | assert len(inspect.signature(self.func).parameters) >= 1, "监听器至少接受 1 个参数" 72 | 73 | 74 | # 定义监听器的类型和存储 75 | event_listeners: dict[type[T], list[EventListener]] = {} 76 | 77 | 78 | # 装饰器,用于注册监听器 79 | def event_listener(event_class: type[T], priority: int = 0, **kwargs): 80 | """ 81 | 用于注册监听器 82 | 83 | Args: 84 | event_class: 事件类型 85 | priority: 优先级,默认为0 86 | **kwargs: 附加参数 87 | """ 88 | if not issubclass(event_class, _Event): 89 | raise TypeError("event_class 类必须是 _Event 的子类") 90 | 91 | def wrapper(func: Callable[[T, ...], Any]): 92 | # 注册事件监听器 93 | listener = EventListener(priority=priority, func=func, kwargs=kwargs) 94 | event_listeners.setdefault(event_class, []).append(listener) 95 | return func 96 | 97 | return wrapper 98 | 99 | 100 | def unregister_listener(event_class: type[T], func: Callable[[T, ...], Any]): 101 | """ 102 | 用于取消注册监听器 103 | 注意,会删除所有与给定函数匹配的监听器。 104 | 105 | Args: 106 | event_class: 事件类型 107 | func: 监听器函数 108 | """ 109 | if not issubclass(event_class, _Event): 110 | raise TypeError("event_class 类必须是 _Event 的子类") 111 | 112 | listeners_list = event_listeners.get(event_class) 113 | 114 | if not listeners_list: 115 | raise ValueError(f"事件类型 {event_class.__name__} 没有已注册的监听器。") 116 | 117 | # 查找所有与给定函数匹配的监听器对象 118 | listeners_to_remove = [listener for listener in listeners_list if listener.func == func] 119 | 120 | if not listeners_to_remove: 121 | # 如果没有找到匹配的函数 122 | raise ValueError(f"未找到函数 {func.__name__} 对应的监听器,无法为事件 {event_class.__name__} 注销。") 123 | 124 | # 移除所有找到的监听器 125 | removed_count = 0 126 | for listener_obj in listeners_to_remove: 127 | listeners_list.remove(listener_obj) 128 | removed_count += 1 129 | 130 | if not listeners_list: 131 | del event_listeners[event_class] 132 | 133 | 134 | class Event(_Event): 135 | """ 136 | 基事件类,所有自定义事件均继承自此类,继承自此类以创建自定义事件 137 | """ 138 | 139 | def _call_hook(self, listener): 140 | return Hook(self, listener).call() 141 | 142 | def call(self): 143 | """ 144 | 按优先级顺序同步触发所有监听器 145 | """ 146 | if self.__class__ in event_listeners: 147 | res_list = [] 148 | for listener in sorted(event_listeners[self.__class__], key=lambda i: i.priority, reverse=True): 149 | if self._call_hook(listener): 150 | logger.debug(f"由 Hook 跳过监听器: {listener.func.__name__}") 151 | continue 152 | try: 153 | res = listener.func(self, **listener.kwargs) 154 | except Exception as e: 155 | if ConfigManager.GlobalConfig().debug.save_dump: 156 | dump_path = save_exc_dump(f"监听器中发生错误") 157 | else: 158 | dump_path = None 159 | logger.error(f"监听器中发生错误: {repr(e)}" 160 | f"{f"\n已保存异常到 {dump_path}" if dump_path else ""}", 161 | exc_info=True) 162 | continue 163 | res_list.append(res) 164 | 165 | @async_task 166 | def call_async(self): 167 | """ 168 | 无需等待的异步按优先级顺序触发所有监听器 169 | """ 170 | self.call() 171 | 172 | 173 | if __name__ == "__main__": 174 | # 示例:自定义事件 175 | """ 176 | class MyEvent(Event): 177 | def __init__(self, message): 178 | self.message = message 179 | 180 | 181 | # 监听器函数 182 | @event_listener(MyEvent, priority=10, other_message="priority is 10") 183 | @event_listener(MyEvent, priority=100, other_message="priority is 100") 184 | @event_listener(MyEvent, other_message="I'm going to be skipped") 185 | def on_my_event(event, other_message=""): 186 | print(f"Received event: {event.message}!", other_message) 187 | 188 | 189 | @event_listener(Hook) 190 | def on_hook(event): 191 | if event.event.__class__ == MyEvent and event.listener.kwargs["other_message"] == "I'm going to be skipped": 192 | return True 193 | 194 | 195 | # 触发事件 196 | event = MyEvent("Hello, World") 197 | event.call() 198 | """ 199 | -------------------------------------------------------------------------------- /Lib/core/ListenerServer.py: -------------------------------------------------------------------------------- 1 | """ 2 | 监听服务器 3 | """ 4 | 5 | from ..utils import Logger 6 | from .ConfigManager import GlobalConfig 7 | from Lib.core import EventManager 8 | 9 | from concurrent.futures import ThreadPoolExecutor 10 | from wsgiref.simple_server import WSGIServer 11 | 12 | from flask import Flask, request 13 | import hmac 14 | 15 | logger = Logger.get_logger() 16 | app = Flask(__name__) 17 | 18 | 19 | class EscalationEvent(EventManager.Event): 20 | """ 21 | 上报事件 22 | """ 23 | 24 | def __init__(self, event_data): 25 | self.event_data = event_data 26 | 27 | 28 | @app.route("/", methods=["POST"]) 29 | def post_data(): 30 | """ 31 | 上报处理 32 | """ 33 | if GlobalConfig().server.secret: 34 | sig = hmac.new(GlobalConfig().server.secret.encode("utf-8"), request.get_data(), 'sha1').hexdigest() 35 | try: 36 | received_sig = request.headers['X-Signature'][len('sha1='):] 37 | except KeyError: 38 | logger.warning("收到非法请求(缺少签名),拒绝访问") 39 | return "", 401 40 | if sig != received_sig: 41 | logger.warning("收到非法请求(签名不匹配),拒绝访问") 42 | return "", 401 43 | data = request.get_json() 44 | logger.debug("收到上报: %s" % data) 45 | if "self" in data and GlobalConfig().account.user_id != 0 and data.get("self") != GlobalConfig().account.user_id: 46 | logger.warning(f"收到来自其他bot的消息,忽略: {data}") 47 | return "ok", 204 48 | EscalationEvent(data).call_async() 49 | 50 | return "ok", 204 51 | 52 | 53 | config = GlobalConfig() 54 | if config.server.server == "werkzeug": 55 | # 使用werkzeug服务器 56 | from werkzeug.serving import WSGIRequestHandler 57 | 58 | 59 | class ThreadPoolWSGIServer(WSGIServer): 60 | """ 61 | 线程池WSGI服务器 62 | """ 63 | 64 | def __init__(self, server_address, app=None, max_workers=10, passthrough_errors=False, 65 | handler_class=WSGIRequestHandler, **kwargs): 66 | super().__init__(server_address, handler_class, **kwargs) 67 | self.executor = ThreadPoolExecutor(max_workers=max_workers) 68 | self.app = app 69 | self.ssl_context = None 70 | self.multithread = True 71 | self.multiprocess = False 72 | self.threaded = True 73 | self.passthrough_errors = passthrough_errors 74 | 75 | def handle_request(self): 76 | """ 77 | 处理请求 78 | """ 79 | request, client_address = self.get_request() 80 | if self.verify_request(request, client_address): 81 | self.executor.submit(self.process_request, request, client_address) 82 | 83 | def serve_forever(self): 84 | """ 85 | 启动服务器 86 | """ 87 | while True: 88 | self.handle_request() 89 | 90 | 91 | class ThreadPoolWSGIRequestHandler(WSGIRequestHandler): 92 | def handle(self): 93 | super().handle() 94 | 95 | 96 | server = ThreadPoolWSGIServer((config.server.host, config.server.port), 97 | app=app, 98 | max_workers=config.server.max_works) 99 | server.RequestHandlerClass = ThreadPoolWSGIRequestHandler 100 | start_server = lambda: server.serve_forever() 101 | elif config.server.server == "waitress": 102 | # 使用waitress服务器 103 | from waitress import serve 104 | 105 | start_server = lambda: serve(app, host=config.server.host, port=config.server.port, threads=config.server.max_works) 106 | else: 107 | raise ValueError("服务器类型错误: 未知服务器类型") 108 | -------------------------------------------------------------------------------- /Lib/core/OnebotAPI.py: -------------------------------------------------------------------------------- 1 | # __ __ ____ _ ____ _ _____ 2 | # | \/ |_ _| _ \ __ _(_)_ __ | __ ) ___ | |_|___ \ 3 | # | |\/| | | | | |_) / _` | | '_ \ | _ \ / _ \| __| __) | 4 | # | | | | |_| | _ < (_| | | | | | | |_) | (_) | |_ / __/ 5 | # |_| |_|\__,_|_| \_\__,_|_|_| |_| |____/ \___/ \__|_____| 6 | 7 | """ 8 | OnebotAPI 9 | 可以方便的调用Onebot的API 10 | """ 11 | 12 | import json 13 | import traceback 14 | import urllib.parse 15 | import requests 16 | 17 | from . import EventManager, ConfigManager 18 | from ..common import save_exc_dump 19 | from ..utils import Logger 20 | 21 | logger = Logger.get_logger() 22 | config = ConfigManager.GlobalConfig() 23 | 24 | 25 | class CallAPIEvent(EventManager.Event): 26 | """ 27 | 调用API事件 28 | """ 29 | def __init__(self, full_path, node, data): 30 | self.full_path: str = full_path 31 | self.node: str = node 32 | self.data: dict | None = data 33 | 34 | 35 | class OnebotAPI: 36 | """ 37 | OnebotAPI 38 | """ 39 | def __init__(self, host: str = None, port: int = None, 40 | original: bool = False): 41 | """ 42 | Args: 43 | host: 调用的ip 44 | port: 调用的端口 45 | original: 是否返回全部json(默认只返回data内) 46 | """ 47 | if host is None: 48 | host = config.api.host 49 | if port is None: 50 | port = config.api.port 51 | 52 | self.host = host 53 | self.port = port 54 | self.original = original 55 | 56 | def set_url(self, host: str, port: int): 57 | """ 58 | 设置url 59 | Args: 60 | host: 请求的host 61 | port: 请求的端口 62 | """ 63 | self.host = host 64 | self.port = port 65 | 66 | def get(self, node, data: dict = None, original: bool = None): 67 | """ 68 | 调用api 69 | Args: 70 | node: 节点 71 | data: 数据 72 | original: 是否返回全部json(默认只返回data内) 73 | """ 74 | 75 | if original is None: 76 | original = self.original 77 | 78 | if node == "": 79 | raise ValueError('The node cannot be empty.') 80 | 81 | host = self.host 82 | port = self.port 83 | 84 | if not host: 85 | raise ValueError('The host cannot be empty.') 86 | 87 | if (not isinstance(port, int)) or port > 65535 or port < 0: 88 | raise ValueError('The port cannot be empty.') 89 | 90 | if not (host.startswith("http://") or host.startswith("https://")): 91 | host = "http://" + host 92 | 93 | # 拼接url 94 | url = urllib.parse.urljoin(host + ":" + str(port), node) 95 | 96 | # 广播call_api事件 97 | event = CallAPIEvent(url, node, data) 98 | event.call_async() 99 | if traceback.extract_stack()[-1].filename == traceback.extract_stack()[-2].filename: 100 | logger.debug(f"调用 API: {node} data: {data} by: {traceback.extract_stack()[-3].filename}") 101 | else: 102 | logger.debug(f"调用 API: {node} data: {data} by: {traceback.extract_stack()[-2].filename}") 103 | headers = { 104 | "Content-Type": "application/json" 105 | } 106 | if config.api.access_token: 107 | headers["Authorization"] = f"Bearer {config.api.access_token}" 108 | # 发起get请求 109 | creat_dump = True 110 | try: 111 | response = requests.post( 112 | url, 113 | headers=headers, 114 | data=json.dumps(data if data is not None else {}) 115 | ) 116 | if response.status_code != 200 or (response.json()['status'] != 'ok' or response.json()['retcode'] != 0): 117 | creat_dump = False 118 | raise Exception(f"返回异常, 状态码: {response.status_code}, 返回内容: {response.text}") 119 | 120 | # 如果original为真,则返回原值和response 121 | if original: 122 | return response.json() 123 | else: 124 | return response.json()['data'] 125 | except Exception as e: 126 | if ConfigManager.GlobalConfig().debug.save_dump and creat_dump: 127 | dump_path = save_exc_dump(f"调用 API: {node} data: {data} 异常") 128 | else: 129 | dump_path = None 130 | logger.error( 131 | f"调用 API: {node} data: {data} 异常: {repr(e)}" 132 | f"{f"\n已保存异常到 {dump_path}" if dump_path else ""}", 133 | exc_info=True 134 | ) 135 | raise e 136 | 137 | def send_private_msg(self, user_id: int, message: str | list[dict]): 138 | """ 139 | 发送私聊消息 140 | Args: 141 | user_id: 用户id 142 | message: 消息内容 143 | """ 144 | data = { 145 | "user_id": user_id, 146 | "message": message 147 | } 148 | return self.get("/send_private_msg", data) 149 | 150 | def send_group_msg(self, group_id: int, message: str | list[dict]): 151 | """ 152 | 发送群消息 153 | Args: 154 | group_id: 群号 155 | message: 消息内容 156 | """ 157 | data = { 158 | "group_id": group_id, 159 | "message": message 160 | } 161 | return self.get("/send_group_msg", data) 162 | 163 | def send_msg(self, user_id: int = -1, group_id: int = -1, message: str | list[dict] = ""): 164 | """ 165 | 发送消息 166 | Args: 167 | user_id: 用户id 168 | group_id: 群号 169 | message: 消息内容 170 | """ 171 | if user_id != -1 and group_id != -1: 172 | raise ValueError('user_id and group_id cannot be both not -1.') 173 | if user_id == -1 and group_id == -1: 174 | raise ValueError('user_id and group_id cannot be both -1.') 175 | if user_id != -1: 176 | return self.send_private_msg(user_id, message) 177 | elif group_id != -1: 178 | return self.send_group_msg(group_id, message) 179 | else: 180 | raise ValueError('user_id and group_id cannot be both -1.') 181 | 182 | def delete_msg(self, message_id: int): 183 | """ 184 | 删除消息 185 | Args: 186 | message_id: 消息id 187 | """ 188 | data = { 189 | "message_id": message_id 190 | } 191 | return self.get("/delete_msg", data) 192 | 193 | def get_msg(self, message_id: int): 194 | """ 195 | 获取消息 196 | Args: 197 | message_id: 消息id 198 | """ 199 | data = { 200 | "message_id": message_id 201 | } 202 | return self.get("/get_msg", data) 203 | 204 | def get_forward_msg(self, id: int): 205 | """ 206 | 获取合并转发消息 207 | Args: 208 | id: 合并转发id 209 | """ 210 | data = { 211 | "id": id 212 | } 213 | return self.get("/get_forward_msg", data) 214 | 215 | def send_like(self, user_id: int, times: int = 1): 216 | """ 217 | 发送点赞 218 | Args: 219 | user_id: 用户id 220 | times: 点赞次数 221 | """ 222 | data = { 223 | "user_id": user_id, 224 | "times": times 225 | } 226 | return self.get("/send_like", data) 227 | 228 | def set_group_kick(self, group_id: int, user_id: int, reject_add_request: bool = False): 229 | """ 230 | 群组踢人 231 | Args: 232 | group_id: 群号 233 | user_id: 用户id 234 | reject_add_request: 拒绝加群请求 235 | """ 236 | data = { 237 | "group_id": group_id, 238 | "user_id": user_id, 239 | "reject_add_request": reject_add_request 240 | } 241 | return self.get("/set_group_kick", data) 242 | 243 | def set_group_ban(self, group_id: int, user_id: int, duration: int = 30 * 60): 244 | """ 245 | 群组单人禁言 246 | Args: 247 | group_id: 群号 248 | user_id: 用户id 249 | duration: 禁言时长,单位秒,0 表示取消禁言 250 | """ 251 | data = { 252 | "group_id": group_id, 253 | "user_id": user_id, 254 | "duration": duration 255 | } 256 | return self.get("/set_group_ban", data) 257 | 258 | def set_group_anonymous_ban(self, group_id: int, anonymous: dict, duration: int = 30 * 60): 259 | """ 260 | 群组匿名用户禁言 261 | Args: 262 | group_id: 群号 263 | anonymous: 匿名用户对象 264 | duration: 禁言时长,单位秒,无法取消禁言 265 | """ 266 | data = { 267 | "group_id": group_id, 268 | "anonymous": anonymous, 269 | "duration": duration 270 | } 271 | return self.get("/set_group_anonymous_ban", data) 272 | 273 | def set_group_whole_ban(self, group_id: int, enable: bool = True): 274 | """ 275 | 群组全员禁言 276 | Args: 277 | group_id: 群号 278 | enable: 是否禁言 279 | """ 280 | data = { 281 | "group_id": group_id, 282 | "enable": enable 283 | } 284 | return self.get("/set_group_whole_ban", data) 285 | 286 | def set_group_admin(self, group_id: int, user_id: int, enable: bool = True): 287 | """ 288 | 群组设置管理员 289 | Args: 290 | group_id: 群号 291 | user_id: 用户id 292 | enable: 是否设置管理员 293 | """ 294 | data = { 295 | "group_id": group_id, 296 | "user_id": user_id, 297 | "enable": enable 298 | } 299 | return self.get("/set_group_admin", data) 300 | 301 | def set_group_card(self, group_id: int, user_id: int, card: str = ""): 302 | """ 303 | 设置群名片(群备注) 304 | Args: 305 | group_id: 群号 306 | user_id: 用户id 307 | card: 群名片内容 308 | """ 309 | data = { 310 | "group_id": group_id, 311 | "user_id": user_id, 312 | "card": card 313 | } 314 | return self.get("/set_group_card", data) 315 | 316 | def set_group_name(self, group_id: int, group_name: str): 317 | """ 318 | 设置群名 319 | Args: 320 | group_id: 群号 321 | group_name: 群名 322 | """ 323 | data = { 324 | "group_id": group_id, 325 | "group_name": group_name 326 | } 327 | return self.get("/set_group_name", data) 328 | 329 | def set_group_leave(self, group_id: int, is_dismiss: bool = False): 330 | """ 331 | Args: 332 | group_id: 群号 333 | is_dismiss: 是否解散,如果登录号是群主,则仅在此项为True时能够解散 334 | """ 335 | data = { 336 | "group_id": group_id, 337 | "is_dismiss": is_dismiss 338 | } 339 | return self.get("/set_group_leave", data) 340 | 341 | def set_group_special_title(self, group_id: int, user_id: int, special_title: str = "", duration: int = -1): 342 | """ 343 | 设置群组专属头衔 344 | Args: 345 | group_id: 群号 346 | user_id: 要设置的QQ号 347 | special_title: 专属头衔,不填或空字符串表示删除专属头衔 348 | duration: 专属头衔有效期,-1表示永久,其他值表示在此时间之前专属头衔会消失 349 | """ 350 | data = { 351 | "group_id": group_id, 352 | "user_id": user_id, 353 | "special_title": special_title, 354 | } 355 | if duration != -1: 356 | data["duration"] = duration 357 | 358 | return self.get("/set_group_special_title", data) 359 | 360 | def set_friend_add_request(self, flag: str, approve: bool = True, remark: str = ""): 361 | """ 362 | 设置好友添加请求 363 | Args: 364 | flag: 请求flag 365 | approve: 是否同意请求 366 | remark: 添加后的好友备注 367 | """ 368 | data = { 369 | "flag": flag, 370 | "approve": approve, 371 | "remark": remark 372 | } 373 | return self.get("/set_friend_add_request", data) 374 | 375 | def set_group_add_request(self, flag: str, sub_type: str = "add", approve: bool = True, reason: str = ""): 376 | """ 377 | 设置群添加请求 378 | Args: 379 | flag: 请求flag 380 | sub_type: 添加请求类型,请参考api文档 381 | approve: 是否同意请求 382 | reason: 拒绝理由 383 | """ 384 | data = { 385 | "flag": flag, 386 | "sub_type": sub_type, 387 | "approve": approve, 388 | "reason": reason 389 | } 390 | return self.get("/set_group_add_request", data) 391 | 392 | def get_login_info(self): 393 | """ 394 | 获取登录号信息 395 | """ 396 | return self.get("/get_login_info") 397 | 398 | def get_stranger_info(self, user_id: int, no_cache: bool = False): 399 | """ 400 | 获取陌生人信息 401 | Args: 402 | user_id: 对方QQ号 403 | no_cache: 是否不使用缓存(使用缓存可能更新不及时,但响应更快) 404 | """ 405 | data = { 406 | "user_id": user_id, 407 | "no_cache": no_cache 408 | } 409 | return self.get("/get_stranger_info", data) 410 | 411 | def get_friend_list(self): 412 | """ 413 | 获取好友列表 414 | """ 415 | return self.get("/get_friend_list") 416 | 417 | def get_group_info(self, group_id: int, no_cache: bool = False): 418 | """ 419 | 获取群信息 420 | Args: 421 | group_id: 群号 422 | no_cache: 是否不使用缓存(使用缓存可能更新不及时,但响应更快) 423 | """ 424 | data = { 425 | "group_id": group_id, 426 | "no_cache": no_cache 427 | } 428 | return self.get("/get_group_info", data) 429 | 430 | def get_group_list(self): 431 | """ 432 | 获取群列表 433 | """ 434 | return self.get("/get_group_list") 435 | 436 | def get_group_member_info(self, group_id: int, user_id: int, no_cache: bool = False): 437 | """ 438 | 获取群成员信息 439 | Args: 440 | group_id: 群号 441 | user_id: QQ号 442 | no_cache: 是否不使用缓存(使用缓存可能更新不及时,但响应更快) 443 | """ 444 | data = { 445 | "group_id": group_id, 446 | "user_id": user_id, 447 | "no_cache": no_cache 448 | } 449 | return self.get("/get_group_member_info", data) 450 | 451 | def get_group_member_list(self, group_id: int): 452 | """ 453 | 获取群成员列表 454 | Args: 455 | group_id: 群号 456 | """ 457 | data = { 458 | "group_id": group_id 459 | } 460 | return self.get("/get_group_member_list", data) 461 | 462 | def get_group_honor_info(self, group_id: int, type_: str = "all"): 463 | """ 464 | 获取群荣誉信息 465 | Args: 466 | group_id: 群号 467 | type_: 要获取的群荣誉类型,可传入 talkative performer legend strong_newbie emotion 以分别获取单个类型的群荣誉数据,或传入 all 获取所有数据 468 | """ 469 | data = { 470 | "group_id": group_id, 471 | "type": type_ 472 | } 473 | return self.get("/get_group_honor_info", data) 474 | 475 | def get_cookies(self): 476 | """ 477 | 获取Cookies 478 | """ 479 | return self.get("/get_cookies") 480 | 481 | def get_csrf_token(self): 482 | """ 483 | 获取CSRF Token 484 | """ 485 | return self.get("/get_csrf_token") 486 | 487 | def get_credentials(self): 488 | """ 489 | 获取Credentials 490 | """ 491 | return self.get("/get_credentials") 492 | 493 | def get_record(self, file: str, out_format: str = "mp3", out_file: str = ""): 494 | """ 495 | 获取语音 496 | Args: 497 | file: 文件ID 498 | out_format: 输出格式,mp3或amr,默认mp3 499 | out_file: 输出文件名,默认使用文件ID 500 | """ 501 | data = { 502 | "file": file, 503 | "out_format": out_format, 504 | "out_file": out_file 505 | } 506 | return self.get("/get_record", data) 507 | 508 | def get_image(self, file: str): 509 | """ 510 | 获取图片 511 | Args: 512 | file: 文件ID 513 | """ 514 | data = { 515 | "file": file 516 | } 517 | return self.get("/get_image", data) 518 | 519 | def can_send_image(self): 520 | """ 521 | 检查是否可以发送图片 522 | """ 523 | return self.get("/can_send_image") 524 | 525 | def can_send_record(self): 526 | """ 527 | 检查是否可以发送语音 528 | """ 529 | return self.get("/can_send_record") 530 | 531 | def get_status(self): 532 | """ 533 | 获取运行状态 534 | """ 535 | return self.get("/get_status") 536 | 537 | def get_version_info(self): 538 | """ 539 | 获取版本信息 540 | """ 541 | return self.get("/get_version_info") 542 | 543 | def set_restart(self, delay: int = 0): 544 | """ 545 | 重启OneBot 546 | Args: 547 | delay: 延迟时间,单位秒,默认0 548 | """ 549 | data = { 550 | "delay": delay 551 | } 552 | return self.get("/set_restart", data) 553 | 554 | def clean_cache(self): 555 | """ 556 | 清理缓存 557 | """ 558 | return self.get("/clean_cache") 559 | 560 | 561 | api = OnebotAPI() 562 | -------------------------------------------------------------------------------- /Lib/core/PluginManager.py: -------------------------------------------------------------------------------- 1 | """ 2 | 插件管理器 3 | """ 4 | 5 | import dataclasses 6 | import importlib 7 | import inspect 8 | import sys 9 | 10 | from Lib.common import save_exc_dump 11 | from Lib.constants import * 12 | from Lib.core import ConfigManager 13 | from Lib.core.EventManager import event_listener 14 | from Lib.core.ListenerServer import EscalationEvent 15 | from Lib.utils.Logger import get_logger 16 | 17 | logger = get_logger() 18 | 19 | plugins: list[dict] = [] 20 | found_plugins: list[dict] = [] 21 | has_main_func_plugins: list[dict] = [] 22 | 23 | if not os.path.exists(PLUGINS_PATH): 24 | os.makedirs(PLUGINS_PATH) 25 | 26 | 27 | class NotEnabledPluginException(Exception): 28 | """ 29 | 插件未启用的异常 30 | """ 31 | pass 32 | 33 | 34 | def load_plugin(plugin): 35 | """ 36 | 加载插件 37 | Args: 38 | plugin: 插件信息 39 | """ 40 | name = plugin["name"] 41 | full_path = plugin["path"] 42 | is_package = os.path.isdir(full_path) and os.path.exists(os.path.join(full_path, "__init__.py")) 43 | 44 | # 计算导入路径 45 | # 获取相对于 WORK_PATH 的路径,例如 "plugins/AIChat" 或 "plugins/single_file_plugin.py" 46 | relative_plugin_path = os.path.relpath(full_path, start=WORK_PATH) 47 | 48 | # 将路径分隔符替换为点,例如 "plugins.AIChat" 或 "plugins.single_file_plugin" 49 | import_path = relative_plugin_path.replace(os.sep, '.') 50 | if not is_package and import_path.endswith('.py'): 51 | import_path = import_path[:-3] # 去掉 .py 后缀 52 | 53 | logger.debug(f"计算 {name} 得到的导入路径: {import_path}") 54 | 55 | if WORK_PATH not in sys.path: 56 | logger.warning(f"项目根目录 {WORK_PATH} 不在 sys.path 中,正在添加。请检查执行环境。") 57 | sys.path.insert(0, WORK_PATH) # 插入到前面,优先查找 58 | 59 | try: 60 | logger.debug(f"尝试加载: {import_path}") 61 | module = importlib.import_module(import_path) 62 | except ImportError as e: 63 | logger.error(f"加载 {import_path} 失败: {repr(e)}", exc_info=True) 64 | raise 65 | 66 | plugin_info = None 67 | try: 68 | if isinstance(module.plugin_info, PluginInfo): 69 | plugin_info = module.plugin_info 70 | else: 71 | logger.warning(f"插件 {name} 的 plugin_info 并非 PluginInfo 类型,无法获取插件信息") 72 | except AttributeError: 73 | logger.warning(f"插件 {name} 未定义 plugin_info 属性,无法获取插件信息") 74 | 75 | return module, plugin_info 76 | 77 | 78 | def load_plugins(): 79 | """ 80 | 加载插件 81 | """ 82 | global plugins, found_plugins 83 | 84 | found_plugins = [] 85 | # 获取插件目录下的所有文件 86 | for plugin in os.listdir(PLUGINS_PATH): 87 | if plugin == "__pycache__": 88 | continue 89 | full_path = os.path.join(PLUGINS_PATH, plugin) 90 | if ( 91 | os.path.isdir(full_path) and 92 | os.path.exists(os.path.join(full_path, "__init__.py")) and 93 | os.path.isfile(os.path.join(full_path, "__init__.py")) 94 | ): 95 | file_path = os.path.join(os.path.join(full_path, "__init__.py")) 96 | name = plugin 97 | elif os.path.isfile(full_path) and full_path.endswith(".py"): 98 | file_path = full_path 99 | name = os.path.split(file_path)[1] 100 | else: 101 | logger.warning(f"{full_path} 不是一个有效的插件") 102 | continue 103 | logger.debug(f"找到插件 {file_path} 待加载") 104 | plugin = {"name": name, "plugin": None, "info": None, "file_path": file_path, "path": full_path} 105 | found_plugins.append(plugin) 106 | 107 | plugins = [] 108 | 109 | for plugin in found_plugins: 110 | name = plugin["name"] 111 | full_path = plugin["path"] 112 | 113 | if plugin["plugin"] is not None: 114 | # 由于其他原因已被加载(例如插件依赖) 115 | logger.debug(f"插件 {name} 已被加载,跳过加载") 116 | continue 117 | 118 | logger.debug(f"开始尝试加载插件 {full_path}") 119 | 120 | try: 121 | module, plugin_info = load_plugin(plugin) 122 | 123 | plugin["info"] = plugin_info 124 | plugin["plugin"] = module 125 | plugins.append(plugin) 126 | except NotEnabledPluginException: 127 | logger.warning(f"插件 {name}({full_path}) 已被禁用,将不会被加载") 128 | continue 129 | except Exception as e: 130 | if ConfigManager.GlobalConfig().debug.save_dump: 131 | dump_path = save_exc_dump(f"尝试加载插件 {full_path} 时失败") 132 | else: 133 | dump_path = None 134 | logger.error(f"尝试加载插件 {full_path} 时失败! 原因:{repr(e)}" 135 | f"{f"\n已保存异常到 {dump_path}" if dump_path else ""}", 136 | exc_info=True) 137 | continue 138 | 139 | logger.debug(f"插件 {name}({full_path}) 加载成功!") 140 | 141 | 142 | @dataclasses.dataclass 143 | class PluginInfo: 144 | """ 145 | 插件信息 146 | """ 147 | NAME: str # 插件名称 148 | AUTHOR: str # 插件作者 149 | VERSION: str # 插件版本 150 | DESCRIPTION: str # 插件描述 151 | HELP_MSG: str # 插件帮助 152 | ENABLED: bool = True # 插件是否启用 153 | IS_HIDDEN: bool = False # 插件是否隐藏(在/help命令中) 154 | extra: dict | None = None # 一个字典,可以用于存储任意信息。其他插件可以通过约定 extra 字典的键名来达成收集某些特殊信息的目的。 155 | 156 | def __post_init__(self): 157 | if self.ENABLED is not True: 158 | raise NotEnabledPluginException 159 | if self.extra is None: 160 | self.extra = {} 161 | 162 | 163 | def requirement_plugin(plugin_name: str): 164 | """ 165 | 插件依赖 166 | Args: 167 | plugin_name: 插件的名称,如果依赖的是库形式的插件则是库文件夹的名称,如果依赖的是文件形式则是插件文件的名称(文件名称包含后缀) 168 | 169 | Returns: 170 | 依赖的插件的信息 171 | """ 172 | logger.debug(f"由于插件依赖,正在尝试加载插件 {plugin_name}") 173 | for plugin in found_plugins: 174 | if plugin["name"] == plugin_name: 175 | if plugin not in plugins: 176 | try: 177 | module, plugin_info = load_plugin(plugin) 178 | plugin["info"] = plugin_info 179 | plugin["plugin"] = module 180 | plugins.append(plugin) 181 | except NotEnabledPluginException: 182 | logger.error(f"被依赖的插件 {plugin_name} 已被禁用,无法加载依赖") 183 | raise Exception(f"被依赖的插件 {plugin_name} 已被禁用,无法加载依赖") 184 | except Exception as e: 185 | if ConfigManager.GlobalConfig().debug.save_dump: 186 | dump_path = save_exc_dump(f"尝试加载被依赖的插件 {plugin_name} 时失败!") 187 | else: 188 | dump_path = None 189 | logger.error(f"尝试加载被依赖的插件 {plugin_name} 时失败! 原因:{repr(e)}" 190 | f"{f"\n已保存异常到 {dump_path}" if dump_path else ""}", 191 | exc_info=True) 192 | raise e 193 | logger.debug(f"由于插件依赖,插件 {plugin_name} 加载成功!") 194 | else: 195 | logger.debug(f"由于插件依赖,插件 {plugin_name} 已被加载,跳过加载") 196 | return plugin 197 | else: 198 | raise FileNotFoundError(f"插件 {plugin_name} 不存在或不符合要求,无法加载依赖") 199 | 200 | 201 | # 该方法已被弃用 202 | def run_plugin_main(event_data): 203 | """ 204 | 运行插件的main函数 205 | Args: 206 | event_data: 事件数据 207 | """ 208 | global has_main_func_plugins 209 | for plugin in has_main_func_plugins: 210 | logger.debug(f"执行插件: {plugin['name']}") 211 | try: 212 | plugin["plugin"].main(event_data, WORK_PATH) 213 | except Exception as e: 214 | logger.error(f"执行插件{plugin['name']}时发生错误: {repr(e)}") 215 | continue 216 | 217 | 218 | @event_listener(EscalationEvent) 219 | def run_plugin_main_wrapper(event): 220 | """ 221 | 运行插件的main函数 222 | Args: 223 | event: 事件 224 | """ 225 | run_plugin_main(event.event_data) 226 | 227 | 228 | def get_caller_plugin_data(): 229 | """ 230 | 获取调用者的插件数据 231 | :return: 232 | plugin_data: dict | None 233 | """ 234 | 235 | stack = inspect.stack()[1:] 236 | for frame_info in stack: 237 | filename = frame_info.filename 238 | 239 | normalized_filename = os.path.normpath(filename) 240 | normalized_plugins_path = os.path.normpath(PLUGINS_PATH) 241 | 242 | if normalized_filename.startswith(normalized_plugins_path): 243 | for plugin in found_plugins: 244 | normalized_plugin_file_path = os.path.normpath(plugin["file_path"]) 245 | plugin_dir, plugin_file = os.path.split(normalized_plugin_file_path) 246 | 247 | if plugin_dir == normalized_plugins_path: 248 | if normalized_plugin_file_path == normalized_filename: 249 | return plugin 250 | else: 251 | if normalized_filename.startswith(plugin_dir): 252 | return plugin 253 | return None 254 | -------------------------------------------------------------------------------- /Lib/core/ThreadPool.py: -------------------------------------------------------------------------------- 1 | """ 2 | 线程池 3 | Created by BigCookie233 4 | """ 5 | 6 | import atexit 7 | from concurrent.futures import ThreadPoolExecutor 8 | 9 | from Lib.common import save_exc_dump 10 | from Lib.core import ConfigManager 11 | from Lib.core.ConfigManager import GlobalConfig 12 | from Lib.utils.Logger import get_logger 13 | 14 | thread_pool = None 15 | logger = get_logger() 16 | 17 | 18 | def shutdown(): 19 | """ 20 | 关闭线程池 21 | """ 22 | global thread_pool 23 | if isinstance(thread_pool, ThreadPoolExecutor): 24 | logger.debug("Closing Thread Pool") 25 | thread_pool.shutdown() 26 | thread_pool = None 27 | 28 | 29 | def init(): 30 | """ 31 | 初始化线程池 32 | Returns: 33 | None 34 | """ 35 | global thread_pool 36 | thread_pool = ThreadPoolExecutor(max_workers=GlobalConfig().thread_pool.max_workers) 37 | atexit.register(shutdown) 38 | 39 | 40 | def _wrapper(func, *args, **kwargs): 41 | try: 42 | return func(*args, **kwargs) 43 | except Exception as e: 44 | if ConfigManager.GlobalConfig().debug.save_dump: 45 | dump_path = save_exc_dump(f"Error in async task({func.__module__}.{func.__name__})") 46 | else: 47 | dump_path = None 48 | # 打印到日志中 49 | logger.error( 50 | f"Error in async task({func.__module__}.{func.__name__}): {repr(e)}" 51 | f"{f"\n已保存异常到 {dump_path}" if dump_path else ""}", 52 | exc_info=True 53 | ) 54 | return None 55 | 56 | 57 | def async_task(func): 58 | """ 59 | 异步任务装饰器 60 | """ 61 | def wrapper(*args, **kwargs): 62 | if isinstance(thread_pool, ThreadPoolExecutor): 63 | return thread_pool.submit(_wrapper, func, *args, **kwargs) 64 | else: 65 | logger.warning("Thread Pool is not initialized. Please call init() before using it.") 66 | return func(*args, **kwargs) 67 | 68 | return wrapper 69 | -------------------------------------------------------------------------------- /Lib/core/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | MRB2 Lib 核心模块 3 | """ 4 | 5 | from . import ConfigManager 6 | from . import EventManager 7 | from . import ListenerServer 8 | from . import OnebotAPI 9 | from . import ThreadPool 10 | from . import PluginManager 11 | -------------------------------------------------------------------------------- /Lib/utils/AutoRestartOnebot.py: -------------------------------------------------------------------------------- 1 | """ 2 | 自动重启 Onebot 实现端 3 | """ 4 | 5 | from Lib.utils import EventClassifier, Logger, Actions 6 | from Lib.core import ConfigManager, EventManager, ThreadPool 7 | 8 | import time 9 | 10 | heartbeat_interval = -1 11 | last_heartbeat_time = -1 12 | logger = Logger.get_logger() 13 | 14 | 15 | @ThreadPool.async_task 16 | def restart_onebot(message): 17 | """ 18 | 重启 Onebot 实现端 19 | Args: 20 | message: 触发重启的原因 21 | Returns: 22 | None 23 | """ 24 | if ConfigManager.GlobalConfig().auto_restart_onebot.enable is False: 25 | logger.warning(f"检测到 {message},由于未启用自动重启功能,将不会自动重启 Onebot 实现端") 26 | return 27 | logger.warning(f"因为 {message},将尝试自动重启 Onebot 实现端!") 28 | action = Actions.SetRestart(2000).call() 29 | if action.get_result().is_ok: 30 | logger.warning("尝试重启 Onebot 实现端成功!") 31 | else: 32 | logger.error("尝试重启 Onebot 实现端失败!") 33 | 34 | 35 | @EventManager.event_listener(EventClassifier.HeartbeatMetaEvent) 36 | def on_heartbeat(event: EventClassifier.HeartbeatMetaEvent): 37 | """ 38 | 心跳包事件监听器 39 | Args: 40 | event: 心跳包事件 41 | """ 42 | global heartbeat_interval, last_heartbeat_time 43 | heartbeat_interval = event.interval / 1000 44 | last_heartbeat_time = time.time() 45 | status = event.status 46 | if status['online'] is not True or status['good'] is not True: 47 | logger.warning("心跳包状态异常,当前状态:%s" % status) 48 | restart_onebot("心跳包状态异常") 49 | 50 | 51 | def check_heartbeat(): 52 | """ 53 | 心跳包检查线程 54 | """ 55 | flag = -1 # 心跳包状态,-1表示正常,其他表示异常 56 | interval = 0.1 # 心跳包检查间隔 57 | has_new_heartbeat = False # 是否有有新的心跳包 58 | _last_heartbeat_time = -1 # 用于检测是否有新的心跳包 59 | while True: 60 | if heartbeat_interval != -1: 61 | interval = heartbeat_interval / 4 62 | 63 | # 检查是否有新的心跳包 64 | if _last_heartbeat_time != last_heartbeat_time: 65 | has_new_heartbeat = True 66 | _last_heartbeat_time = last_heartbeat_time 67 | 68 | # 检查心跳包是否超时 69 | if time.time() - last_heartbeat_time > heartbeat_interval * 2: 70 | if flag == -1: 71 | logger.warning("心跳包超时!请检查 Onebot 实现端是否正常运行!") 72 | restart_onebot("心跳包超时") 73 | flag = 3 74 | elif flag > 0 and has_new_heartbeat: 75 | flag -= 1 76 | has_new_heartbeat = False 77 | elif flag == 0: 78 | logger.info("心跳包间隔已恢复正常") 79 | flag = -1 80 | 81 | time.sleep(interval) 82 | -------------------------------------------------------------------------------- /Lib/utils/EventClassifier.py: -------------------------------------------------------------------------------- 1 | """ 2 | 事件分发器 3 | """ 4 | 5 | from typing import TypedDict, NotRequired, Literal 6 | 7 | from ..core import EventManager, ListenerServer 8 | from . import QQRichText, QQDataCacher, Logger 9 | 10 | logger = Logger.get_logger() 11 | 12 | 13 | class Event(EventManager.Event): 14 | """ 15 | 事件类 16 | """ 17 | 18 | def __init__(self, event_data): 19 | self.event_data: dict = event_data 20 | self.time: int = self["time"] 21 | self.self_id: int = self["self_id"] 22 | self.post_type: str = self["post_type"] 23 | 24 | def __getitem__(self, item): 25 | if item not in self.event_data: 26 | raise KeyError(f"{item} not in {self.event_data}") 27 | return self.event_data.get(item) 28 | 29 | def get(self, key, default=None): 30 | """ 31 | 获取事件数据 32 | Args: 33 | key: 键 34 | default: 默认值 35 | Returns: 36 | None 37 | """ 38 | return self.event_data.get(key, default) 39 | 40 | def __contains__(self, other): 41 | return other in self.event_data 42 | 43 | def __repr__(self): 44 | return str(self.event_data) 45 | 46 | def logger(self): 47 | """ 48 | 发送事件日志 49 | """ 50 | return False 51 | 52 | 53 | class EventData(TypedDict): 54 | """ 55 | 事件数据 56 | """ 57 | cls: Event 58 | post_type: str 59 | rules: dict 60 | 61 | 62 | events: list[EventData] = [] 63 | 64 | 65 | def register_event(post_type: str, **other_rules): 66 | """ 67 | 注册事件 68 | Args: 69 | post_type: 事件类型 70 | other_rules: 其他规则 71 | Returns: 72 | None 73 | """ 74 | 75 | def decorator(cls): 76 | """ 77 | Args: 78 | @param cls: 79 | Returns: 80 | None 81 | """ 82 | data: EventData = { 83 | "cls": cls, 84 | "post_type": post_type, 85 | "rules": other_rules 86 | } 87 | events.append(data) 88 | return cls 89 | 90 | return decorator 91 | 92 | 93 | class SenderDict(TypedDict, total=False): 94 | """ 95 | 发送者数据 96 | """ 97 | user_id: NotRequired[int] 98 | nickname: NotRequired[str] 99 | sex: NotRequired[Literal["male", "female", "unknown"]] 100 | age: NotRequired[int] 101 | 102 | 103 | class PrivateDict(TypedDict, total=False): 104 | """ 105 | 私聊发送者数据 106 | """ 107 | user_id: NotRequired[int] 108 | nickname: NotRequired[str] 109 | sex: NotRequired[Literal["male", "female", "unknown"]] 110 | age: NotRequired[int] 111 | 112 | 113 | class GroupSenderDict(TypedDict, total=False): 114 | """ 115 | 群聊发送者数据 116 | """ 117 | user_id: NotRequired[int] 118 | nickname: NotRequired[str] 119 | card: NotRequired[str] 120 | sex: NotRequired[Literal["male", "female", "unknown"]] 121 | age: NotRequired[int] 122 | level: NotRequired[int] 123 | role: NotRequired[Literal["owner", "admin", "member"]] 124 | title: NotRequired[str] 125 | 126 | 127 | # 注册事件类 128 | @register_event("message") 129 | class MessageEvent(Event): 130 | """ 131 | 消息事件 132 | """ 133 | 134 | def __init__(self, event_data): 135 | super().__init__(event_data) 136 | self.message_type = self["message_type"] 137 | self.user_id: int = int(self["user_id"]) 138 | self.sub_type: str = self["sub_type"] 139 | self.message: QQRichText.QQRichText = QQRichText.QQRichText(self["message"]) 140 | self.raw_message: str = self["raw_message"] 141 | self.message_id: int = int(self["message_id"]) 142 | self.sender: SenderDict = self["sender"] 143 | 144 | 145 | @register_event("message", message_type="private") 146 | class PrivateMessageEvent(MessageEvent): 147 | """ 148 | 私聊消息事件 149 | """ 150 | 151 | def __init__(self, event_data): 152 | super().__init__(event_data) 153 | self.sender: PrivateDict = self["sender"] 154 | 155 | def logger(self): 156 | if self.sub_type == "friend": 157 | logger.info( 158 | f"收到来自好友 " 159 | f"{QQDataCacher.get_user_info( 160 | self.user_id, 161 | is_friend=True, 162 | **{k: v for k, v in self.sender.items() if k not in ['user_id']} 163 | ).nickname}" 164 | f"({self.user_id}) " 165 | f"的消息: " 166 | f"{self.message.render()}" 167 | f"({self.message_id})" 168 | ) 169 | return None 170 | 171 | elif self.sub_type == "group": 172 | logger.info( 173 | f"收到来自群 " 174 | f"{QQDataCacher.get_group_info(self.get('group_id')).group_name}" 175 | f"({self.get('group_id')})" 176 | f" 内成员 " 177 | f"{QQDataCacher.get_group_member_info( 178 | self.get('group_id'), self.user_id, 179 | **{k: v for k, v in self.sender.items() if k not in ['group_id', 'user_id']} 180 | ).get_nickname()}" 181 | f"({self.user_id}) " 182 | f"的群临时会话消息: " 183 | f"{self.message.render()}" 184 | f"({self.message_id})" 185 | ) 186 | return None 187 | 188 | elif self.sub_type == "other": 189 | logger.info( 190 | f"收到来自 " 191 | f"{QQDataCacher.get_user_info( 192 | self.user_id, 193 | **{k: v for k, v in self.sender.items() if k not in ['user_id']} 194 | ).nickname}" 195 | f"({self.user_id}) " 196 | f"的消息: " 197 | f"{self.message.render()}" 198 | f"({self.message_id})" 199 | ) 200 | return None 201 | 202 | else: 203 | return super().logger() 204 | 205 | 206 | @register_event("message", message_type="group") 207 | class GroupMessageEvent(MessageEvent): 208 | """ 209 | 群聊消息事件 210 | """ 211 | 212 | def __init__(self, event_data): 213 | super().__init__(event_data) 214 | self.group_id: int = int(self["group_id"]) 215 | self.sender: GroupSenderDict = self["sender"] 216 | 217 | def logger(self): 218 | if self.sub_type == "normal": 219 | logger.info( 220 | f"收到来自群 " 221 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 222 | f"({self.group_id})" 223 | f" 内成员 " 224 | f"{QQDataCacher.get_group_member_info( 225 | self.group_id, self.user_id, 226 | **{k: v for k, v in self.sender.items() 227 | if k not in ['group_id', 'user_id']}).get_nickname()}" 228 | f"({self.user_id}) " 229 | f"的消息: " 230 | f"{self.message.render(group_id=self.group_id)}" 231 | f"({self.message_id})" 232 | ) 233 | return None 234 | 235 | elif self.sub_type == "anonymous": 236 | anonymous_data = self.get('anonymous', {}) 237 | anonymous_str = f"{QQDataCacher.get_user_info(anonymous_data['id']).nickname}"\ 238 | if anonymous_data else "匿名用户" 239 | anonymous_detail = f"({anonymous_data['id']}; flag: {anonymous_data['flag']})" if anonymous_data else "" 240 | logger.info( 241 | f"收到来自群 " 242 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 243 | f"({self.group_id})" 244 | f" 内 {anonymous_str}{anonymous_detail} " 245 | f"的匿名消息: " 246 | f"{self.message.render(group_id=self.group_id)}" 247 | f"({self.message_id})" 248 | ) 249 | return None 250 | 251 | elif self.sub_type == "notice": 252 | logger.info( 253 | f"收到来自群 " 254 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 255 | f"({self.group_id}) " 256 | f"内的系统消息: " 257 | f"{self.message.render(group_id=self.group_id)}" 258 | f"({self.message_id})" 259 | ) 260 | return None 261 | 262 | else: 263 | return super().logger() 264 | 265 | 266 | @register_event("notice") 267 | class NoticeEvent(Event): 268 | """ 269 | 通知事件 270 | """ 271 | 272 | def __init__(self, event_data): 273 | super().__init__(event_data) 274 | self.notice_type: str = self["notice_type"] 275 | 276 | 277 | class FileDict(TypedDict, total=False): 278 | """ 279 | 文件数据 280 | """ 281 | id: str 282 | name: str 283 | size: int 284 | busid: int 285 | 286 | 287 | @register_event("notice", notice_type="group_upload") 288 | class GroupUploadEvent(NoticeEvent): 289 | """ 290 | 群文件上传事件 291 | """ 292 | 293 | def __init__(self, event_data): 294 | super().__init__(event_data) 295 | self.group_id: int = int(self["group_id"]) 296 | self.user_id: int = int(self["user_id"]) 297 | self.file: FileDict = self["file"] 298 | 299 | def logger(self): 300 | logger.info( 301 | f"群 " 302 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 303 | f"({self.group_id}) " 304 | f"内成员 " 305 | f"{QQDataCacher.get_group_member_info(self.group_id, self.user_id).get_nickname()} " 306 | f"({self.user_id}) " 307 | f"上传了文件: " 308 | f"{self.file['name']}" 309 | f"({self.file['id']})" 310 | ) 311 | 312 | 313 | @register_event("notice", notice_type="group_admin") 314 | class GroupAdminEvent(NoticeEvent): 315 | """ 316 | 群管理员变动事件 317 | """ 318 | 319 | def __init__(self, event_data): 320 | super().__init__(event_data) 321 | self.group_id: int = int(self["group_id"]) 322 | self.user_id: int = int(self["user_id"]) 323 | self.sub_type: str = self["sub_type"] 324 | 325 | 326 | @register_event("notice", notice_type="group_admin", sub_type="set") 327 | class GroupSetAdminEvent(GroupAdminEvent): 328 | """ 329 | 群管理员被设置事件 330 | """ 331 | 332 | def logger(self): 333 | logger.info( 334 | f"群 " 335 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 336 | f"({self.group_id}) " 337 | f"内 成员 " 338 | f"{QQDataCacher.get_group_member_info(self.group_id, self.user_id).get_nickname()}" 339 | f"({self.user_id}) " 340 | f"被设置为管理员" 341 | ) 342 | 343 | 344 | @register_event("notice", notice_type="group_admin", sub_type="unset") 345 | class GroupUnsetAdminEvent(GroupAdminEvent): 346 | """ 347 | 群管理员被取消事件 348 | """ 349 | 350 | def logger(self): 351 | logger.info( 352 | f"群 " 353 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 354 | f"({self.group_id}) " 355 | f"内 成员 " 356 | f"{QQDataCacher.get_group_member_info(self.group_id, self.user_id).get_nickname()}" 357 | f"({self.user_id}) " 358 | f"被取消管理员" 359 | ) 360 | 361 | 362 | @register_event("notice", notice_type="group_decrease") 363 | class GroupDecreaseEvent(NoticeEvent): 364 | """ 365 | 群成员减少事件 366 | """ 367 | 368 | def __init__(self, event_data): 369 | super().__init__(event_data) 370 | self.group_id: int = int(self["group_id"]) 371 | self.user_id: int = int(self["user_id"]) 372 | self.operator_id = int(self["operator_id"]) 373 | self.sub_type: str = self["sub_type"] 374 | 375 | 376 | @register_event("notice", notice_type="group_decrease", sub_type="leave") 377 | class GroupDecreaseLeaveEvent(GroupDecreaseEvent): 378 | """ 379 | 群成员离开事件 380 | """ 381 | 382 | def logger(self): 383 | logger.info( 384 | f"群 " 385 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 386 | f"({self.group_id}) " 387 | f"内成员 " 388 | f"{QQDataCacher.get_user_info(self.user_id).get_nickname()}" 389 | f"({self.user_id}) " 390 | f"退出了群聊" 391 | ) 392 | 393 | 394 | @register_event("notice", notice_type="group_decrease", sub_type="kick") 395 | class GroupDecreaseKickEvent(GroupDecreaseEvent): 396 | """ 397 | 群成员被踢事件 398 | """ 399 | 400 | def logger(self): 401 | logger.info( 402 | f"群 " 403 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 404 | f"({self.group_id}) " 405 | f"内成员 " 406 | f"{QQDataCacher.get_user_info(self.user_id).get_nickname()}" 407 | f"({self.user_id}) " 408 | f"被管理员 " 409 | f"{QQDataCacher.get_group_member_info(self.group_id, self.operator_id).get_nickname()}" 410 | f"({self.operator_id}) " 411 | f"踢出了群聊" 412 | ) 413 | 414 | 415 | @register_event("notice", notice_type="group_decrease", sub_type="kick_me") 416 | class GroupDecreaseKickMeEvent(GroupDecreaseEvent): 417 | """ 418 | 机器人自己被移出事件 419 | """ 420 | 421 | def logger(self): 422 | logger.info( 423 | f"群 " 424 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 425 | f"({self.group_id}) " 426 | f"内 " 427 | f"{QQDataCacher.get_group_member_info(self.group_id, self.operator_id).get_nickname()}" 428 | f"({self.operator_id}) " 429 | f"将机器人踢出了群聊" 430 | ) 431 | 432 | 433 | @register_event("notice", notice_type="group_increase") 434 | class GroupIncreaseEvent(NoticeEvent): 435 | """ 436 | 群成员增加事件 437 | """ 438 | 439 | def __init__(self, event_data): 440 | super().__init__(event_data) 441 | self.group_id: int = int(self["group_id"]) 442 | self.user_id: int = int(self["user_id"]) 443 | self.operator_id: int = int(self["operator_id"]) 444 | self.sub_type: str = self["sub_type"] 445 | 446 | 447 | @register_event("notice", notice_type="group_increase", sub_type="approve") 448 | class GroupIncreaseApproveEvent(GroupIncreaseEvent): 449 | """ 450 | 群成员同意入群事件 451 | """ 452 | 453 | def logger(self): 454 | logger.info( 455 | f"群 " 456 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 457 | f"({self.group_id}) " 458 | f"内管理员 " 459 | f"{QQDataCacher.get_group_member_info(self.group_id, self.operator_id).get_nickname()}" 460 | f"({self.operator_id}) " 461 | f"将 " 462 | f"{QQDataCacher.get_user_info(self.user_id).get_nickname()}" 463 | f"({self.user_id}) " 464 | f"批准入群" 465 | ) 466 | 467 | 468 | @register_event("notice", notice_type="group_increase", sub_type="invite") 469 | class GroupIncreaseInviteEvent(GroupIncreaseEvent): 470 | """ 471 | 群成员被邀请入群事件 472 | """ 473 | 474 | def logger(self): 475 | logger.info( 476 | f"群 " 477 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 478 | f"({self.group_id}) " 479 | f"内成员 " 480 | f"{QQDataCacher.get_group_member_info(self.group_id, self.operator_id).get_nickname()}" 481 | f"({self.user_id}) " 482 | f"将 " 483 | f"{QQDataCacher.get_user_info(self.user_id).get_nickname()}" 484 | f"({self.operator_id}) " 485 | f"邀请入群" 486 | ) 487 | 488 | 489 | @register_event("notice", notice_type="group_ban") 490 | class GroupBanEvent(NoticeEvent): 491 | """ 492 | 群禁言事件 493 | """ 494 | 495 | def __init__(self, event_data): 496 | super().__init__(event_data) 497 | self.group_id: int = int(self["group_id"]) 498 | self.user_id: int = int(self["user_id"]) 499 | self.operator_id: int = int(self["operator_id"]) 500 | self.sub_type: str = self["sub_type"] 501 | self.duration: int = int(self["duration"]) 502 | 503 | 504 | @register_event("notice", notice_type="group_ban", sub_type="ban") 505 | class GroupBanSetEvent(GroupBanEvent): 506 | """ 507 | 群禁言被设置事件 508 | """ 509 | 510 | def logger(self): 511 | logger.info( 512 | f"群 " 513 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 514 | f"({self.group_id}) " 515 | f"内成员 " 516 | f"{QQDataCacher.get_group_member_info(self.group_id, self.user_id).get_nickname()}" 517 | f"({self.user_id}) " 518 | f"被管理员 " 519 | f"{QQDataCacher.get_group_member_info(self.group_id, self.operator_id).get_nickname()}" 520 | f"({self.operator_id}) " 521 | f"禁言了: " 522 | f"{self.duration}s" 523 | ) 524 | 525 | 526 | @register_event("notice", notice_type="group_ban", sub_type="lift_ban") 527 | class GroupBanLiftEvent(GroupBanEvent): 528 | """ 529 | 群禁言被解除事件 530 | """ 531 | 532 | def logger(self): 533 | logger.info( 534 | f"群 " 535 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 536 | f"({self.group_id}) " 537 | f"内成员 " 538 | f"{QQDataCacher.get_group_member_info(self.group_id, self.user_id).get_nickname()}" 539 | f"({self.user_id}) " 540 | f"被管理员 " 541 | f"{QQDataCacher.get_group_member_info(self.group_id, self.operator_id).get_nickname()}" 542 | f"({self.operator_id}) " 543 | f"解除了禁言" 544 | ) 545 | 546 | 547 | @register_event("notice", notice_type="friend_add") 548 | class FriendAddEvent(NoticeEvent): 549 | """ 550 | 好友添加事件 551 | """ 552 | 553 | def __init__(self, event_data): 554 | super().__init__(event_data) 555 | self.user_id: int = int(self["user_id"]) 556 | 557 | def logger(self): 558 | logger.info( 559 | f"好友 " 560 | f"{QQDataCacher.get_user_info(self.user_id).get_nickname()}" 561 | f"({self.user_id}) " 562 | f"添加了机器人的好友" 563 | ) 564 | 565 | 566 | @register_event("notice", notice_type="group_recall") 567 | class GroupRecallEvent(NoticeEvent): 568 | """ 569 | 群消息撤回事件 570 | """ 571 | 572 | def __init__(self, event_data): 573 | super().__init__(event_data) 574 | self.group_id: int = int(self["group_id"]) 575 | self.user_id: int = int(self["user_id"]) 576 | self.operator_id: int = int(self["operator_id"]) 577 | self.message_id: int = int(self["message_id"]) 578 | 579 | def logger(self): 580 | if self.user_id == self.operator_id: 581 | logger.info( 582 | f"群 " 583 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 584 | f"({self.group_id}) " 585 | f"内成员 " 586 | f"{QQDataCacher.get_group_member_info(self.group_id, self.user_id).get_nickname()}" 587 | f"({self.user_id}) " 588 | f"撤回了消息: " 589 | f"{self.message_id}" 590 | ) 591 | else: 592 | logger.info( 593 | f"群 " 594 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 595 | f"({self.group_id}) " 596 | f"内成员 " 597 | f"{QQDataCacher.get_group_member_info(self.group_id, self.user_id).get_nickname()}" 598 | f"({self.user_id}) " 599 | f"被管理员 " 600 | f"{QQDataCacher.get_group_member_info(self.group_id, self.operator_id).get_nickname()}" 601 | f"({self.operator_id}) " 602 | f"撤回了消息: " 603 | f"{self.message_id}" 604 | ) 605 | 606 | 607 | @register_event("notice", notice_type="friend_recall") 608 | class FriendRecallEvent(NoticeEvent): 609 | """ 610 | 好友消息撤回事件 611 | """ 612 | 613 | def __init__(self, event_data): 614 | super().__init__(event_data) 615 | self.user_id: int = int(self["user_id"]) 616 | self.message_id: int = int(self["message_id"]) 617 | 618 | def logger(self): 619 | logger.info( 620 | f"好友 " 621 | f"{QQDataCacher.get_user_info(self.user_id).get_nickname()}" 622 | f"({self.user_id}) " 623 | f"撤回了消息: " 624 | f"{self.message_id}" 625 | ) 626 | 627 | 628 | @register_event("notice", notice_type="notify", sub_type="poke") 629 | class GroupPokeEvent(NoticeEvent): 630 | """ 631 | 群戳一戳事件 632 | """ 633 | 634 | def __init__(self, event_data): 635 | super().__init__(event_data) 636 | self.group_id: int = int(self["group_id"]) 637 | self.user_id: int = int(self["user_id"]) 638 | self.target_id: int = int(self["target_id"]) 639 | 640 | def logger(self): 641 | logger.info( 642 | f"群 " 643 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 644 | f"({self.group_id}) " 645 | f"内 " 646 | f"{QQDataCacher.get_group_member_info(self.group_id, self.user_id).get_nickname()}" # user_id is the poker 647 | f"({self.user_id}) " 648 | f"戳了戳 " 649 | f"{QQDataCacher.get_group_member_info(self.group_id, self.target_id).get_nickname()}" # target_id is pokered 650 | f"({self.target_id})" 651 | ) 652 | 653 | 654 | @register_event("notice", notice_type="notify", sub_type="lucky_king") 655 | class GroupLuckyKingEvent(NoticeEvent): 656 | """ 657 | 群红包运气王事件 658 | """ 659 | 660 | def __init__(self, event_data): 661 | super().__init__(event_data) 662 | self.group_id: int = int(self["group_id"]) 663 | self.user_id: int = int(self["user_id"]) 664 | self.target_id: int = int(self["target_id"]) 665 | 666 | def logger(self): 667 | logger.info( 668 | f"群 " 669 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 670 | f"({self.group_id}) " 671 | f"内 " 672 | f"{QQDataCacher.get_group_member_info(self.group_id, self.user_id).get_nickname()}" # user_id is lucky king 673 | f"({self.user_id}) " 674 | f"成为了 " 675 | f"{QQDataCacher.get_group_member_info(self.group_id, self.target_id).get_nickname()}" # target_id is sender 676 | f"({self.target_id}) " 677 | f"发送的红包的运气王" 678 | ) 679 | 680 | 681 | @register_event("notice", notice_type="notify", sub_type="honor") 682 | class GroupHonorEvent(NoticeEvent): 683 | """ 684 | 群荣誉变更事件 685 | """ 686 | 687 | def __init__(self, event_data): 688 | super().__init__(event_data) 689 | self.group_id: int = int(self["group_id"]) 690 | self.user_id: int = int(self["user_id"]) 691 | self.honor_type: str = self["honor_type"] 692 | 693 | def logger(self): 694 | if self.honor_type not in ["talkative", "performer", "emotion"]: 695 | logger.info( 696 | f"群 " 697 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 698 | f"({self.group_id}) " 699 | f"内 " 700 | f"{QQDataCacher.get_group_member_info(self.group_id, self.user_id).get_nickname()}" 701 | f"({self.user_id}) " 702 | f"获得了未知荣誉: " 703 | f"{self.honor_type}" 704 | ) 705 | else: 706 | super().logger() 707 | 708 | 709 | @register_event("notice", notice_type="notify", sub_type="honor", honor_type="talkative") 710 | class GroupTalkativeHonorEvent(GroupHonorEvent): 711 | """ 712 | 群龙王变更事件 713 | """ 714 | 715 | def logger(self): 716 | logger.info( 717 | f"群 " 718 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 719 | f"({self.group_id}) " 720 | f"内 " 721 | f"{QQDataCacher.get_group_member_info(self.group_id, self.user_id).get_nickname()}" 722 | f"({self.user_id}) " 723 | f"获得了群龙王称号" 724 | ) 725 | 726 | 727 | @register_event("notice", notice_type="notify", sub_type="honor", honor_type="performer") 728 | class GroupPerformerHonorEvent(GroupHonorEvent): 729 | """ 730 | 群群聊之火变更事件 731 | """ 732 | 733 | def logger(self): 734 | logger.info( 735 | f"群 " 736 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 737 | f"({self.group_id}) " 738 | f"内 " 739 | f"{QQDataCacher.get_group_member_info(self.group_id, self.user_id).get_nickname()}" 740 | f"({self.user_id}) " 741 | f"获得了群聊炽焰称号" 742 | ) 743 | 744 | 745 | @register_event("notice", notice_type="notify", sub_type="honor", honor_type="emotion") 746 | class GroupEmotionHonorEvent(GroupHonorEvent): 747 | """ 748 | 群表快乐源泉变更事件 749 | """ 750 | 751 | def logger(self): 752 | logger.info( 753 | f"群 " 754 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 755 | f"({self.group_id}) " 756 | f"内 " 757 | f"{QQDataCacher.get_group_member_info(self.group_id, self.user_id).get_nickname()}" 758 | f"({self.user_id}) " 759 | f"获得了快乐源泉称号" 760 | ) 761 | 762 | 763 | @register_event("request") 764 | class RequestEvent(Event): 765 | """ 766 | 请求事件 767 | """ 768 | 769 | def __init__(self, event_data): 770 | super().__init__(event_data) 771 | self.request_type: str = self["request_type"] 772 | self.comment: str = self["comment"] 773 | self.flag: str = self["flag"] 774 | 775 | 776 | @register_event("request", request_type="friend") 777 | class FriendRequestEvent(RequestEvent): 778 | """ 779 | 加好友请求事件 780 | """ 781 | 782 | def __init__(self, event_data): 783 | super().__init__(event_data) 784 | self.user_id: int = int(self["user_id"]) 785 | 786 | def logger(self): 787 | logger.info( 788 | f"{QQDataCacher.get_user_info(self.user_id).get_nickname()}" 789 | f"({self.user_id})" 790 | f"请求添加机器人为好友\n" 791 | f"验证信息: {self.comment}\n" 792 | f"flag: {self.flag}" 793 | ) 794 | 795 | 796 | @register_event("request", request_type="group") 797 | class GroupRequestEvent(RequestEvent): 798 | """ 799 | 加群请求事件 800 | """ 801 | 802 | def __init__(self, event_data): 803 | super().__init__(event_data) 804 | self.sub_type: str = self["sub_type"] 805 | self.group_id: int = int(self["group_id"]) 806 | self.user_id: int = int(self["user_id"]) 807 | 808 | 809 | @register_event("request", request_type="group", sub_type="add") 810 | class GroupAddRequestEvent(GroupRequestEvent): 811 | """ 812 | 加群请求事件 - 添加 813 | """ 814 | 815 | def logger(self): 816 | logger.info( 817 | f"{QQDataCacher.get_user_info(self.user_id).get_nickname()}" 818 | f"({self.user_id})" 819 | f"请求加入群 " 820 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 821 | f"({self.group_id})\n" 822 | f"验证信息: {self.comment}\n" 823 | f"flag: {self.flag}" 824 | ) 825 | 826 | 827 | @register_event("request", request_type="group", sub_type="invite") 828 | class GroupInviteRequestEvent(GroupRequestEvent): 829 | """ 830 | 加群请求事件 - 邀请 831 | """ 832 | 833 | def logger(self): 834 | logger.info( 835 | f"{QQDataCacher.get_group_member_info(self.group_id, self.user_id).get_nickname()}" 836 | f"({self.user_id})" 837 | f"邀请机器人加入群 " 838 | f"{QQDataCacher.get_group_info(self.group_id).group_name}" 839 | ) 840 | 841 | 842 | @register_event("meta_event") 843 | class MetaEvent(Event): 844 | """ 845 | 元事件 846 | """ 847 | 848 | def __init__(self, event_data): 849 | super().__init__(event_data) 850 | self.meta_event_type: str = self["meta_event_type"] 851 | 852 | 853 | @register_event("meta_event", meta_event_type="lifecycle") 854 | class LifecycleMetaEvent(MetaEvent): 855 | """ 856 | 元事件 - 生命周期 857 | """ 858 | 859 | def __init__(self, event_data): 860 | super().__init__(event_data) 861 | self.sub_type: str = self["sub_type"] 862 | 863 | def logger(self): 864 | logger.info( 865 | f"收到元事件: " + { 866 | "enable": "OneBot 启用", 867 | "disable": "OneBot 禁用", 868 | "connect": "OneBot 连接成功" 869 | }[self.sub_type] 870 | ) 871 | 872 | 873 | @register_event("meta_event", meta_event_type="lifecycle", sub_type="enable") 874 | class EnableMetaEvent(LifecycleMetaEvent): 875 | """ 876 | 元事件 - 生命周期 - OneBot 启用 877 | """ 878 | 879 | def logger(self): 880 | logger.info("收到元事件: OneBot 启用") 881 | 882 | 883 | @register_event("meta_event", meta_event_type="lifecycle", sub_type="disable") 884 | class DisableMetaEvent(LifecycleMetaEvent): 885 | """ 886 | 元事件 - 生命周期 - OneBot 禁用 887 | """ 888 | 889 | def logger(self): 890 | logger.info("收到元事件: OneBot 禁用") 891 | 892 | 893 | @register_event("meta_event", meta_event_type="lifecycle", sub_type="connect") 894 | class ConnectMetaEvent(LifecycleMetaEvent): 895 | """ 896 | 元事件 - 生命周期 - OneBot 连接成功 897 | """ 898 | 899 | def logger(self): 900 | logger.info("收到元事件: OneBot 连接成功") 901 | 902 | 903 | @register_event("meta_event", meta_event_type="heartbeat") 904 | class HeartbeatMetaEvent(MetaEvent): 905 | """ 906 | 元事件 - 心跳 907 | """ 908 | 909 | def __init__(self, event_data): 910 | super().__init__(event_data) 911 | self.status: dict = self["status"] 912 | self.interval: int = int(self["interval"]) 913 | 914 | def logger(self): 915 | logger.debug(f"收到心跳包") 916 | 917 | 918 | @EventManager.event_listener(ListenerServer.EscalationEvent) 919 | def on_escalation(event_data): 920 | """ 921 | 事件分发器 922 | Args: 923 | event_data: 事件数据 924 | Returns: 925 | None 926 | """ 927 | event_data = event_data.event_data 928 | event = Event(event_data) 929 | event_call_list = [event] 930 | matched_event = False 931 | for event_cls_data in events: 932 | if ( 933 | event_data["post_type"] == event_cls_data['post_type'] and 934 | all(k in event_data and event_data[k] == v for k, v in event_cls_data['rules'].items()) 935 | ): 936 | event = event_cls_data['cls'](event_data) 937 | if not matched_event: 938 | if event.logger() is not False: 939 | matched_event = True 940 | event_call_list.append(event) 941 | 942 | if not matched_event: 943 | logger.warning(f"未知的上报事件: {event_data}") 944 | 945 | # 广播事件 946 | for event in event_call_list: 947 | event.call() 948 | -------------------------------------------------------------------------------- /Lib/utils/EventHandlers.py: -------------------------------------------------------------------------------- 1 | """ 2 | 事件处理器 3 | """ 4 | import copy 5 | import inspect 6 | from typing import Literal, Callable, Any, Type 7 | 8 | from Lib.common import save_exc_dump 9 | from Lib.core import EventManager, ConfigManager, PluginManager 10 | from Lib.utils import EventClassifier, Logger, QQRichText, StateManager 11 | 12 | logger = Logger.get_logger() 13 | 14 | 15 | class Rule: 16 | """ 17 | Rule基类,请勿直接使用 18 | """ 19 | 20 | def match(self, event_data: EventClassifier.Event): 21 | """ 22 | 匹配事件 23 | Args: 24 | event_data: 事件数据 25 | Returns: 26 | 是否匹配到事件 27 | """ 28 | pass 29 | 30 | def __and__(self, other: "Rule"): 31 | if not isinstance(other, Rule): 32 | raise TypeError("other must be a Rule") 33 | return AllRule(self, other) 34 | 35 | def __or__(self, other: "Rule"): 36 | if not isinstance(other, Rule): 37 | raise TypeError("other must be a Rule") 38 | return AnyRule(self, other) 39 | 40 | 41 | class AnyRule(Rule): 42 | """ 43 | 输入n个rule,若匹配其中任意一个则返回True 44 | """ 45 | 46 | def __init__(self, *rules: Rule): 47 | self.rules = rules 48 | 49 | def match(self, event_data: EventClassifier.Event): 50 | return any(rule.match(event_data) for rule in self.rules) 51 | 52 | 53 | class AllRule(Rule): 54 | """ 55 | 输入n个rule,若匹配所有则返回True 56 | """ 57 | 58 | def __init__(self, *rules: Rule): 59 | self.rules = rules 60 | 61 | def match(self, event_data: EventClassifier.Event): 62 | return all(rule.match(event_data) for rule in self.rules) 63 | 64 | 65 | class KeyValueRule(Rule): 66 | """ 67 | 键值规则 68 | 检测event data中的某个键的值是否满足要求 69 | """ 70 | 71 | def __init__(self, key, value, model: Literal["eq", "ne", "in", "not in", "func"], 72 | func: Callable[[Any, Any], bool] = None): 73 | """ 74 | Args: 75 | key: 键 76 | value: 值 77 | model: 匹配模式(可选: eq, ne, in, not in, func) 78 | func: 函数(仅在 model 为 func 时有效,输入为 (event_data.get(key), value),返回 bool) 79 | """ 80 | self.key = key 81 | self.value = value 82 | self.model = model 83 | if model == "func" and func is None: 84 | raise ValueError("if model is func, func must be a callable") 85 | self.func = func 86 | 87 | def match(self, event_data: EventClassifier.Event): 88 | try: 89 | match self.model: 90 | case "eq": 91 | return event_data.get(self.key) == self.value 92 | case "ne": 93 | return event_data.get(self.key) != self.value 94 | case "in": 95 | return self.value in event_data.get(self.key) 96 | case "not in": 97 | return self.value not in event_data.get(self.key) 98 | case "func": 99 | return self.func(event_data.get(self.key), self.value) 100 | return None 101 | except Exception as e: 102 | if ConfigManager.GlobalConfig().debug.save_dump: 103 | dump_path = save_exc_dump(f"执行匹配事件器时出错 {event_data}") 104 | else: 105 | dump_path = None 106 | logger.error(f"执行匹配事件器时出错 {event_data}: {repr(e)}" 107 | f"{f"\n已保存异常到 {dump_path}" if dump_path else ""}", 108 | exc_info=True) 109 | return False 110 | 111 | 112 | class FuncRule(Rule): 113 | """ 114 | 函数规则 115 | 检测event data是否满足函数 116 | """ 117 | 118 | def __init__(self, func: Callable[[Any], bool]): 119 | """ 120 | Args: 121 | func: 用于检测函数(输入为 event_data, 返回 bool) 122 | """ 123 | self.func = func 124 | 125 | def match(self, event_data: EventClassifier.Event): 126 | try: 127 | return self.func(event_data) 128 | except Exception as e: 129 | if ConfigManager.GlobalConfig().debug.save_dump: 130 | dump_path = save_exc_dump(f"执行匹配事件器时出错 {event_data}") 131 | else: 132 | dump_path = None 133 | 134 | logger.error(f"执行匹配事件器时出错 {event_data}: {repr(e)}" 135 | f"{f"\n已保存异常到 {dump_path}" if dump_path else ""}", 136 | exc_info=True 137 | ) 138 | return False 139 | 140 | 141 | class CommandRule(Rule): 142 | """ 143 | 命令规则 144 | 用于匹配命令 145 | 146 | 默认匹配:命令起始符 + 命令 和 命令起始符 + 别名。 147 | 若消息前带有 @bot 时,可直接匹配 命令本身 和 别名,无需命令起始符。 148 | 149 | 会自动移除消息中的 @bot 和命令起始符,同时会自动将 别名 替换为 命令本身,以简化插件处理逻辑。 150 | """ 151 | 152 | def __init__( 153 | self, 154 | command: str, 155 | aliases: set[str] = None, 156 | command_start: list[str] = None, 157 | reply: bool = False, 158 | no_args: bool = False, 159 | ): 160 | """ 161 | Args: 162 | command: 命令 163 | aliases: 命令别名 164 | command_start: 命令起始符(不填写默认为配置文件中的command_start) 165 | reply: 是否可包含回复(默认否) 166 | no_args: 是否不需要命令参数(即消息只能完全匹配命令,不包含其他的内容) 167 | """ 168 | if aliases is None: 169 | aliases = set() 170 | if command_start is None: 171 | command_start = ConfigManager.GlobalConfig().command.command_start 172 | if any(_ in command and _ for _ in ['[', ']'] + command_start): 173 | raise ValueError("command cannot contain [ or ]") 174 | if command in aliases: 175 | raise ValueError("command cannot be an alias") 176 | 177 | self.command = command 178 | self.aliases = aliases 179 | self.command_start = command_start 180 | self.reply = reply 181 | self.no_args = no_args 182 | 183 | def match(self, event_data: EventClassifier.MessageEvent): 184 | # 检查是否是消息事件 185 | if not isinstance(event_data, EventClassifier.MessageEvent): 186 | logger.warning(f"event {event_data} is not a MessageEvent, cannot match command") 187 | return False 188 | 189 | # 复制一份消息段 190 | segments = copy.deepcopy(event_data.message.rich_array) 191 | 192 | # 初始化是否@了机器人以及回复消息段的变量 193 | is_at = False 194 | reply_segment = None 195 | 196 | # 检查消息是否以回复形式开始 197 | if ( 198 | self.reply and 199 | len(segments) > 0 and 200 | isinstance(segments[0], QQRichText.Reply) 201 | ): 202 | reply_segment = segments[0] 203 | segments = segments[1:] 204 | 205 | # 检查消息是否以@机器人开始 206 | if ( 207 | len(segments) > 0 and 208 | isinstance(segments[0], QQRichText.At) and 209 | str(segments[0].data.get("qq")) == str(event_data.self_id) 210 | ): 211 | segments = segments[1:] 212 | is_at = True 213 | 214 | # 将消息段转换为字符串消息,并去除前导空格 215 | message = str(QQRichText.QQRichText(segments)) 216 | while len(message) > 0 and message[0] == " ": 217 | message = message[1:] 218 | 219 | # 重新将处理后的消息转换为QQRichText对象,并获取其字符串表示 220 | string_message = str(QQRichText.QQRichText(message)) 221 | 222 | # 生成所有可能的命令前缀组合,包括命令起始符和别名 223 | commands = [_ + self.command for _ in self.command_start] 224 | if is_at: 225 | # 如果消息前面有at,则不需要命令起始符 226 | commands += [self.command] + [alias for alias in self.aliases] 227 | 228 | # 添加所有别名的命令前缀组合 229 | commands += [_ + alias for alias in self.aliases for _ in self.command_start] 230 | 231 | if self.no_args: 232 | # 检查消息是否以任何预设命令前缀开始 233 | if any(string_message == _ for _ in commands): 234 | # 移除命令前缀 235 | for start in self.command_start: 236 | if string_message.startswith(start): 237 | string_message = string_message[len(start):] 238 | break 239 | # 替换别名为主命令 240 | for alias in self.aliases: 241 | if string_message == alias: 242 | string_message = self.command + string_message[len(alias):] 243 | break 244 | else: 245 | return False 246 | 247 | else: 248 | # 检查消息是否以任何预设命令前缀开始 249 | if any(string_message.startswith(_) for _ in commands): 250 | # 移除命令前缀 251 | for start in self.command_start: 252 | if string_message.startswith(start): 253 | string_message = string_message[len(start):] 254 | break 255 | # 替换别名为主命令 256 | for alias in self.aliases: 257 | if string_message.startswith(alias): 258 | string_message = self.command + string_message[len(alias):] 259 | break 260 | else: 261 | return False 262 | 263 | # 更新消息对象 264 | message = QQRichText.QQRichText(string_message) 265 | 266 | # 将回复消息段添加到消息段列表中(如果有) 267 | if reply_segment is not None: 268 | message.rich_array.insert(0, reply_segment) 269 | 270 | event_data.message = message 271 | event_data.raw_message = string_message 272 | return True 273 | 274 | 275 | def _to_me(event_data: EventClassifier.MessageEvent): 276 | """ 277 | 判断是否是@自己或是私聊 278 | Args: 279 | event_data: 事件数据 280 | Returns: 281 | 是否是@自己或是私聊 282 | """ 283 | if not isinstance(event_data, EventClassifier.MessageEvent): 284 | logger.warning(f"event {event_data} is not a MessageEvent, cannot match to_me") 285 | return False 286 | if event_data.message_type == "private": 287 | return True 288 | if event_data.message_type == "group": 289 | for rich in event_data.message.rich_array: 290 | if (isinstance(rich, QQRichText.At) and str(rich.data.get("qq")) == 291 | str(ConfigManager.GlobalConfig().account.user_id)): 292 | return True 293 | return False 294 | 295 | 296 | to_me = FuncRule(_to_me) 297 | 298 | 299 | class Matcher: 300 | """ 301 | 事件处理器 302 | """ 303 | 304 | def __init__(self): 305 | self.handlers = [] 306 | 307 | def register_handler(self, priority: int = 0, rules: list[Rule] = None, *args, **kwargs): 308 | """ 309 | 注册事件处理器 310 | 如果注册的处理器返回True,则事件传播将被阻断 311 | Args: 312 | priority: 事件优先级 313 | rules: 匹配规则 314 | """ 315 | if rules is None: 316 | rules = [] 317 | if any(not isinstance(rule, Rule) for rule in rules): 318 | raise TypeError("rules must be a list of Rule") 319 | 320 | def wrapper(func): 321 | self.handlers.append((priority, rules, func, args, kwargs)) 322 | return func 323 | 324 | return wrapper 325 | 326 | def match(self, event_data: EventClassifier.Event, plugin_data: dict): 327 | """ 328 | 匹配事件处理器 329 | Args: 330 | event_data: 事件数据 331 | plugin_data: 插件数据 332 | """ 333 | for priority, rules, handler, args, kwargs in sorted(self.handlers, key=lambda x: x[0], reverse=True): 334 | try: 335 | if not all(rule.match(event_data) for rule in rules): 336 | continue 337 | 338 | # 检测依赖注入 339 | handler_kwargs = kwargs.copy() # 复制静态 kwargs 340 | 341 | sig = inspect.signature(handler) 342 | 343 | for name, param in sig.parameters.items(): 344 | if name == "state": 345 | if isinstance(event_data, EventClassifier.MessageEvent): 346 | if event_data.message_type == "private": 347 | state_id = f"u{event_data.user_id}" 348 | elif event_data.message_type == "group": 349 | state_id = f"g{event_data["group_id"]}_u{event_data.user_id}" 350 | else: 351 | raise TypeError("event_data.message_type must be private or group") 352 | else: 353 | raise TypeError("event_data must be a MessageEvent") 354 | handler_kwargs[name] = StateManager.get_state(state_id, plugin_data) 355 | elif name == "user_state": 356 | if isinstance(event_data, EventClassifier.MessageEvent): 357 | state_id = f"u{event_data.user_id}" 358 | else: 359 | raise TypeError("event_data must be a MessageEvent") 360 | handler_kwargs[name] = StateManager.get_state(state_id, plugin_data) 361 | elif name == "group_state": 362 | if isinstance(event_data, EventClassifier.GroupMessageEvent): 363 | state_id = f"g{event_data.group_id}" 364 | else: 365 | raise TypeError("event_data must be a MessageEvent") 366 | handler_kwargs[name] = StateManager.get_state(state_id, plugin_data) 367 | 368 | result = handler(event_data, *args, **handler_kwargs) 369 | 370 | if result is True: 371 | logger.debug(f"处理器 {handler.__name__} 阻断了事件 {event_data} 的传播") 372 | return # 阻断同一 Matcher 内的传播 373 | except Exception as e: 374 | if ConfigManager.GlobalConfig().debug.save_dump: 375 | dump_path = save_exc_dump(f"执行匹配事件或执行处理器时出错 {event_data}") 376 | else: 377 | dump_path = None 378 | logger.error( 379 | f"执行匹配事件或执行处理器时出错 {event_data}: {repr(e)}" 380 | f"{f"\n已保存异常到 {dump_path}" if dump_path else ""}", 381 | exc_info=True 382 | ) 383 | 384 | 385 | events_matchers: dict[str, dict[Type[EventClassifier.Event], list[tuple[int, list[Rule], Matcher]]]] = {} 386 | 387 | 388 | def _on_event(event_data, path, event_type, plugin_data): 389 | matchers = events_matchers[path][event_type] 390 | for priority, rules, matcher in sorted(matchers, key=lambda x: x[0], reverse=True): 391 | matcher_event_data = event_data.__class__(event_data.event_data) 392 | if all(rule.match(matcher_event_data) for rule in rules): 393 | matcher.match(matcher_event_data, plugin_data) 394 | 395 | 396 | def on_event(event: Type[EventClassifier.Event], priority: int = 0, rules: list[Rule] = None): 397 | """ 398 | 注册事件处理器 399 | Args: 400 | event: 事件类型 401 | priority: 事件优先级 402 | rules: 匹配规则 403 | Returns: 404 | 事件处理器 405 | """ 406 | if rules is None: 407 | rules = [] 408 | if any(not isinstance(rule, Rule) for rule in rules): 409 | raise TypeError("rules must be a list of Rule") 410 | if not issubclass(event, EventClassifier.Event): 411 | raise TypeError("event must be an instance of EventClassifier.Event") 412 | plugin_data = PluginManager.get_caller_plugin_data() 413 | path = plugin_data["path"] 414 | if path not in events_matchers: 415 | events_matchers[path] = {} 416 | if event not in events_matchers[path]: 417 | events_matchers[path][event] = [] 418 | EventManager.event_listener(event, path=path, event_type=event, plugin_data=plugin_data)(_on_event) 419 | events_matcher = Matcher() 420 | events_matchers[path][event].append((priority, rules, events_matcher)) 421 | return events_matcher 422 | -------------------------------------------------------------------------------- /Lib/utils/Logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | 日志记录器 3 | """ 4 | import inspect 5 | import logging 6 | import logging.handlers as handlers 7 | import sys 8 | from ..constants import * 9 | 10 | import coloredlogs 11 | 12 | 13 | logger_instance: logging.Logger = None # 重命名全局变量以区分 14 | FRAMEWORK_LOGGER_NAME = "murainbot" 15 | 16 | 17 | def init(logs_path: str = LOGS_PATH, logger_level: int = logging.INFO): 18 | """ 19 | 初始化日志记录器 20 | Args: 21 | @param logs_path: 22 | @param logger_level: 23 | Returns: 24 | None 25 | """ 26 | global logger_instance 27 | 28 | if logger_instance is not None: 29 | return logger_instance 30 | # 日志颜色 31 | log_colors = { 32 | "DEBUG": "white", 33 | "INFO": "green", 34 | "WARNING": "yellow", 35 | "ERROR": "red", 36 | "CRITICAL": "bold_red", 37 | } 38 | log_field_styles = { 39 | "asctime": {"color": "green"}, 40 | "hostname": {"color": "magenta"}, 41 | "levelname": {"color": "white"} 42 | } 43 | # 日志格式 44 | fmt = "[%(asctime)s] [%(name)s] [%(levelname)s]: %(message)s" 45 | # 设置日志 46 | coloredlogs.install(isatty=True, stream=sys.stdout, field_styles=log_field_styles, fmt=fmt, colors=log_colors) 47 | 48 | # 设置文件日志 49 | logger_instance = logging.getLogger() 50 | 51 | logger_instance.setLevel(logger_level) 52 | coloredlogs.set_level(logger_level) 53 | 54 | log_name = "latest.log" 55 | log_path = os.path.join(logs_path, log_name) 56 | # 如果指定路径不存在,则尝试创建路径 57 | if not os.path.exists(logs_path): 58 | os.makedirs(logs_path) 59 | 60 | def namer(filename): 61 | """ 62 | 生成文件名 63 | Args: 64 | filename: 文件名 65 | Returns: 66 | 文件名 67 | """ 68 | dir_name, base_name = os.path.split(filename) 69 | base_name = base_name.replace(log_name + '.', "") 70 | rotation_filename = os.path.join(dir_name, base_name) 71 | return rotation_filename 72 | 73 | file_handler = handlers.TimedRotatingFileHandler(log_path, when="MIDNIGHT", encoding="utf-8") 74 | file_handler.namer = namer 75 | file_handler.suffix = "%Y-%m-%d.log" 76 | file_handler.setFormatter(logging.Formatter(fmt)) 77 | logger_instance.addHandler(file_handler) 78 | return logger_instance 79 | 80 | 81 | def set_logger_level(level: int): 82 | """ 83 | 设置日志级别 84 | Args: 85 | level: 日志级别 86 | Returns: 87 | None 88 | """ 89 | global logger_instance 90 | logger_instance.setLevel(level) 91 | coloredlogs.set_level(level) 92 | 93 | 94 | def get_logger(name: str | None = None): 95 | """ 96 | 获取日志记录器 97 | Returns: 98 | Logger 99 | """ 100 | 101 | if name is None: 102 | try: 103 | frame = inspect.currentframe().f_back 104 | # 从栈帧的全局变量中获取 __name__ 105 | module_name = frame.f_globals.get('__name__') 106 | 107 | if module_name and isinstance(module_name, str): 108 | if module_name == "__main__": 109 | logger_name = FRAMEWORK_LOGGER_NAME 110 | elif module_name.startswith("Lib"): 111 | logger_name = FRAMEWORK_LOGGER_NAME + module_name[3:] 112 | elif module_name.startswith("plugins"): 113 | logger_name = FRAMEWORK_LOGGER_NAME + "." + module_name 114 | else: 115 | logger_name = module_name 116 | else: 117 | logger_name = FRAMEWORK_LOGGER_NAME 118 | except Exception: 119 | logger_name = FRAMEWORK_LOGGER_NAME 120 | elif isinstance(name, str): 121 | logger_name = f"{FRAMEWORK_LOGGER_NAME}.{name}" 122 | else: 123 | logger_name = FRAMEWORK_LOGGER_NAME 124 | 125 | if not logger_instance: 126 | init() 127 | 128 | return logging.getLogger(logger_name) 129 | -------------------------------------------------------------------------------- /Lib/utils/PluginConfig.py: -------------------------------------------------------------------------------- 1 | """ 2 | 插件配置管理 3 | """ 4 | 5 | import inspect 6 | 7 | from Lib.core import ConfigManager, PluginManager 8 | from Lib.constants import * 9 | 10 | 11 | class PluginConfig(ConfigManager.ConfigManager): 12 | """ 13 | 插件配置管理 14 | """ 15 | def __init__( 16 | self, 17 | plugin_name: str = None, 18 | default_config: str | dict = None 19 | ): 20 | """ 21 | Args: 22 | plugin_name: 插件名称,留空自动获取 23 | default_config: 默认配置,选填 24 | """ 25 | if plugin_name is None: 26 | stack = inspect.stack() 27 | stack.reverse() 28 | while stack: 29 | frame, filename, line_number, function_name, lines, index = stack.pop(0) 30 | if filename.startswith(PLUGINS_PATH): 31 | for plugin in PluginManager.found_plugins: 32 | head, tail = os.path.split(plugin["file_path"]) 33 | if head == PLUGINS_PATH: 34 | # 是文件类型的插件 35 | if plugin["file_path"] == filename: 36 | plugin_name = plugin["name"] 37 | else: 38 | # 是库类型的插件 39 | if filename.startswith(os.path.split(plugin["file_path"])[0]): 40 | plugin_name = plugin["name"] 41 | super().__init__(os.path.join(PLUGIN_CONFIGS_PATH, f"{plugin_name}.yml"), default_config) 42 | self.plugin_name = plugin_name 43 | -------------------------------------------------------------------------------- /Lib/utils/QQDataCacher.py: -------------------------------------------------------------------------------- 1 | """ 2 | QQ数据缓存 3 | """ 4 | import time 5 | import threading 6 | 7 | from ..core import OnebotAPI, ConfigManager 8 | from . import Logger 9 | 10 | NotFetched = type("NotFetched", (), {"__getattr__": lambda _, __: NotFetched, 11 | "__repr__": lambda _: "NotFetched", 12 | "__bool__": lambda _: False}) 13 | api = OnebotAPI.api 14 | logger = Logger.get_logger() 15 | 16 | if ConfigManager.GlobalConfig().qq_data_cache.enable: 17 | expire_time = ConfigManager.GlobalConfig().qq_data_cache.expire_time 18 | else: 19 | expire_time = 0 20 | 21 | 22 | class QQDataItem: 23 | """ 24 | QQ数据缓存类 25 | """ 26 | 27 | def __init__(self): 28 | self._data = NotFetched # 数据 29 | self.last_update = time.time() # 最后刷新时间 30 | self.last_use = -1 # 最后被使用时间(数据被使用) 31 | 32 | def refresh_cache(self): 33 | """ 34 | 刷新缓存 35 | Returns: 36 | None 37 | """ 38 | self.last_update = time.time() 39 | 40 | 41 | class UserData(QQDataItem): 42 | """ 43 | QQ用户数据缓存类 44 | """ 45 | 46 | def __init__( 47 | self, 48 | user_id: int, 49 | nickname: str = NotFetched, 50 | sex: str = NotFetched, 51 | age: int = NotFetched, 52 | is_friend: bool = NotFetched, 53 | remark: str | None = NotFetched # 此值仅在是好友的时候会存在 54 | ): 55 | super().__init__() 56 | self._user_id = user_id 57 | self._data = { 58 | "user_id": user_id, 59 | "nickname": nickname, 60 | "sex": sex, 61 | "age": age, 62 | "is_friend": is_friend, 63 | "remark": remark 64 | } 65 | 66 | def refresh_cache(self): 67 | """ 68 | 刷新缓存 69 | Returns: 70 | None 71 | """ 72 | if int(self._user_id) <= 0: 73 | logger.warn(f"获取用户{self._user_id}缓存信息失败: user_id小于等于0") 74 | return 75 | try: 76 | data = api.get_stranger_info(self._user_id) 77 | for k in data: 78 | self._data[k] = data[k] 79 | self._data["is_friend"] = NotFetched 80 | self._data["remark"] = NotFetched 81 | except Exception as e: 82 | logger.warn(f"获取用户{self._user_id}缓存信息失败: {repr(e)}") 83 | return 84 | 85 | def __getattr__(self, item): 86 | if item == "_data" or item == "data": 87 | return self._data 88 | 89 | if item in ["remark", "is_friend"] and self._data.get(item) != NotFetched: 90 | try: 91 | res = api.get_friend_list() 92 | for friend in res: 93 | if friend["user_id"] == self._user_id: 94 | self._data["remark"] = friend["remark"] 95 | self._data["is_friend"] = True 96 | break 97 | else: 98 | self._data["is_friend"] = False 99 | self._data["remark"] = None 100 | except Exception as e: 101 | logger.warn(f"获取用户{self._user_id}是否为好友失败: {repr(e)}") 102 | return None 103 | 104 | if self._data.get(item) == NotFetched or time.time() - self.last_update > expire_time: 105 | self.refresh_cache() 106 | 107 | if self._data.get(item) == NotFetched: 108 | return None 109 | 110 | if item in self._data: 111 | self.last_use = time.time() 112 | 113 | return self._data.get(item) 114 | 115 | def get_nickname(self) -> str: 116 | """ 117 | 获取昵称(如果有备注名优先返回备注名) 118 | Returns: 119 | 昵称 120 | """ 121 | return self.remark or self.nickname 122 | 123 | def __repr__(self): 124 | return f"UserData(user_id={self._user_id})" 125 | 126 | 127 | class GroupMemberData(QQDataItem): 128 | """ 129 | QQ群成员数据缓存类 130 | """ 131 | 132 | def __init__( 133 | self, 134 | group_id: int, 135 | user_id: int, 136 | nickname: str = NotFetched, 137 | card: str = NotFetched, 138 | sex: str = NotFetched, 139 | age: int = NotFetched, 140 | area: str = NotFetched, 141 | join_time: int = NotFetched, 142 | last_sent_time: int = NotFetched, 143 | level: str = NotFetched, 144 | role: str = NotFetched, 145 | unfriendly: bool = NotFetched, 146 | title: str = NotFetched, 147 | title_expire_time: int = NotFetched, 148 | card_changeable: bool = NotFetched, 149 | ): 150 | super().__init__() 151 | self._group_id = group_id 152 | self._user_id = user_id 153 | self._data = { 154 | "group_id": group_id, 155 | "user_id": user_id, 156 | "nickname": nickname, 157 | "card": card, 158 | "sex": sex, 159 | "age": age, 160 | "area": area, 161 | "join_time": join_time, 162 | "last_sent_time": last_sent_time, 163 | "level": level, 164 | "role": role, 165 | "unfriendly": unfriendly, 166 | "title": title, 167 | "title_expire_time": title_expire_time, 168 | "card_changeable": card_changeable, 169 | } 170 | 171 | def refresh_cache(self): 172 | """ 173 | 刷新缓存 174 | Returns: 175 | None 176 | """ 177 | if int(self._group_id) <= 0 or int(self._user_id) <= 0: 178 | logger.warn(f"获取群{self._group_id}中成员{self._user_id}缓存信息失败: group_id或user_id小于等于0") 179 | return 180 | try: 181 | data = api.get_group_member_info(self._group_id, self._user_id, no_cache=True) 182 | for k in data: 183 | self._data[k] = data[k] 184 | except Exception as e: 185 | logger.warn(f"获取群{self._group_id}中成员{self._user_id}缓存信息失败: {repr(e)}") 186 | user_data = get_user_info(self._user_id) 187 | self._data["nickname"] = user_data.nickname if user_data.nickname else NotFetched 188 | self._data["sex"] = user_data.sex if user_data.sex else NotFetched 189 | self._data["age"] = user_data.age if user_data.age else NotFetched 190 | super().refresh_cache() 191 | 192 | def __getattr__(self, item): 193 | if item == "_data" or item == "data": 194 | return self._data 195 | 196 | if self._data.get(item) == NotFetched or time.time() - self.last_update > expire_time: 197 | self.refresh_cache() 198 | 199 | if self._data.get(item) == NotFetched: 200 | return None 201 | 202 | if item in self._data: 203 | self.last_use = time.time() 204 | 205 | return self._data.get(item) 206 | 207 | def __repr__(self): 208 | return f"GroupMemberData(group_id={self._group_id}, user_id={self._user_id})" 209 | 210 | def get_nickname(self): 211 | """ 212 | 获取群名片(如果有群名片优先返回群名片) 213 | Returns: 214 | 群名片 215 | """ 216 | return self.card or self.nickname 217 | 218 | 219 | class GroupData(QQDataItem): 220 | """ 221 | QQ群数据缓存类 222 | """ 223 | 224 | def __init__( 225 | self, 226 | group_id: int, 227 | group_name: str = NotFetched, 228 | member_count: int = NotFetched, 229 | max_member_count: int = NotFetched 230 | ): 231 | super().__init__() 232 | self._group_id = group_id 233 | self._data = { 234 | "group_id": group_id, 235 | "group_name": group_name, 236 | "member_count": member_count, 237 | "max_member_count": max_member_count, 238 | "group_member_list": NotFetched 239 | } 240 | 241 | def refresh_cache(self): 242 | """ 243 | 刷新缓存 244 | Returns: 245 | None 246 | """ 247 | if int(self._group_id) <= 0: 248 | logger.warn(f"获取群{self._group_id}缓存信息失败: group_id小于等于0") 249 | return 250 | try: 251 | data = api.get_group_info(group_id=self._group_id, no_cache=True) 252 | for k in data: 253 | self._data[k] = data[k] 254 | self._data["group_member_list"] = NotFetched 255 | except Exception as e: 256 | logger.warn(f"获取群{self._group_id}缓存信息失败: {repr(e)}") 257 | return 258 | super().refresh_cache() 259 | 260 | def __getattr__(self, item): 261 | if item == "_data" or item == "data": 262 | return self._data 263 | 264 | if item == "group_member_list" and self._data.get(item) == NotFetched: 265 | try: 266 | res = api.get_group_member_list(self._group_id) 267 | member_list = [GroupMemberData(**{k: (v if v is not None else NotFetched) 268 | for k, v in member.items()}) 269 | for member in res] 270 | self._data[item] = member_list 271 | except Exception as e: 272 | logger.warn(f"获取群{self._group_id}成员列表信息失败: {repr(e)}") 273 | return 274 | 275 | if self._data.get(item) == NotFetched or time.time() - self.last_update > expire_time: 276 | self.refresh_cache() 277 | 278 | if self._data.get(item) == NotFetched: 279 | return None 280 | 281 | if item in self._data: 282 | self.last_use = time.time() 283 | 284 | return self._data.get(item) 285 | 286 | def __repr__(self): 287 | return f"GroupData(group_id={self._group_id})" 288 | 289 | 290 | group_info = {} 291 | group_member_info = {} 292 | user_info = {} 293 | 294 | group_info_lock = threading.Lock() 295 | group_member_info_lock = threading.Lock() 296 | user_info_lock = threading.Lock() 297 | 298 | max_cache_size = ConfigManager.GlobalConfig().qq_data_cache.max_cache_size 299 | expire_time = expire_time 300 | 301 | 302 | def get_user_info(user_id: int, *args, **kwargs) -> UserData: 303 | """ 304 | 获取用户信息 305 | Args: 306 | user_id: 用户ID 307 | Returns: 308 | None 309 | """ 310 | with user_info_lock: 311 | if user_id not in user_info: 312 | data = UserData(user_id, *args, **kwargs) 313 | user_info[user_id] = data 314 | 315 | data = user_info[user_id] 316 | return data 317 | 318 | 319 | def get_group_info(group_id: int, *args, **kwargs) -> GroupData: 320 | """ 321 | 获取群信息 322 | Args: 323 | group_id: 群号 324 | Returns: 325 | None 326 | """ 327 | with group_info_lock: 328 | if group_id not in group_info: 329 | data = GroupData(group_id, *args, **kwargs) 330 | group_info[group_id] = data 331 | 332 | data = group_info[group_id] 333 | return data 334 | 335 | 336 | def get_group_member_info(group_id: int, user_id: int, *args, **kwargs) -> GroupMemberData: 337 | """ 338 | 获取群成员信息 339 | Args: 340 | group_id: 群号 341 | user_id: 用户ID 342 | Returns: 343 | None 344 | """ 345 | with group_member_info_lock: 346 | if group_id not in group_member_info: 347 | group_member_info[group_id] = {} 348 | 349 | if user_id not in group_member_info[group_id]: 350 | data = GroupMemberData(group_id, user_id, *args, **kwargs) 351 | group_member_info[group_id][user_id] = data 352 | 353 | data = group_member_info[group_id][user_id] 354 | return data 355 | 356 | 357 | def garbage_collection(): 358 | """ 359 | 垃圾回收 360 | Returns: 361 | None 362 | """ 363 | counter = 0 364 | 365 | with group_member_info_lock: 366 | for k in list(group_member_info.keys()): 367 | group_member_items = list(zip(group_member_info[k].keys(), group_member_info[k].values())) 368 | max_last_use_time = max([item[1].last_use for item in group_member_items]) 369 | 370 | if max_last_use_time < time.time() - expire_time * 2: 371 | del group_member_info[k] 372 | counter += 1 373 | continue 374 | 375 | group_member_items.sort(key=lambda x: x[1].last_use) 376 | if len(group_member_items) > max_cache_size * (2 / 3): 377 | for user_id, _ in group_member_items[:int(max_cache_size * (1 / 3))]: 378 | del group_member_info[k][user_id] 379 | counter += 1 380 | del group_member_items, max_last_use_time 381 | 382 | with group_info_lock: 383 | group_items = list(zip(group_info.keys(), group_info.values())) 384 | group_items.sort(key=lambda x: x[1].last_use) 385 | if len(group_items) > max_cache_size * (2 / 3): 386 | for group_id, _ in group_items[:int(max_cache_size * (1 / 3))]: 387 | del group_info[group_id] 388 | counter += 1 389 | del group_items 390 | 391 | with user_info_lock: 392 | user_items = list(zip(user_info.keys(), user_info.values())) 393 | user_items.sort(key=lambda x: x[1].last_use) 394 | if len(user_items) > max_cache_size * (2 / 3): 395 | for user_id, _ in user_items[:int(max_cache_size * (1 / 3))]: 396 | del user_info[user_id] 397 | counter += 1 398 | del user_items 399 | 400 | return counter 401 | 402 | 403 | def scheduled_garbage_collection(): 404 | """ 405 | 定时垃圾回收 406 | Returns: 407 | None 408 | """ 409 | t = 0 410 | while True: 411 | time.sleep(60) 412 | t += 1 413 | if ( 414 | t > 4 or ( 415 | t > 1 and ( 416 | len(group_info) > max_cache_size or 417 | len(user_info) > max_cache_size or 418 | len(group_member_info) > max_cache_size 419 | ) 420 | ) 421 | ): 422 | t = 0 423 | logger.debug("QQ数据缓存清理开始...") 424 | try: 425 | counter = garbage_collection() 426 | logger.debug(f"QQ数据缓存清理完成,共清理了 {counter} 项信息。") 427 | except Exception as e: 428 | logger.warn(f"QQ数据缓存清理时出现异常: {repr(e)}") 429 | 430 | 431 | # 启动垃圾回收线程 432 | threading.Thread(target=scheduled_garbage_collection, daemon=True).start() 433 | -------------------------------------------------------------------------------- /Lib/utils/QQDataCacher.pyi: -------------------------------------------------------------------------------- 1 | NotFetched = type("NotFetched", (), {"__getattr__": lambda _, __: NotFetched, 2 | "__repr__": lambda _: "NotFetched", 3 | "__bool__": lambda _: False}) 4 | 5 | 6 | class QQDataItem: 7 | def __init__(self): 8 | self._data: dict | NotFetched = NotFetched # 数据 9 | self.last_update: float = None # 最后刷新时间 10 | self.last_use: float = None 11 | 12 | def refresh_cache(self): 13 | self.last_update = None 14 | 15 | class UserData(QQDataItem): 16 | def __init__( 17 | self, 18 | user_id: int, 19 | nickname: str = NotFetched, 20 | sex: str = NotFetched, 21 | age: int = NotFetched, 22 | is_friend: bool = NotFetched, 23 | remark: str | None = NotFetched # 此值仅在是好友的时候会存在 24 | ) -> None: 25 | self._data = None 26 | self._user_id = None 27 | 28 | def refresh_cache(self) -> None: ... 29 | 30 | def get_nickname(self) -> str: ... 31 | 32 | @property 33 | def data(self) -> dict: ... 34 | 35 | @property 36 | def user_id(self) -> int: ... 37 | 38 | @property 39 | def nickname(self) -> str: ... 40 | 41 | @property 42 | def sex(self) -> str: ... 43 | 44 | @property 45 | def age(self) -> int: ... 46 | 47 | @property 48 | def is_friend(self) -> bool: ... 49 | 50 | @property 51 | def remark(self) -> str: ... 52 | 53 | 54 | class GroupMemberData(QQDataItem): 55 | def __init__( 56 | self, 57 | group_id: int, 58 | user_id: int, 59 | nickname: str = NotFetched, 60 | card: str = NotFetched, 61 | sex: str = NotFetched, 62 | age: int = NotFetched, 63 | area: str = NotFetched, 64 | join_time: int = NotFetched, 65 | last_sent_time: int = NotFetched, 66 | level: str = NotFetched, 67 | role: str = NotFetched, 68 | unfriendly: bool = NotFetched, 69 | title: str = NotFetched, 70 | title_expire_time: int = NotFetched, 71 | card_changeable: bool = NotFetched, 72 | ): 73 | self._data = None 74 | self._user_id = None 75 | self._group_id = None 76 | 77 | def refresh_cache(self) -> None: ... 78 | 79 | def get_nickname(self) -> str: ... 80 | 81 | @property 82 | def data(self): ... 83 | 84 | @property 85 | def group_id(self): ... 86 | @property 87 | def user_id(self): ... 88 | 89 | @property 90 | def nickname(self) -> str: ... 91 | 92 | @property 93 | def card(self) -> str: ... 94 | 95 | @property 96 | def sex(self) -> str: ... 97 | 98 | @property 99 | def age(self) -> int: ... 100 | 101 | @property 102 | def area(self) -> str: ... 103 | 104 | @property 105 | def join_time(self) -> int: ... 106 | 107 | @property 108 | def last_sent_time(self) -> int: ... 109 | 110 | @property 111 | def level(self) -> str: ... 112 | 113 | @property 114 | def role(self) -> str: ... 115 | 116 | @property 117 | def unfriendly(self) -> bool: ... 118 | 119 | @property 120 | def title(self) -> str: ... 121 | 122 | @property 123 | def title_expire_time(self) -> int: ... 124 | 125 | @property 126 | def card_changeable(self) -> bool: ... 127 | 128 | 129 | class GroupData(QQDataItem): 130 | def __init__( 131 | self, 132 | group_id: int, 133 | group_name: str = NotFetched, 134 | member_count: int = NotFetched, 135 | max_member_count: int = NotFetched 136 | ) -> None: 137 | super().__init__() 138 | self._group_id = None 139 | self._data = None 140 | 141 | def refresh_cache(self) -> None: ... 142 | 143 | @property 144 | def data(self): ... 145 | 146 | @property 147 | def group_id(self): ... 148 | 149 | @property 150 | def group_name(self) -> str: ... 151 | 152 | @property 153 | def member_count(self) -> int: ... 154 | 155 | @property 156 | def max_member_count(self) -> int: ... 157 | 158 | @property 159 | def group_member_list(self) -> list[GroupMemberData]: ... 160 | 161 | 162 | 163 | group_info: dict = None 164 | group_member_info: dict = None 165 | user_info: dict = None 166 | max_cache_size: int = None 167 | expire_time: int = None 168 | 169 | def get_group_info(group_id: int, *args, **kwargs) -> GroupData: ... 170 | 171 | def get_group_member_info(group_id: int, user_id: int, *args, **kwargs) -> GroupMemberData: ... 172 | 173 | def get_user_info(user_id: int, *args, **kwargs) -> UserData: ... 174 | 175 | def garbage_collection() -> int: ... 176 | 177 | def scheduled_garbage_collection() -> None: ... 178 | -------------------------------------------------------------------------------- /Lib/utils/StateManager.py: -------------------------------------------------------------------------------- 1 | """ 2 | 状态管理器 3 | 注意,数据存储于内存中,重启丢失,需要持久化保存的数据请勿放在里面 4 | """ 5 | 6 | from Lib.core import PluginManager 7 | 8 | states = {} 9 | 10 | 11 | def get_state(state_id: str, plugin_data: dict = None): 12 | """ 13 | 获取状态数据 14 | Args: 15 | state_id: 状态ID 16 | plugin_data: 插件数据 17 | 18 | Returns: 19 | 状态数据,结构类似 20 | { 21 | "state_id": state_id, 22 | "data": { 23 | k1: v1, 24 | k2: v2 25 | }, 26 | "other_plugin_data": { 27 | plugin1_path: { 28 | "data": { 29 | k1: v1, 30 | k2: v2 31 | }, 32 | "meta": { 33 | "plugin_data": plugin_data 34 | } 35 | }, 36 | plugin2_path: { 37 | "data": { 38 | k1: v1, 39 | k2: v2 40 | }, 41 | "meta": { 42 | "plugin_data": plugin_data 43 | } 44 | } 45 | } 46 | } 47 | """ 48 | if plugin_data is None: 49 | plugin_data = PluginManager.get_caller_plugin_data() 50 | 51 | if state_id not in states: 52 | states[state_id] = {} 53 | 54 | if plugin_data["path"] not in states[state_id]: 55 | states[state_id][plugin_data["path"]] = { 56 | "data": {}, 57 | "meta": { 58 | "plugin_data": plugin_data 59 | } 60 | } 61 | 62 | return { 63 | "state_id": state_id, 64 | "data": states[state_id][plugin_data["path"]]["data"], 65 | "other_plugin_data": { 66 | k: v for k, v in states[state_id].items() if k != plugin_data["path"] 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Lib/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | MRB2 Lib 工具模块 3 | """ 4 | from . import Logger 5 | from . import EventClassifier 6 | from . import StateManager 7 | from . import QQRichText 8 | from . import QQDataCacher 9 | from . import Actions 10 | from . import EventHandlers 11 | from . import AutoRestartOnebot 12 | from . import PluginConfig 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | ![MuRainBot2](https://socialify.git.ci/MuRainBot/MuRainBot2/image?custom_description=%E5%9F%BA%E4%BA%8Epython%E9%80%82%E9%85%8Donebot11%E5%8D%8F%E8%AE%AE%E7%9A%84%E8%BD%BB%E9%87%8F%E7%BA%A7OnebotBot%E6%A1%86%E6%9E%B6&description=1&forks=1&issues=1&logo=https%3A%2F%2Fgithub.com%2FMuRainBot%2FMuRainBot2Doc%2Fblob%2Fmaster%2Fdocs%2Fpublic%2Ficon.png%3Fraw%3Dtrue&name=1&pattern=Overlapping+Hexagons&pulls=1&stargazers=1&theme=Auto) 4 | 5 | GitHub license 6 | 7 | 8 | python 9 | 10 | 11 | OneBot v11 12 | 13 |
14 | 15 | visitor counter 16 | 17 |
18 | 19 | ## 🤔 概述 20 | 21 | MuRainBot2 (MRB2) 是一个基于 Python、适配 OneBot v11 协议的轻量级开发框架。 22 | 23 | 它专注于提供稳定高效的核心事件处理与 API 调用能力,所有具体功能(如关键词回复、群管理等)均通过插件实现,赋予开发者高度的灵活性。 24 | 25 | 对于具体的操作(如监听消息、发送消息、批准加群请求等)请先明确: 26 | 27 | - 什么是[OneBot11协议](https://11.onebot.dev); 28 | - 什么是Onebot实现端,什么是Onebot开发框架; 29 | - Onebot实现端具有哪些功能 30 | 31 | ## ✨ 核心特性 32 | 33 | * **🪶 轻量高效:** 没有太多冗杂的功能,使用简单,内存占用较低。 34 | * **🧩 轻松扩展:** 灵活的插件系统,让您能够轻松、快速地添加、移除或定制所需功能。 35 | * **🔁 基于线程池:** 基于内置线程池实现并发处理,没有异步的较为复杂的语法,直接编写同步代码。 36 | 37 | ## 🚨 重要提醒:关于重构与兼容性 38 | 39 | > [!CAUTION] 40 | > **请注意:** 本项目在 2024 年底至 2025 年初进行了一次 **彻底的重构**(主要涉及 `dev` 分支并在 2025年1月29日 合并至 `master`)。 41 | > 42 | > **当前的 MRB2 版本与重构前的旧版本插件完全不兼容。** 如果您是旧版本用户或拥有旧插件,请参考 **[最新文档](https://mrb2.xiaosu.icu)** 进行适配迁移。 43 | 44 | ## 📖 背景与术语 45 | 46 | * **MRB2:** MuRainBot2 的缩写。 47 | * **OneBot v11 协议:** 一个广泛应用于即时通讯软件中的聊天机器人的应用层协议标准,本项目基于此标准开发。详情请见 [OneBot v11](https://11.onebot.dev/)。 48 | * **框架:** MRB2 作为一个 OneBot 开发框架,负责处理与 OneBot 实现端的通信、事件分发、API 调用封装等底层工作,以及提供插件系统,让开发者可以专注于插件功能的实现。更多通用术语可参考 [OneBot v12 术语表](https://12.onebot.dev/glossary/) (v11 与 v12 大体相通)。 49 | * **插件:** MRB2 的所有功能都由插件提供。插件通常是放置在 `plugins` 目录下的 Python 文件或包含 `__init__.py` 的 Python 包。 50 | 51 | ~~*什么?你问我为什么要叫MRB2,因为这个框架最初是给我的一个叫做沐雨的bot写的,然后之前还有[一个写的很垃圾](https://github.com/xiaosuyyds/PyQQbot)的版本,所以就叫做MRB2*~~ 52 | 53 | ## 🐛 问题反馈 54 | 55 | 如果使用时遇到问题,请按以下步骤操作: 56 | 57 | 1. 将框架版本更新到 [`dev`](https://github.com/MuRainBot/MuRainBot2/tree/dev) 分支(可选,但推荐) 58 | 2. 将 `config.yml` 中的 `debug.enable` 设置为 `true`。 59 | 3. 复现您遇到的 Bug。 60 | 4. **检查 Onebot 实现端的日志**,确认问题是否源于实现端本身。如果是,请向您使用的实现端反馈。 61 | 5. 如果问题确认在 MRB2 框架: 62 | * 请准备**完整**的 MRB2 日志文件 (`logs` 目录下)。您可以自行遮挡日志中的 QQ 号、群号等敏感信息。 63 | * 提供清晰的错误描述、复现步骤。 64 | * 如果开启了 `save_dump` 且生成了 dump 文件,可以一并提供。(不强制,但是推荐提供,不过需要注意可以检查一下是否包含apikey等敏感信息) 65 | * 将当前使用的MRB2版本、日志、错误描述、复现步骤,以及dump文件(可选),提交到项目的 [**Issues**](https://github.com/MuRainBot/MuRainBot2/issues/new/choose) 页面。 66 | 67 | 如果不遵守以上要求,您的问题可能会被关闭或无视。 68 | 69 | ## 📁 目录结构 70 | 71 |
72 | 查看基本目录结构 73 | 74 | ``` 75 | ├─ data MRB2及插件的临时/缓存文件 76 | │ ├─ ... 77 | ├─ Lib MRB2的Lib库,插件和MRB2均需要依赖此Lib 78 | │ ├─ __init__.py MRB2Lib的初始化文件 79 | │ ├─ core 核心模块,负责配置文件读取、与实现端通信、插件加载等 80 | │ | ├─ ... 81 | │ ├─ utils 工具模块,实现一些偏工具类的功能,例如QQ信息缓存、日志记录、事件分类等 82 | │ | ├─ ... 83 | │ ... 84 | ├─ logs 85 | │ ├─ latest.log 当日的日志 86 | │ ├─ xxxx-xx-xx.log 以往的日志 87 | │ ... 88 | ├─ plugins 89 | │ ├─ xxx.py xxx插件代码 90 | │ ├─ yyy.py yyy插件代码 91 | │ ... 92 | ├─ plugin_configs 93 | │ ├─ xxx.yml xxx插件的配置文件 94 | │ ├─ yyy.yml yyy插件的配置文件 95 | │ ... 96 | ├─ config.yml MRB2配置文件 97 | ├─ main.py MRB2的入口文件 98 | └─ README.md 这个文件就不用解释了吧(?) 99 | ``` 100 | 101 |
102 | 103 | ## 💻 如何部署? 104 | 105 | **本项目使用 Python 3.12+ 开发,并利用了其部分新特性 (如 [PEP 701](https://docs.python.org/zh-cn/3/whatsnew/3.12.html#whatsnew312-pep701))。推荐使用 Python 3.12 或更高版本运行,如果使用 Python 3.12 以下版本,由于未经测试,可能会导致部分代码出现问题。** 106 | 107 | 详细的部署步骤、配置说明和插件开发指南,请查阅: 108 | 109 | ### ➡️ [**MRB2 官方文档**](https://mrb2.xiaosu.icu) 110 | 111 | ## 📕 关于版本 112 | 113 | * 当前版本:`1.0.0-dev` 114 | * 版本号格式:`<主版本>.<次版本>.<修订版本>[-<特殊标识>]` (例如 `1.0.0`, `1.0.1-beta`, `1.1.0-dev`)。 115 | 116 | ## ❤️ 鸣谢 ❤️ 117 | 118 | **贡献指南:** 我们欢迎各种形式的贡献!包括 Issues 和 Pull Request,您可以向我们反馈 bug 提供建议,也可以通过 PR 直接帮我们编写代码来实现功能或者修复bug。请将您的 Pull Request 提交到 `dev` 分支。我们会定期将 `dev` 分支的稳定更新合并到 `master` 分支。 119 | 120 | **感谢所有为 MRB2 付出努力的贡献者!** 121 | 122 | 123 | Contributors 124 | 125 | 126 | **特别感谢 [HarcicYang](https://github.com/HarcicYang)、[kaokao221](https://github.com/kaokao221) 和 [BigCookie233](https://github.com/BigCookie233) 在项目开发过程中提供的宝贵帮助!** 127 | 128 | ## ⭐ Star History ⭐ 129 | 130 | [![](https://api.star-history.com/svg?repos=MuRainBot/MuRainBot2&type=Date)](https://github.com/MuRainBot/MuRainBot2/stargazers) 131 | 132 | 133 | ## 🚀 关于性能 134 | 135 | 本项目在正常使用,默认配置,多群聊,6-8个中等复杂度(如签到、图片绘制(如视频信息展示等)、AI聊天(基于API接口调用的))的插件情况下内存占用稳定在 100-160MB 左右 136 | (具体取决于插件和群聊数量以及配置文件,也可能超过这个范围) 137 | 138 | 仅安装默认插件,默认配置,情况下内存占用稳定在 40MB-60MB 左右 139 | 140 | 如果实在内存不够用可调小缓存(配置文件中的 `qq_data_cache.max_cache_size`)(尽管这个也占不了多少内存) 141 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | # MuRainBot2配置文件 2 | account: # 账号相关 3 | user_id: 0 # QQ账号(留空则自动获取) 4 | nick_name: "" # 昵称(留空则自动获取) 5 | bot_admin: [] 6 | 7 | api: # Api设置(Onebot HTTP通信) 8 | host: '127.0.0.1' 9 | port: 5700 10 | access_token: "" # HTTP的Access Token,为空则不使用(详见https://github.com/botuniverse/onebot-11/blob/master/communication/authorization.md#http-%E5%92%8C%E6%AD%A3%E5%90%91-websocket) 11 | 12 | server: # 监听服务器设置(Onebot HTTP POST通信) 13 | host: '127.0.0.1' 14 | port: 5701 15 | server: 'werkzeug' # 使用的服务器(werkzeug或waitress,使用waitress需先pip install waitress) 16 | max_works: 4 # 最大工作线程数 17 | secret: "" # 上报数据签名密钥(详见https://github.com/botuniverse/onebot-11/blob/master/communication/http-post.md#%E7%AD%BE%E5%90%8D) 18 | 19 | thread_pool: # 线程池相关 20 | max_workers: 10 # 线程池最大线程数 21 | 22 | qq_data_cache: # QQ数据缓存设置 23 | enable: true # 是否启用缓存(非常不推荐关闭缓存,对于对于需要无缓存的场景,推荐在插件内自行调用api来获取而非关闭此配置项) 24 | expire_time: 300 # 缓存过期时间(秒) 25 | max_cache_size: 500 # 最大缓存数量(设置过大可能会导致报错) 26 | 27 | debug: # 调试模式,若启用框架的日志等级将被设置为debug,不建议在生产环境开启 28 | enable: false # 是否启用调试模式 29 | save_dump: true # 是否在发生异常的同时保存一个dump错误文件(不受debug.enable约束,独立开关,若要使用请先安装coredumpy库,不使用可不安装) 30 | 31 | auto_restart_onebot: # 在Onebot实现端状态异常时自动重启Onebot实现端(需开启心跳包) 32 | enable: true # 是否启用自动重启 33 | 34 | command: # 命令相关 35 | command_start: ["/"] # 命令起始符 36 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """ 2 | MuRainBot2 3 | """ 4 | 5 | # __ __ ____ _ ____ _ _____ 6 | # | \/ |_ _| _ \ __ _(_)_ __ | __ ) ___ | |_|___ \ 7 | # | |\/| | | | | |_) / _` | | '_ \ | _ \ / _ \| __| __) | 8 | # | | | | |_| | _ < (_| | | | | | | |_) | (_) | |_ / __/ 9 | # |_| |_|\__,_|_| \_\__,_|_|_| |_| |____/ \___/ \__|_____| 10 | import atexit 11 | import logging 12 | import random 13 | import threading 14 | import time 15 | 16 | BANNER = r""" __ __ ____ _ ____ _ _____ 17 | | \/ |_ _| _ \ __ _(_)_ __ | __ ) ___ | |_|___ \ 18 | | |\/| | | | | |_) / _` | | '_ \ | _ \ / _ \| __| __) | 19 | | | | | |_| | _ < (_| | | | | | | |_) | (_) | |_ / __/ 20 | |_| |_|\__,_|_| \_\__,_|_|_| |_| |____/ \___/ \__|_____|""" 21 | BANNER_LINK = "https://github.com/MuRainBot/MuRainBot2" 22 | 23 | 24 | def color_text(text: str, text_color: tuple[int, int, int] = None, bg_color: tuple[int, int, int] = None): 25 | """ 26 | 富文本生成器 27 | @param text: 文本 28 | @param text_color: 文本颜色 29 | @param bg_color: 背景颜色 30 | @return: 富文本 31 | """ 32 | text = text + "\033[0m" if text_color is not None or bg_color is not None else text 33 | if text_color is not None: 34 | text = f"\033[38;2;{text_color[0]};{text_color[1]};{text_color[2]}m" + text 35 | if bg_color is not None: 36 | text = f"\033[48;2;{bg_color[0]};{bg_color[1]};{bg_color[2]}m" + text 37 | return text 38 | 39 | 40 | def get_gradient(start_color: tuple[int, int, int], end_color: tuple[int, int, int], length: float): 41 | """ 42 | 渐变色生成 43 | @param start_color: 开始颜色 44 | @param end_color: 结束颜色 45 | @param length: 0-1的值 46 | @return: RGB颜色 47 | """ 48 | return ( 49 | int(start_color[0] + (end_color[0] - start_color[0]) * length), 50 | int(start_color[1] + (end_color[1] - start_color[1]) * length), 51 | int(start_color[2] + (end_color[2] - start_color[2]) * length) 52 | ) 53 | 54 | 55 | def print_loading(wait_str): 56 | """ 57 | 输出加载动画 58 | """ 59 | loading_string_list = [r"|/-\\", r"▁▂▃▄▅▆▇█▇▆▅▄▃▂", "\u2801\u2808\u2810\u2820\u2880\u2900\u2804\u2802", r"←↖↑↗→↘↓↙"] 60 | loading_string = random.choice(loading_string_list) 61 | i = 0 62 | while not is_done: 63 | if i == len(loading_string): 64 | i = 0 65 | print("\r" + wait_str + color_text(loading_string[i], banner_start_color), end="") 66 | time.sleep(0.07) 67 | i += 1 68 | 69 | 70 | # 主函数 71 | if __name__ == '__main__': 72 | banner_start_color = (14, 190, 255) 73 | banner_end_color = (255, 66, 179) 74 | banner = BANNER.split("\n") 75 | color_banner = "" 76 | # 输出banner 77 | for i in range(len(banner)): 78 | for j in range(len(banner[i])): 79 | color_banner += color_text( 80 | banner[i][j], 81 | get_gradient( 82 | banner_start_color, 83 | banner_end_color, 84 | ((j / (len(banner[i]) - 1) + i / (len(banner) - 1)) / 2) 85 | ) 86 | ) 87 | color_banner += "\n" 88 | 89 | print(color_banner.strip()) 90 | 91 | # 输出项目链接 92 | for c in color_text(BANNER_LINK, get_gradient(banner_start_color, banner_end_color, 0.5)): 93 | print(c, end="") 94 | 95 | wait_str = color_text("正在加载 Lib, 首次启动可能需要几秒钟,请稍等...", banner_start_color) 96 | print("\n" + wait_str, end="") 97 | is_done = False 98 | 99 | threading.Thread(target=print_loading, daemon=True, args=(wait_str,)).start() 100 | 101 | # 开始加载 102 | start_loading = time.time() 103 | 104 | from Lib.utils import Logger 105 | 106 | Logger.init() 107 | 108 | from Lib.core import * 109 | 110 | from Lib import * 111 | 112 | atexit.register(common.finalize_and_cleanup) 113 | 114 | ThreadPool.init() 115 | 116 | Logger.set_logger_level(logging.DEBUG if ConfigManager.GlobalConfig().debug.enable else logging.INFO) 117 | 118 | is_done = True 119 | 120 | print("\r" + color_text( 121 | f"Lib 加载完成!耗时: {round(time.time() - start_loading, 2)}s 正在启动 MuRainBot... ", 122 | banner_end_color 123 | ) 124 | ) 125 | 126 | logger = Logger.get_logger() 127 | 128 | logger.info("日志初始化完成,MuRainBot正在启动...") 129 | 130 | if ConfigManager.GlobalConfig().account.user_id == 0 or not ConfigManager.GlobalConfig().account.nick_name: 131 | logger.info("正在尝试获取用户信息...") 132 | try: 133 | account = OnebotAPI.api.get_login_info() 134 | new_account = ConfigManager.GlobalConfig().config.get("account") 135 | new_account.update({ 136 | "user_id": account['user_id'], 137 | "nick_name": account['nickname'] 138 | }) 139 | 140 | ConfigManager.GlobalConfig().set("account", new_account) 141 | except Exception as e: 142 | logger.warning(f"获取用户信息失败, 可能会导致严重的问题: {repr(e)}") 143 | 144 | logger.info(f"欢迎使用: {ConfigManager.GlobalConfig().account.nick_name}" 145 | f"({ConfigManager.GlobalConfig().account.user_id})") 146 | 147 | logger.debug(f"准备加载插件") 148 | PluginManager.load_plugins() 149 | logger.info(f"插件加载完成!共成功加载了 {len(PluginManager.plugins)} 个插件" 150 | f"{': \n' if len(PluginManager.plugins) >= 1 else ''}" 151 | f"{'\n'.join( 152 | [ 153 | f'{_['name']}: {_['info'].NAME}' if 'info' in _ and _['info'] else _['name'] 154 | for _ in PluginManager.plugins 155 | ] 156 | )}") 157 | 158 | threading.Thread(target=AutoRestartOnebot.check_heartbeat, daemon=True).start() 159 | 160 | logger.info(f"启动监听服务器: {ConfigManager.GlobalConfig().server.server}") 161 | 162 | if ConfigManager.GlobalConfig().server.server == "werkzeug": 163 | # 禁用werkzeug的日志记录 164 | log = logging.getLogger('werkzeug') 165 | log.disabled = True 166 | 167 | threading.Thread(target=ListenerServer.start_server, daemon=True).start() 168 | 169 | try: 170 | while True: 171 | time.sleep(1) 172 | except KeyboardInterrupt: 173 | logger.info("正在关闭...") 174 | -------------------------------------------------------------------------------- /plugins/Helper.py: -------------------------------------------------------------------------------- 1 | """ 2 | MRB2示例插件 - 帮助插件 3 | """ 4 | 5 | 6 | # __ __ ____ _ ____ _ 7 | # | \/ |_ _| _ \ __ _(_)_ __ | __ ) ___ | |_ 8 | # | |\/| | | | | |_) / _` | | '_ \ | _ \ / _ \| __| 9 | # | | | | |_| | _ < (_| | | | | | | |_) | (_) | |_ 10 | # |_| |_|\__,_|_| \_\__,_|_|_| |_| |____/ \___/ \__| 11 | 12 | from Lib import * 13 | from Lib.core import PluginManager, ConfigManager 14 | 15 | logger = Logger.get_logger() 16 | 17 | plugin_info = PluginManager.PluginInfo( 18 | NAME="Helper", 19 | AUTHOR="Xiaosu", 20 | VERSION="1.0.0", 21 | DESCRIPTION="用于获取插件帮助信息", 22 | HELP_MSG="发送 /help 或 /帮助 以获取所有插件的帮助信息" 23 | ) 24 | 25 | 26 | @common.function_cache(1) 27 | def get_help_text(): 28 | """ 29 | 获取所有插件的帮助信息 30 | Returns: 31 | str: 帮助信息 32 | """ 33 | plugins = PluginManager.plugins 34 | text = f"{ConfigManager.GlobalConfig().account.nick_name} 帮助" 35 | for plugin in plugins: 36 | try: 37 | plugin_info = plugin["info"] 38 | if plugin_info.DESCRIPTION and plugin_info.IS_HIDDEN is False: 39 | text += f"\n{plugin_info.NAME} - {plugin_info.DESCRIPTION}" 40 | except Exception as e: 41 | logger.warning(f"获取插件{plugin['name']}信息时发生错误: {repr(e)}") 42 | text += "\n----------\n发送/help <插件名>或/帮助 <插件名>以获取插件详细帮助信息" 43 | return text 44 | 45 | 46 | rule = EventHandlers.CommandRule("help", aliases={"帮助"}) 47 | 48 | matcher = EventHandlers.on_event(EventClassifier.MessageEvent, priority=0, rules=[rule]) 49 | 50 | 51 | @matcher.register_handler() 52 | def on_help(event_data: EventClassifier.MessageEvent): 53 | """ 54 | 帮助命令处理 55 | """ 56 | cmd = str(event_data.message).strip().split(" ", 1) 57 | if len(cmd) == 1: 58 | Actions.SendMsg( 59 | message=QQRichText.QQRichText( 60 | QQRichText.Reply(event_data["message_id"]), 61 | QQRichText.Text(get_help_text()) 62 | ), 63 | **{"group_id": event_data["group_id"]} 64 | if event_data["message_type"] == "group" else 65 | {"user_id": event_data["user_id"]} 66 | ).call() 67 | else: 68 | plugin_name = cmd[1].lower() 69 | for plugin in PluginManager.plugins: 70 | try: 71 | plugin_info = plugin["info"] 72 | if plugin_info is None: 73 | continue 74 | if plugin_info.NAME.lower() == plugin_name and plugin_info.IS_HIDDEN is False: 75 | Actions.SendMsg( 76 | message=QQRichText.QQRichText( 77 | QQRichText.Reply(event_data["message_id"]), 78 | QQRichText.Text(plugin_info.HELP_MSG + "\n----------\n发送/help以获取全部的插件帮助信息") 79 | ), 80 | **{"group_id": event_data["group_id"]} 81 | if event_data["message_type"] == "group" else 82 | {"user_id": event_data["user_id"]} 83 | ).call() 84 | return 85 | except Exception as e: 86 | logger.warning(f"获取插件{plugin['name']}信息时发生错误: {repr(e)}") 87 | continue 88 | else: 89 | Actions.SendMsg( 90 | message=QQRichText.QQRichText( 91 | QQRichText.Reply(event_data["message_id"]), 92 | QQRichText.Text("没有找到此插件,请检查是否有拼写错误") 93 | ), 94 | **{"group_id": event_data["group_id"]} 95 | if event_data["message_type"] == "group" else 96 | {"user_id": event_data["user_id"]} 97 | ).call() 98 | -------------------------------------------------------------------------------- /plugins/LagrangeExtension/Events.py: -------------------------------------------------------------------------------- 1 | """ 2 | Lagrange的一些拓展事件 3 | """ 4 | from typing import TypedDict 5 | 6 | from Lib.utils import EventClassifier, Logger, QQDataCacher 7 | 8 | logger = Logger.get_logger() 9 | 10 | 11 | @EventClassifier.register_event("notice", notice_type="bot_online") 12 | class BotOnLineEvent(EventClassifier.NoticeEvent): 13 | def __init__(self, event_data): 14 | super().__init__(event_data) 15 | self.reason: str = event_data["reason"] 16 | 17 | def logger(self): 18 | logger.info(f"机器人上线: {self.reason}") 19 | 20 | 21 | @EventClassifier.register_event("notice", notice_type="bot_offline") 22 | class BotOffLineEvent(EventClassifier.NoticeEvent): 23 | def __init__(self, event_data): 24 | super().__init__(event_data) 25 | self.tag: str = event_data["tag"] 26 | self.message: str = event_data["message"] 27 | 28 | def logger(self): 29 | logger.warning(f"机器人离线: {self.tag} {self.message}") 30 | 31 | 32 | @EventClassifier.register_event("notice", notice_type="group_name_change") 33 | class GroupNameChangeEvent(EventClassifier.NoticeEvent): 34 | def __init__(self, event_data): 35 | super().__init__(event_data) 36 | self.group_id: int = int(self["group_id"]) 37 | self.name: str = self["name"] 38 | 39 | def logger(self): 40 | logger.info(f"群 {self.group_id} 的名称改变为: {self.name}") 41 | 42 | 43 | @EventClassifier.register_event("notice", notice_type="essence") 44 | class GroupEssenceEvent(EventClassifier.NoticeEvent): 45 | def __init__(self, event_data): 46 | super().__init__(event_data) 47 | self.group_id: int = int(self["group_id"]) 48 | self.sender_id: int = int(self["sender_id"]) 49 | self.operator_id: int = int(self["operator_id"]) 50 | self.message_id: int = int(self["message_id"]) 51 | self.sub_type: str = self["sub_type"] 52 | 53 | 54 | @EventClassifier.register_event("notice", notice_type="essence", sub_type="add") 55 | class GroupAddEssenceEvent(GroupEssenceEvent): 56 | def logger(self): 57 | logger.info( 58 | f"群 {QQDataCacher.get_group_info(self.group_id).group_name}" 59 | f"({self.group_id}) " 60 | f"内 " 61 | f"{QQDataCacher.get_group_member_info(self.group_id, self.sender_id).get_nickname()}" 62 | f"({self.sender_id}) " 63 | f"的消息 {self.message_id} 被 " 64 | f"{QQDataCacher.get_group_member_info(self.group_id, self.operator_id).get_nickname()}" 65 | f"({self.operator_id}) " 66 | f"设置为精华消息" 67 | ) 68 | 69 | 70 | @EventClassifier.register_event("notice", notice_type="essence", sub_type="delete") 71 | class GroupDeleteEssenceEvent(GroupEssenceEvent): 72 | def logger(self): 73 | logger.info( 74 | f"群 {QQDataCacher.get_group_info(self.group_id).group_name}" 75 | f"({self.group_id}) " 76 | f"内 " 77 | f"{QQDataCacher.get_group_member_info(self.group_id, self.sender_id).get_nickname()}" 78 | f"({self.sender_id}) " 79 | f"的消息 {self.message_id} 被 " 80 | f"{QQDataCacher.get_group_member_info(self.group_id, self.operator_id).get_nickname()}" 81 | f"({self.operator_id}) " 82 | f"取消了精华消息" 83 | ) 84 | 85 | 86 | @EventClassifier.register_event("notice", notice_type="reaction") 87 | class GroupReactionEvent(EventClassifier.NoticeEvent): 88 | def __init__(self, event_data): 89 | super().__init__(event_data) 90 | self.group_id: int = int(self["group_id"]) 91 | self.message_id: int = int(self["message_id"]) 92 | self.operator_id: int = int(self["operator_id"]) 93 | self.sub_type: str = self["sub_type"] 94 | self.count: int = self["count"] 95 | self.code: int = self["code"] 96 | 97 | 98 | @EventClassifier.register_event("notice", notice_type="reaction", sub_type="add") 99 | class GroupAddReactionEvent(GroupReactionEvent): 100 | def logger(self): 101 | logger.info( 102 | f"群 {QQDataCacher.get_group_info(self.group_id).group_name}" 103 | f"({self.group_id}) " 104 | f"内 " 105 | f"{QQDataCacher.get_group_member_info(self.group_id, self.operator_id).get_nickname()}" 106 | f"({self.operator_id}) " 107 | f"给消息 {self.message_id} 添加了表情 {self.code} (共计{self.count}个)" 108 | ) 109 | 110 | 111 | @EventClassifier.register_event("notice", notice_type="reaction", sub_type="remove") 112 | class GroupDeleteReactionEvent(GroupReactionEvent): 113 | def logger(self): 114 | logger.info( 115 | f"群 {QQDataCacher.get_group_info(self.group_id).group_name}" 116 | f"({self.group_id}) " 117 | f"内 " 118 | f"{QQDataCacher.get_group_member_info(self.group_id, self.operator_id).get_nickname()}" 119 | f"({self.operator_id}) " 120 | f"给消息 {self.message_id} 删除了表情 {self.code} (还有{self.count}个)" 121 | ) 122 | 123 | 124 | class FileDict(TypedDict, total=False): 125 | """ 126 | 文件数据 127 | """ 128 | id: str 129 | name: str 130 | size: int 131 | url: str 132 | hash: int 133 | 134 | 135 | @EventClassifier.register_event("notice", notice_type="offline_file") 136 | class PrivateFileEvent(EventClassifier.NoticeEvent): 137 | """ 138 | 私聊文件发送事件 139 | """ 140 | 141 | def __init__(self, event_data): 142 | super().__init__(event_data) 143 | self.user_id: int = int(self["user_id"]) 144 | self.file: FileDict = self["file"] 145 | 146 | def logger(self): 147 | logger.info( 148 | f"用户 " 149 | f"{QQDataCacher.get_user_info(self.user_id).get_nickname()} " 150 | f"({self.user_id}) " 151 | f"上传了文件: " 152 | f"{self.file['name']}" 153 | f"({self.file['id']})\n" 154 | f"URL: {self.file['url']}" 155 | ) 156 | -------------------------------------------------------------------------------- /plugins/LagrangeExtension/Segments.py: -------------------------------------------------------------------------------- 1 | """ 2 | Lagrange的拓展消息段 3 | """ 4 | 5 | from Lib.utils import QQRichText 6 | 7 | 8 | class MFace(QQRichText.Segment): 9 | """ 10 | 商城表情消息段 11 | """ 12 | 13 | segment_type = "mface" 14 | 15 | def __init__(self, emoji_package_id: int, emoji_id: int, key: str, summary: str, url: str = None): 16 | """ 17 | Args: 18 | emoji_package_id: 表情包 ID 19 | emoji_id: 表情 ID 20 | key: 表情 Key 21 | summary: 表情说明 22 | url: 表情 Url(可选) 23 | """ 24 | self.emoji_package_id = emoji_package_id 25 | self.emoji_id = emoji_id 26 | self.key = key 27 | self.summary = summary 28 | super().__init__({"type": "mface", "data": {"emoji_package_id": emoji_package_id, "emoji_id": emoji_id, 29 | "key": key, "summary": summary}}) 30 | if url: 31 | self.url = url 32 | self.array["data"]["url"] = url 33 | 34 | def set_url(self, url: str): 35 | """ 36 | 设置表情 Url 37 | Args: 38 | url: 表情 Url 39 | Returns: 40 | None 41 | """ 42 | self.url = url 43 | self.data["data"]["url"] = url 44 | 45 | def set_emoji_id(self, emoji_id: int): 46 | """ 47 | 设置表情 ID 48 | Args: 49 | emoji_id: 表情 ID 50 | Returns: 51 | None 52 | """ 53 | self.emoji_id = emoji_id 54 | self.data["data"]["emoji_id"] = emoji_id 55 | 56 | def set_emoji_package_id(self, emoji_package_id: int): 57 | """ 58 | 设置表情包 ID 59 | Args: 60 | emoji_package_id: 表情包 ID 61 | Returns: 62 | None 63 | """ 64 | self.emoji_package_id = emoji_package_id 65 | self.data["data"]["emoji_package_id"] = emoji_package_id 66 | 67 | def set_key(self, key: str): 68 | """ 69 | 设置表情 Key 70 | Args: 71 | key: 表情 Key 72 | Returns: 73 | None 74 | """ 75 | self.key = key 76 | self.data["data"]["key"] = key 77 | 78 | def render(self, group_id: int | None = None): 79 | return f"[mface: {self.summary}({self.emoji_package_id}:{self.emoji_id}:{self.key}):{self.url}]" 80 | 81 | 82 | class File(QQRichText.Segment): 83 | """ 84 | 文件消息段 85 | """ 86 | 87 | segment_type = "file" 88 | 89 | def __init__(self, file_name: str, file_id: str, file_hash: int, url: str): 90 | """ 91 | Args: 92 | file_name: 文件名 93 | file_id: 文件 ID 94 | file_hash: 文件 Hash 95 | url: 下载链接 96 | """ 97 | self.file_name = file_name 98 | self.file_id = file_id 99 | self.file_hash = file_hash 100 | self.url = url 101 | 102 | super().__init__({"type": "file", "data": {"file_name": file_name, "file_id": file_id, "file_hash": file_hash, 103 | "url": url}}) 104 | 105 | def set_file_name(self, file_name: str): 106 | """ 107 | 设置文件名 108 | Args: 109 | file_name: 文件名 110 | Returns: 111 | None 112 | """ 113 | self.file_name = file_name 114 | self.data["data"]["file_name"] = file_name 115 | 116 | def set_file_id(self, file_id: str): 117 | """ 118 | 设置文件 ID 119 | Args: 120 | file_id: 文件 ID 121 | Returns: 122 | None 123 | """ 124 | self.file_id = file_id 125 | self.data["data"]["file_id"] = file_id 126 | 127 | def set_file_hash(self, file_hash: int): 128 | """ 129 | 设置文件 Hash 130 | Args: 131 | file_hash: 文件 Hash 132 | Returns: 133 | None 134 | """ 135 | self.file_hash = file_hash 136 | self.data["data"]["file_hash"] = file_hash 137 | 138 | def set_url(self, url: str): 139 | """ 140 | 设置下载链接 141 | Args: 142 | url: 下载链接 143 | Returns: 144 | None 145 | """ 146 | self.url = url 147 | self.data["data"]["url"] = url 148 | 149 | def render(self, group_id: int | None = None): 150 | return f"[file: {self.file_name}({self.file_id}:{self.file_hash}):{self.url}]" 151 | -------------------------------------------------------------------------------- /plugins/LagrangeExtension/__init__.py: -------------------------------------------------------------------------------- 1 | # __ __ ____ _ ____ _ 2 | # | \/ |_ _| _ \ __ _(_)_ __ | __ ) ___ | |_ 3 | # | |\/| | | | | |_) / _` | | '_ \ | _ \ / _ \| __| 4 | # | | | | |_| | _ < (_| | | | | | | |_) | (_) | |_ 5 | # |_| |_|\__,_|_| \_\__,_|_|_| |_| |____/ \___/ \__| 6 | 7 | """ 8 | Lagrange实现端扩展插件 9 | 可能更新不及时,如果有偏差请再issue内告诉我或者直接跟我提pr 10 | """ 11 | 12 | from Lib.core import PluginManager 13 | 14 | plugin_info = PluginManager.PluginInfo( 15 | NAME="LagrangeExtension", 16 | AUTHOR="Xiaosu", 17 | VERSION="1.0.0", 18 | DESCRIPTION="用于支持一些Lagrange扩展的消息段和操作", 19 | HELP_MSG="", 20 | IS_HIDDEN=True 21 | ) 22 | 23 | 24 | from plugins.LagrangeExtension import Actions, Segments, Events 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | coloredlogs~=15.0.1 2 | requests~=2.32.3 3 | PyYAML~=6.0.2 4 | Flask~=3.1.0 5 | Werkzeug~=3.1.3 6 | coredumpy~=0.4.3 -------------------------------------------------------------------------------- /testing.py: -------------------------------------------------------------------------------- 1 | # __ __ ____ _ ____ _ _____ 2 | # | \/ |_ _| _ \ __ _(_)_ __ | __ ) ___ | |_|___ \ 3 | # | |\/| | | | | |_) / _` | | '_ \ | _ \ / _ \| __| __) | 4 | # | | | | |_| | _ < (_| | | | | | | |_) | (_) | |_ / __/ 5 | # |_| |_|\__,_|_| \_\__,_|_|_| |_| |____/ \___/ \__|_____| 6 | """ 7 | 一个MuRainBot2的虚拟onebot实现端服务器 8 | """ 9 | import threading 10 | import time 11 | 12 | import requests 13 | from flask import Flask, request 14 | 15 | api_port = 5700 16 | listening_port = 5701 17 | login_info = { 18 | "user_id": 1919810114514, 19 | "nickname": "MuRainBot2" 20 | } 21 | test_environment_info = { 22 | "type": "group", # private, group 23 | "id": 1234567890, # 群/好友ID 24 | "name": "测试群", # 群/好友昵称 25 | "sender_info": { 26 | "user_id": 11111111, 27 | "nickname": "测试用户", 28 | "card": "测试群昵称", 29 | "sex": "unknown", 30 | "age": 0, 31 | "join_time": 0, 32 | "level": 0, 33 | "area": "", 34 | "role": "member", # owner, admin, member 35 | "unfriendly": False, 36 | "title": "测试头衔", 37 | "title_expire_time": -1, 38 | "card_changeable": False 39 | }, 40 | "member_count": 2, 41 | "max_member_count": 2, 42 | } 43 | 44 | app = Flask(__name__) 45 | 46 | 47 | def get_response(data): 48 | return { 49 | "status": "ok", 50 | "retcode": 0, 51 | "data": data 52 | } 53 | 54 | 55 | now_message_id = 0 56 | 57 | 58 | @app.route("/", methods=["GET", "POST"]) 59 | def index(node): 60 | global now_message_id 61 | args = request.args if request.method == "GET" else request.json 62 | if node == "send_private_msg": 63 | user_id = args.get('user_id') 64 | message = args.get('message') 65 | if user_id is not None and message is not None: 66 | now_message_id += 1 67 | print(f"向用户{user_id}发送消息:{message}({now_message_id})") 68 | return get_response({"message_id": now_message_id}) 69 | else: 70 | return "ok", 400 71 | elif node == "send_group_msg": 72 | group_id = args.get('group_id') 73 | message = args.get('message') 74 | if group_id is not None and message is not None: 75 | now_message_id += 1 76 | print(f"向群{group_id}发送消息:{message}({now_message_id})") 77 | return get_response({"message_id": now_message_id}) 78 | else: 79 | return "ok", 400 80 | elif node == "send_msg": 81 | message_type = args.get('message_type') 82 | user_id = args.get('user_id') 83 | group_id = args.get('group_id') 84 | message = args.get('message') 85 | if message_type is None: 86 | if user_id is not None: 87 | message_type = "private" 88 | elif group_id is not None: 89 | message_type = "group" 90 | else: 91 | return "ok", 400 92 | if message_type == "private" and user_id is not None: 93 | req = requests.get(f"http://127.0.0.1:{api_port}/send_private_msg?user_id={user_id}&message={message}") 94 | return req.json(), req.status_code 95 | elif message_type == "group" and group_id is not None: 96 | req = requests.get(f"http://127.0.0.1:{api_port}/send_group_msg?group_id={group_id}&message={message}") 97 | return req.json(), req.status_code 98 | else: 99 | return "ok", 400 100 | elif node == "get_stranger_info": 101 | user_id = args.get('user_id') 102 | if user_id is not None: 103 | return get_response({ 104 | "user_id": user_id, 105 | "nickname": test_environment_info["sender_info"]["nickname"], 106 | "remark": test_environment_info["sender_info"]["card"] 107 | }) 108 | else: 109 | return "ok", 400 110 | elif node == "get_friend_list": 111 | return get_response([{ 112 | "user_id": test_environment_info["sender_info"]["user_id"], 113 | "nickname": test_environment_info["sender_info"]["nickname"], 114 | "remark": test_environment_info["sender_info"]["card"] 115 | }]) 116 | elif node == "get_version_info": 117 | return get_response({ 118 | "app_name": "MuRainBot2", 119 | "app_version": "0.0.1", 120 | "protocol_version": "v11", 121 | }) 122 | elif node == "get_group_list": 123 | return get_response([{ 124 | "group_id": test_environment_info["id"], 125 | "group_name": test_environment_info["name"], 126 | "member_count": test_environment_info["member_count"], 127 | "max_member_count": test_environment_info["max_member_count"] 128 | }]) 129 | elif node == "get_group_info": 130 | group_id = args.get('group_id') 131 | if group_id is not None: 132 | return get_response({ 133 | "group_id": group_id, 134 | "group_name": test_environment_info["name"], 135 | "member_count": test_environment_info["member_count"], 136 | "max_member_count": test_environment_info["max_member_count"] 137 | }) 138 | else: 139 | return "ok", 400 140 | elif node == "get_group_member_info": 141 | user_id = args.get('user_id') 142 | group_id = args.get('group_id') 143 | if user_id is not None and group_id is not None: 144 | return get_response(test_environment_info["sender_info"]) 145 | else: 146 | return "ok", 400 147 | elif node == "get_group_member_list": 148 | group_id = args.get('group_id') 149 | if group_id is not None: 150 | return get_response([test_environment_info["sender_info"]]) 151 | else: 152 | return "ok", 400 153 | elif node == "get_login_info": 154 | return get_response(login_info) 155 | else: 156 | return "ok", 404 157 | 158 | 159 | # 启动api服务器线程 160 | if __name__ == "__main__": 161 | app_run = threading.Thread(target=lambda: app.run(port=api_port), daemon=True) 162 | app_run.start() 163 | while True: 164 | input_message = input("请输入要发送的消息") 165 | now_message_id += 1 166 | 167 | data = { 168 | "time": time.time(), 169 | "post_type": "message", 170 | "self_id": login_info["user_id"], 171 | "message_type": test_environment_info["type"], 172 | "sub_type": "friend" if test_environment_info["type"] == "private" else "normal", 173 | "message_id": now_message_id, 174 | "user_id": test_environment_info["id"], 175 | "message": input_message, 176 | "raw_message": input_message, 177 | "font": 0, 178 | "sender": { 179 | "user_id": test_environment_info["id"], 180 | "nickname": test_environment_info["sender_info"]["nickname"], 181 | "sex": test_environment_info["sender_info"]["sex"], 182 | "age": test_environment_info["sender_info"]["age"] 183 | } 184 | } 185 | if test_environment_info["type"] == "group": 186 | data["group_id"] = test_environment_info["id"] 187 | data["user_id"] = test_environment_info["sender_info"]["user_id"] 188 | data["anonymous"] = None 189 | data["sender"]["card"] = test_environment_info["sender_info"]["card"] 190 | data["sender"]["area"] = test_environment_info["sender_info"]["area"] 191 | data["sender"]["role"] = test_environment_info["sender_info"]["role"] 192 | data["sender"]["level"] = test_environment_info["sender_info"]["level"] 193 | data["sender"]["title"] = test_environment_info["sender_info"]["title"] 194 | url = f"http://127.0.0.1:{listening_port}" 195 | requests.post(url, json=data) 196 | --------------------------------------------------------------------------------